@nifrajs/core 5.6.0 → 5.8.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 +99 -0
- package/README.md +5 -0
- package/dist/binary.d.ts +15 -1
- package/dist/binary.d.ts.map +1 -1
- package/dist/binary.js +1 -0
- package/dist/binary.js.map +1 -1
- package/dist/edge-kit.d.ts +1 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +1 -0
- package/dist/edge-kit.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 +2 -0
- package/dist/evidence.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/hook-origins.d.ts +2 -0
- package/dist/internal/hook-origins.d.ts.map +1 -1
- package/dist/internal/hook-origins.js +12 -0
- package/dist/internal/hook-origins.js.map +1 -1
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +12 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/answer-step.d.ts +27 -0
- package/dist/server/answer-step.d.ts.map +1 -0
- package/dist/server/answer-step.js +33 -0
- package/dist/server/answer-step.js.map +1 -0
- package/dist/server/base64-answers.d.ts +5 -0
- package/dist/server/base64-answers.d.ts.map +1 -0
- package/dist/server/base64-answers.js +132 -0
- package/dist/server/base64-answers.js.map +1 -0
- package/dist/server/core-copies.d.ts.map +1 -1
- package/dist/server/core-copies.js +5 -2
- package/dist/server/core-copies.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/json-response.d.ts +3 -0
- package/dist/server/json-response.d.ts.map +1 -0
- package/dist/server/json-response.js +14 -0
- package/dist/server/json-response.js.map +1 -0
- package/dist/server/json-text.d.ts +16 -0
- package/dist/server/json-text.d.ts.map +1 -0
- package/dist/server/json-text.js +34 -0
- package/dist/server/json-text.js.map +1 -0
- package/dist/server/node-outcome.d.ts +5 -0
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +3 -4
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -2
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +2 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +27 -24
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +2 -1
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/route-template.d.ts +10 -0
- package/dist/server/route-template.d.ts.map +1 -0
- package/dist/server/route-template.js +21 -0
- package/dist/server/route-template.js.map +1 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +2 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +26 -6
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +22 -10
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +109 -45
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +2 -1
- package/dist/server/sse.js.map +1 -1
- package/dist/server/text-slots.d.ts +8 -0
- package/dist/server/text-slots.d.ts.map +1 -0
- package/dist/server/text-slots.js +19 -0
- package/dist/server/text-slots.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +5 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +26 -17
- package/dist/single-copy.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +1 -1
- package/src/binary.ts +17 -0
- package/src/edge-kit.ts +1 -0
- package/src/errors.ts +1 -0
- package/src/evidence.ts +4 -0
- package/src/index.ts +2 -1
- package/src/internal/hook-origins.ts +10 -0
- package/src/reflection.ts +18 -0
- package/src/server/answer-step.ts +68 -0
- package/src/server/base64-answers.ts +138 -0
- package/src/server/core-copies.ts +5 -2
- package/src/server/install.ts +3 -0
- package/src/server/json-response.ts +13 -0
- package/src/server/json-text.ts +34 -0
- package/src/server/node-outcome.ts +4 -5
- package/src/server/request-context.ts +4 -2
- package/src/server/respond.ts +27 -18
- package/src/server/response-contract-lane.ts +2 -1
- package/src/server/route-template.ts +24 -0
- package/src/server/runtime-core.ts +2 -1
- package/src/server/server-types.ts +27 -4
- package/src/server/server.ts +131 -63
- package/src/server/sse.ts +2 -1
- package/src/server/text-slots.ts +21 -0
- package/src/server.ts +1 -0
- package/src/single-copy.ts +31 -15
- package/src/transport-codec.ts +3 -1
package/src/server/server.ts
CHANGED
|
@@ -69,7 +69,12 @@ import type {
|
|
|
69
69
|
StandardResult,
|
|
70
70
|
StandardSchemaV1,
|
|
71
71
|
} from "../schema/standard.ts"
|
|
72
|
-
import
|
|
72
|
+
import { withAnswerSteps } from "./answer-step.ts"
|
|
73
|
+
import {
|
|
74
|
+
type AuthenticationStage,
|
|
75
|
+
isAuthenticatedRequest,
|
|
76
|
+
markAuthenticatedRequest,
|
|
77
|
+
} from "./auth.ts"
|
|
73
78
|
import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
|
|
74
79
|
import { LAST_BEFORE_HANDLE } from "./before-handler.ts"
|
|
75
80
|
import {
|
|
@@ -158,6 +163,7 @@ import {
|
|
|
158
163
|
type StatusResponse,
|
|
159
164
|
} from "./runtime-core.ts"
|
|
160
165
|
import { normalizeStaticResponseHeaders, type StaticResponseHeaders } from "./static-headers.ts"
|
|
166
|
+
import { withTextSlots } from "./text-slots.ts"
|
|
161
167
|
import { plainValidationError } from "./validation.ts"
|
|
162
168
|
|
|
163
169
|
// NodeServeOutcome (the nifra<->node bridge render form) now lives in `./node-outcome.ts`; re-exported
|
|
@@ -191,6 +197,7 @@ import {
|
|
|
191
197
|
NODE_NATIVE_MOUNT,
|
|
192
198
|
REPORT_ERROR,
|
|
193
199
|
RESOLVE_NODE_MOUNT,
|
|
200
|
+
SERVER_LIFECYCLE,
|
|
194
201
|
} from "./install.ts"
|
|
195
202
|
import type { EffectLedgerRuntime } from "./ledger-lane.ts"
|
|
196
203
|
import { jsonLogger, type Logger } from "./logger.ts"
|
|
@@ -233,6 +240,7 @@ import type {
|
|
|
233
240
|
RunningServer,
|
|
234
241
|
ServerOptions,
|
|
235
242
|
StopHook,
|
|
243
|
+
StopOptions,
|
|
236
244
|
ToolAnnotations,
|
|
237
245
|
} from "./server-types.ts"
|
|
238
246
|
import type { SSEInit, TypedSSEStream } from "./sse.ts"
|
|
@@ -572,6 +580,7 @@ export type {
|
|
|
572
580
|
RunningServer,
|
|
573
581
|
ServerOptions,
|
|
574
582
|
StopHook,
|
|
583
|
+
StopOptions,
|
|
575
584
|
ToolAnnotations,
|
|
576
585
|
}
|
|
577
586
|
|
|
@@ -704,7 +713,8 @@ function resolvedSource(req: Request, url: string): RequestSource {
|
|
|
704
713
|
const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i
|
|
705
714
|
|
|
706
715
|
// Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
|
|
707
|
-
|
|
716
|
+
// `null` until one passes, so an empty Host is never taken as the cached one.
|
|
717
|
+
let lastValidHost: string | null = null
|
|
708
718
|
|
|
709
719
|
// A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
|
|
710
720
|
// no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
|
|
@@ -724,10 +734,11 @@ const invalidHost = (host: string): boolean => {
|
|
|
724
734
|
* its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
|
|
725
735
|
* matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
|
|
726
736
|
* `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
|
|
727
|
-
* header.
|
|
737
|
+
* header. On Bun only a target that needs resolving is checked unless `validateHost` is set.
|
|
728
738
|
*/
|
|
729
|
-
const requestTarget = (req: Request): string | boolean => {
|
|
730
|
-
if (!rawRequestTargets && !unparsedBunTargets
|
|
739
|
+
const requestTarget = (req: Request, validateHost: boolean): string | boolean => {
|
|
740
|
+
if (!rawRequestTargets && !(unparsedBunTargets && (validateHost || needsResolving(req.url))))
|
|
741
|
+
return false
|
|
731
742
|
const host = headerOf(req, "host")
|
|
732
743
|
if (host !== null && invalidHost(host)) {
|
|
733
744
|
if (rawRequestTargets) return true
|
|
@@ -778,12 +789,15 @@ function compareMounts<Env>(a: FetchMount<Env>, b: FetchMount<Env>): number {
|
|
|
778
789
|
return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order
|
|
779
790
|
}
|
|
780
791
|
|
|
792
|
+
/** Rebase a mounted request's path without losing the authentication mark used by child caches. */
|
|
781
793
|
function stripMountPrefix(request: Request, prefix: string): Request {
|
|
782
794
|
if (prefix === "/") return request
|
|
783
795
|
const url = new URL(request.url)
|
|
784
796
|
const rest = url.pathname.slice(prefix.length)
|
|
785
797
|
url.pathname = rest === "" ? "/" : rest
|
|
786
|
-
|
|
798
|
+
const childRequest = new Request(url.href, request)
|
|
799
|
+
if (isAuthenticatedRequest(request)) markAuthenticatedRequest(childRequest)
|
|
800
|
+
return childRequest
|
|
787
801
|
}
|
|
788
802
|
|
|
789
803
|
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
@@ -1258,6 +1272,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1258
1272
|
private readonly formOrigins: FormOrigins | undefined
|
|
1259
1273
|
/** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
|
|
1260
1274
|
private readonly trustBodyFraming: boolean
|
|
1275
|
+
private readonly validateHost: boolean
|
|
1261
1276
|
private readonly wsMaxPayloadBytes: number
|
|
1262
1277
|
private readonly requestTimeoutMs: number
|
|
1263
1278
|
private readonly wsUpgradeTimeoutMs: number
|
|
@@ -1269,8 +1284,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1269
1284
|
private readonly acceptInboundDeadlines: boolean
|
|
1270
1285
|
private readonly maxInboundDeadlineMs: number
|
|
1271
1286
|
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
1272
|
-
private readonly gracefulSignals:
|
|
1287
|
+
private readonly gracefulSignals: StopOptions | undefined
|
|
1273
1288
|
private readonly stopHooks: StopHook[]
|
|
1289
|
+
private readonly stoppingHooks: StopHook[]
|
|
1274
1290
|
private readonly fetchMounts: FetchMount<EnvOf<Ctx>>[]
|
|
1275
1291
|
private mountOrder: number
|
|
1276
1292
|
/** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
|
|
@@ -1365,6 +1381,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1365
1381
|
this.staticResponseHeaders,
|
|
1366
1382
|
// Response hooks write next, and they run for this same request object.
|
|
1367
1383
|
ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
|
|
1384
|
+
this.onResponseHooks.length === 0,
|
|
1368
1385
|
)
|
|
1369
1386
|
private readonly responseRequests: WeakMap<Request, Request>
|
|
1370
1387
|
/** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
|
|
@@ -1400,6 +1417,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1400
1417
|
this.protoPoisoning = options.protoPoisoning ?? "reject"
|
|
1401
1418
|
this.formOrigins = options.formOrigins
|
|
1402
1419
|
this.trustBodyFraming = options.trustBodyFraming ?? false
|
|
1420
|
+
this.validateHost = options.validateHost ?? false
|
|
1403
1421
|
const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
|
|
1404
1422
|
assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
|
|
1405
1423
|
this.maxBodyBytes = maxBodyBytes
|
|
@@ -1433,6 +1451,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1433
1451
|
this.gracefulSignals =
|
|
1434
1452
|
options.gracefulSignals === true ? {} : options.gracefulSignals || undefined
|
|
1435
1453
|
this.stopHooks = []
|
|
1454
|
+
this.stoppingHooks = []
|
|
1436
1455
|
this.fetchMounts = []
|
|
1437
1456
|
this.mountOrder = 0
|
|
1438
1457
|
this.preRouteMountCount = 0
|
|
@@ -1508,6 +1527,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1508
1527
|
return this
|
|
1509
1528
|
}
|
|
1510
1529
|
|
|
1530
|
+
/**
|
|
1531
|
+
* Register a callback awaited as soon as `stop()` begins, while the server still serves: readiness
|
|
1532
|
+
* should fail from here, and work that outlives a request (a poller, a queue consumer) should stop
|
|
1533
|
+
* taking more. Hung callbacks are bounded.
|
|
1534
|
+
*/
|
|
1535
|
+
onStopping(fn: StopHook): this {
|
|
1536
|
+
this.assertConfigurable("onStopping()")
|
|
1537
|
+
if (typeof fn !== "function") throw new TypeError("onStopping callback must be a function")
|
|
1538
|
+
this.stoppingHooks.push(fn)
|
|
1539
|
+
return this
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1511
1542
|
/**
|
|
1512
1543
|
* Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
|
|
1513
1544
|
* routes, so routes can move one at a time while the remaining legacy surface stays live.
|
|
@@ -2570,6 +2601,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2570
2601
|
schema: RouteSchema | undefined,
|
|
2571
2602
|
handler: (context: never) => unknown,
|
|
2572
2603
|
): CatalogRoute {
|
|
2604
|
+
schema = withTextSlots(schema)
|
|
2573
2605
|
// Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
|
|
2574
2606
|
// app: the module that registered the route, read where a static scan cannot see the path.
|
|
2575
2607
|
if (
|
|
@@ -2617,6 +2649,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2617
2649
|
lanes,
|
|
2618
2650
|
routeAssurance,
|
|
2619
2651
|
} = compiled
|
|
2652
|
+
// A schema that writes its answer its own way (`t.bytes()`) wraps every stage that can answer.
|
|
2653
|
+
const stages = withAnswerSteps(schema, method, path, {
|
|
2654
|
+
handler,
|
|
2655
|
+
beforeHandle: this.beforeHandleHooks,
|
|
2656
|
+
afterHandle: this.afterHandleHooks,
|
|
2657
|
+
onError: this.onErrorHooks,
|
|
2658
|
+
})
|
|
2659
|
+
handler = stages.handler
|
|
2660
|
+
const { beforeHandle, afterHandle, onError } = stages
|
|
2620
2661
|
if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
2621
2662
|
handler = this.idempotencyRuntime.wrapHandler(handler)
|
|
2622
2663
|
}
|
|
@@ -2644,8 +2685,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2644
2685
|
? this.buildFusedBodyDeriveBeforeAfterRunner(
|
|
2645
2686
|
handler as unknown as InternalHandler,
|
|
2646
2687
|
this.derives[0]!,
|
|
2647
|
-
|
|
2648
|
-
|
|
2688
|
+
beforeHandle[0]!,
|
|
2689
|
+
afterHandle[0],
|
|
2649
2690
|
schema?.body as StandardSchemaV1,
|
|
2650
2691
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2651
2692
|
)
|
|
@@ -2653,7 +2694,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2653
2694
|
? this.buildFusedDeriveBeforeRunner(
|
|
2654
2695
|
handler as unknown as InternalHandler,
|
|
2655
2696
|
this.derives[0]!,
|
|
2656
|
-
|
|
2697
|
+
beforeHandle[0]!,
|
|
2657
2698
|
undefined,
|
|
2658
2699
|
schema?.query,
|
|
2659
2700
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
@@ -2662,8 +2703,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2662
2703
|
? this.buildFusedDeriveBeforeRunner(
|
|
2663
2704
|
handler as unknown as InternalHandler,
|
|
2664
2705
|
this.derives[0]!,
|
|
2665
|
-
|
|
2666
|
-
|
|
2706
|
+
beforeHandle[0]!,
|
|
2707
|
+
afterHandle[0],
|
|
2667
2708
|
schema?.query,
|
|
2668
2709
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2669
2710
|
)
|
|
@@ -2689,8 +2730,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2689
2730
|
? this.buildFusedBodyDeriveBeforeAfter(
|
|
2690
2731
|
handler as unknown as InternalHandler,
|
|
2691
2732
|
this.derives[0]!,
|
|
2692
|
-
|
|
2693
|
-
|
|
2733
|
+
beforeHandle[0]!,
|
|
2734
|
+
afterHandle[0],
|
|
2694
2735
|
schema?.body as StandardSchemaV1,
|
|
2695
2736
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2696
2737
|
)
|
|
@@ -2698,7 +2739,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2698
2739
|
? this.buildFusedDeriveBefore(
|
|
2699
2740
|
handler as unknown as InternalHandler,
|
|
2700
2741
|
this.derives[0]!,
|
|
2701
|
-
|
|
2742
|
+
beforeHandle[0]!,
|
|
2702
2743
|
schema?.query,
|
|
2703
2744
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2704
2745
|
)
|
|
@@ -2706,8 +2747,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2706
2747
|
? this.buildFusedDeriveBeforeAfter(
|
|
2707
2748
|
handler as unknown as InternalHandler,
|
|
2708
2749
|
this.derives[0]!,
|
|
2709
|
-
|
|
2710
|
-
|
|
2750
|
+
beforeHandle[0]!,
|
|
2751
|
+
afterHandle[0]!,
|
|
2711
2752
|
schema?.query,
|
|
2712
2753
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2713
2754
|
)
|
|
@@ -2717,9 +2758,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2717
2758
|
handler: handler as unknown as InternalHandler,
|
|
2718
2759
|
derives: this.derives,
|
|
2719
2760
|
authStages: compiled.authStages,
|
|
2720
|
-
beforeHandle
|
|
2721
|
-
afterHandle
|
|
2722
|
-
onError
|
|
2761
|
+
beforeHandle,
|
|
2762
|
+
afterHandle,
|
|
2763
|
+
onError,
|
|
2723
2764
|
decorations: routeDecorations,
|
|
2724
2765
|
hasDecorations,
|
|
2725
2766
|
bodySchema: schema?.body,
|
|
@@ -2748,9 +2789,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2748
2789
|
authStages: [...compiled.authStages],
|
|
2749
2790
|
decorations: routeDecorations,
|
|
2750
2791
|
hasDecorations,
|
|
2751
|
-
beforeHandle: [...
|
|
2752
|
-
afterHandle: [...
|
|
2753
|
-
onError: [...
|
|
2792
|
+
beforeHandle: [...beforeHandle],
|
|
2793
|
+
afterHandle: [...afterHandle],
|
|
2794
|
+
onError: [...onError],
|
|
2754
2795
|
lifecycleHookLane: lanes.lifecycleHookLane,
|
|
2755
2796
|
around: [...this.aroundHooks],
|
|
2756
2797
|
execution,
|
|
@@ -3190,6 +3231,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3190
3231
|
// Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
|
|
3191
3232
|
// its routes; dropping it would leak the resource past `stop()`.
|
|
3192
3233
|
this.stopHooks.push(...source.stopHooks)
|
|
3234
|
+
this.stoppingHooks.push(...source.stoppingHooks)
|
|
3193
3235
|
this.mcpResourceList.push(...source.mcpResourceList)
|
|
3194
3236
|
this.mcpPromptList.push(...source.mcpPromptList)
|
|
3195
3237
|
}
|
|
@@ -3353,7 +3395,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3353
3395
|
fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
|
|
3354
3396
|
// Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
|
|
3355
3397
|
// Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
|
|
3356
|
-
const target = requestTarget(req)
|
|
3398
|
+
const target = requestTarget(req, this.validateHost)
|
|
3357
3399
|
if (target === true) return jsonError(400, "invalid_host")
|
|
3358
3400
|
if (target !== false) {
|
|
3359
3401
|
const rebuilt = withResolvedTarget(req, target)
|
|
@@ -3857,7 +3899,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3857
3899
|
// The adapter answers a pass by handing this same object to `fetch`.
|
|
3858
3900
|
const received = req
|
|
3859
3901
|
// The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
|
|
3860
|
-
const target = requestTarget(req)
|
|
3902
|
+
const target = requestTarget(req, this.validateHost)
|
|
3861
3903
|
if (target === true) {
|
|
3862
3904
|
return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
|
|
3863
3905
|
}
|
|
@@ -3995,14 +4037,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3995
4037
|
return refuse(jsonError(500, "internal_error"))
|
|
3996
4038
|
}
|
|
3997
4039
|
}
|
|
3998
|
-
const child =
|
|
3999
|
-
mount.authStages.length === 0
|
|
4000
|
-
? upgrade()
|
|
4001
|
-
: thenMaybe(
|
|
4002
|
-
authenticateMount(mount.authStages, request, platform, this.logger),
|
|
4003
|
-
(refusal): MaybePromise<WebSocketUpgradeOutcome> =>
|
|
4004
|
-
refusal === undefined ? upgrade() : refuse(refusal),
|
|
4005
|
-
)
|
|
4006
4040
|
const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
|
|
4007
4041
|
outcome.kind === "pass" ||
|
|
4008
4042
|
(outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
|
|
@@ -4010,7 +4044,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4010
4044
|
: outcome.kind === "reject"
|
|
4011
4045
|
? refuse(outcome.response)
|
|
4012
4046
|
: outcome
|
|
4013
|
-
|
|
4047
|
+
const run = (): MaybePromise<WebSocketUpgradeOutcome> => {
|
|
4048
|
+
const child = upgrade()
|
|
4049
|
+
return child instanceof Promise ? child.then(settle) : settle(child)
|
|
4050
|
+
}
|
|
4051
|
+
// Only a child's miss may fall through, never an authentication refusal.
|
|
4052
|
+
return mount.authStages.length === 0
|
|
4053
|
+
? run()
|
|
4054
|
+
: thenMaybe(
|
|
4055
|
+
authenticateMount(mount.authStages, request, platform, this.logger),
|
|
4056
|
+
(refusal) => (refusal === undefined ? run() : refuse(refusal)),
|
|
4057
|
+
)
|
|
4014
4058
|
}
|
|
4015
4059
|
return dispatch(0)
|
|
4016
4060
|
}
|
|
@@ -4517,25 +4561,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4517
4561
|
if (mount === undefined) return fallback ?? jsonError(404, "not_found")
|
|
4518
4562
|
const selected = mount
|
|
4519
4563
|
const run = (): MaybePromise<Response> => {
|
|
4564
|
+
const reused = selected.stripPrefix ? selected.wsPassed?.get(request) : undefined
|
|
4520
4565
|
const childRequest = selected.stripPrefix
|
|
4521
|
-
? (
|
|
4566
|
+
? (reused ?? stripMountPrefix(request, selected.path))
|
|
4522
4567
|
: request
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
)
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
return outcome.
|
|
4535
|
-
response.status === 404 ? dispatch(index + 1, response) : response,
|
|
4536
|
-
)
|
|
4568
|
+
// HTTP authentication may have marked the original since the WebSocket pass.
|
|
4569
|
+
if (reused !== undefined && isAuthenticatedRequest(request)) {
|
|
4570
|
+
markAuthenticatedRequest(reused)
|
|
4571
|
+
}
|
|
4572
|
+
const outcome = selected.handler(childRequest, platform)
|
|
4573
|
+
if (!replayable || !selected.fallbackOn404) return outcome
|
|
4574
|
+
if (outcome instanceof Promise) {
|
|
4575
|
+
return outcome.then((response) =>
|
|
4576
|
+
response.status === 404 ? dispatch(index + 1, response) : response,
|
|
4577
|
+
)
|
|
4578
|
+
}
|
|
4579
|
+
return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
|
|
4537
4580
|
}
|
|
4538
|
-
|
|
4581
|
+
// Keep gate refusals outside the child's 404 fallthrough path.
|
|
4582
|
+
return mount.authStages.length === 0
|
|
4583
|
+
? run()
|
|
4584
|
+
: thenMaybe(
|
|
4585
|
+
authenticateMount(mount.authStages, request, platform, this.logger),
|
|
4586
|
+
(refusal) => refusal ?? run(),
|
|
4587
|
+
)
|
|
4539
4588
|
}
|
|
4540
4589
|
return dispatch(first)
|
|
4541
4590
|
}
|
|
@@ -7003,9 +7052,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
7003
7052
|
peer,
|
|
7004
7053
|
fallback,
|
|
7005
7054
|
)
|
|
7006
|
-
// The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
|
|
7007
|
-
|
|
7008
|
-
|
|
7055
|
+
// The table matched the target as sent; one `fetch` refuses or rewrites goes through it. Left
|
|
7056
|
+
// off, `validateHost` costs nothing here: the table holds no wildcard, a `..` parameter falls
|
|
7057
|
+
// back, and reading the Host on every request measured about 6% of a bare route.
|
|
7058
|
+
tableAt(path, pattern.segments)[method as Method] = this.validateHost
|
|
7059
|
+
? (request, server) =>
|
|
7060
|
+
requestTarget(request, true) ? fallback(request, server) : handler(request, server)
|
|
7061
|
+
: handler
|
|
7009
7062
|
count += 1
|
|
7010
7063
|
if (paramNames.length > 0) {
|
|
7011
7064
|
below.add(pattern.segments.length + method)
|
|
@@ -7205,11 +7258,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
7205
7258
|
* The Bun semantics: poll `pendingRequests` (awaiting
|
|
7206
7259
|
* `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
|
|
7207
7260
|
* ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
|
|
7208
|
-
* New connections may be accepted during the drain window;
|
|
7209
|
-
*
|
|
7210
|
-
*
|
|
7261
|
+
* New connections may be accepted during the drain window; `delayMs` is the time a load balancer
|
|
7262
|
+
* gets to stop routing here first. The `onStopping` hooks run before that delay, the `onStop`
|
|
7263
|
+
* hooks after the drain. Open WebSockets get a 1001 close when the drain starts and the same
|
|
7264
|
+
* window to finish it; a socket opened after `stop()` began is closed at once.
|
|
7211
7265
|
*/
|
|
7212
|
-
async stop({ drainMs = DEFAULT_DRAIN_MS
|
|
7266
|
+
async stop({ drainMs = DEFAULT_DRAIN_MS, delayMs = 0 }: StopOptions = {}): Promise<void> {
|
|
7213
7267
|
const onSignal = this.signalHandler
|
|
7214
7268
|
if (onSignal !== undefined) {
|
|
7215
7269
|
this.signalHandler = undefined
|
|
@@ -7218,6 +7272,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
7218
7272
|
const server = this.bunServer
|
|
7219
7273
|
if (server === undefined) return
|
|
7220
7274
|
this.bunServer = undefined
|
|
7275
|
+
await this[SERVER_LIFECYCLE]("stopping")
|
|
7276
|
+
if (delayMs > 0) await Bun.sleep(delayMs)
|
|
7221
7277
|
const sockets = this.bunSockets
|
|
7222
7278
|
this.bunSockets = undefined
|
|
7223
7279
|
if (sockets !== undefined) for (const ws of sockets) ws.close(1001)
|
|
@@ -7227,16 +7283,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
7227
7283
|
await Bun.sleep(DRAIN_POLL_MS)
|
|
7228
7284
|
}
|
|
7229
7285
|
server.stop(busy()) // force-close iff stragglers remain past the deadline
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7286
|
+
await this[SERVER_LIFECYCLE]("stopped")
|
|
7287
|
+
}
|
|
7288
|
+
|
|
7289
|
+
/**
|
|
7290
|
+
* @internal Run the `onStopping` or the `onStop` hooks, each bounded. A runtime adapter that drains
|
|
7291
|
+
* its own listener calls it on either side of the drain, as `stop()` does here.
|
|
7292
|
+
*/
|
|
7293
|
+
async [SERVER_LIFECYCLE](phase: "stopping" | "stopped"): Promise<void> {
|
|
7294
|
+
const hooks = phase === "stopping" ? this.stoppingHooks : this.stopHooks
|
|
7295
|
+
if (hooks.length === 0) return
|
|
7296
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
7233
7297
|
await Promise.race([
|
|
7234
|
-
|
|
7235
|
-
new Promise<void>((resolve) =>
|
|
7298
|
+
Promise.allSettled(hooks.map((hook) => Promise.resolve().then(hook))),
|
|
7299
|
+
new Promise<void>((resolve) => {
|
|
7300
|
+
timer = setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)
|
|
7301
|
+
}),
|
|
7236
7302
|
])
|
|
7303
|
+
// A pending bound would hold the event loop open after every hook settled.
|
|
7304
|
+
clearTimeout(timer)
|
|
7237
7305
|
}
|
|
7238
7306
|
|
|
7239
|
-
private installSignalHandlers(drain:
|
|
7307
|
+
private installSignalHandlers(drain: StopOptions): void {
|
|
7240
7308
|
// Drain, then let the process exit naturally - the stopped server no longer
|
|
7241
7309
|
// holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
|
|
7242
7310
|
// signals is consented; we don't force `process.exit`.
|
package/src/server/sse.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* }, { keepAlive: 15_000 }))
|
|
23
23
|
*/
|
|
24
24
|
import { INSTALL_SSE } from "./install.ts"
|
|
25
|
+
import { jsonText } from "./json-text.ts"
|
|
25
26
|
import type { IdentityPlugin } from "./plugin.ts"
|
|
26
27
|
import type { AnyServer } from "./server.ts"
|
|
27
28
|
import type { SseRuntime } from "./sse-hook.ts"
|
|
@@ -92,7 +93,7 @@ export interface TypedSSEStream<Event> {
|
|
|
92
93
|
export function typedSSEStream<Event>(stream: SSEStream): TypedSSEStream<Event> {
|
|
93
94
|
return {
|
|
94
95
|
send(event, init = {}) {
|
|
95
|
-
stream.send({ ...init, data:
|
|
96
|
+
stream.send({ ...init, data: jsonText(event) })
|
|
96
97
|
},
|
|
97
98
|
comment(text) {
|
|
98
99
|
stream.send({ comment: text })
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "../schema/standard.ts"
|
|
2
|
+
import type { RouteSchema } from "./context.ts"
|
|
3
|
+
|
|
4
|
+
// What a schema offers for a slot whose values arrive as text: `@nifrajs/schema` sets it on a
|
|
5
|
+
// `t.object` (and any `t` schema that does not coerce) to the coercing form of the same schema.
|
|
6
|
+
const TEXT_SLOT = Symbol.for("nifra.schema.textSlot")
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `schema` with its `query`, `headers` and `cookies` schemas swapped for the text form each offers, or
|
|
10
|
+
* `schema` itself when none offers one. A query string, header values and a `Cookie` header are
|
|
11
|
+
* always text, so a schema that reads a boolean or a number there can only validate its coercing form.
|
|
12
|
+
*/
|
|
13
|
+
export function withTextSlots(schema: RouteSchema | undefined): RouteSchema | undefined {
|
|
14
|
+
let out = schema
|
|
15
|
+
for (const slot of ["query", "headers", "cookies"] as const) {
|
|
16
|
+
const validate = out?.[slot]?.["~standard"].validate
|
|
17
|
+
const form: StandardSchemaV1 | undefined = validate && Reflect.get(validate, TEXT_SLOT)
|
|
18
|
+
if (form !== undefined) out = { ...out, [slot]: form }
|
|
19
|
+
}
|
|
20
|
+
return out
|
|
21
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -108,6 +108,7 @@ export type {
|
|
|
108
108
|
} from "./server/registry.ts"
|
|
109
109
|
export { replacedRequestOf } from "./server/request-lineage.ts"
|
|
110
110
|
export { writableResponse } from "./server/respond.ts"
|
|
111
|
+
export { routeTemplateOf } from "./server/route-template.ts"
|
|
111
112
|
export {
|
|
112
113
|
type PlainRender,
|
|
113
114
|
type ResponseResult,
|
package/src/single-copy.ts
CHANGED
|
@@ -78,13 +78,9 @@ export const IDENTITY_SENSITIVE_PACKAGES: readonly string[] = [
|
|
|
78
78
|
/** Files a redirect may rewrite. Everything else in a package (json, css, wasm) loads unchanged. */
|
|
79
79
|
const REDIRECTABLE = /\.(?:m|c)?[jt]sx?$/
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (path.endsWith(".tsx")) return "tsx"
|
|
85
|
-
if (path.endsWith(".jsx")) return "jsx"
|
|
86
|
-
return /\.[mc]?ts$/.test(path) ? "ts" : "js"
|
|
87
|
-
}
|
|
81
|
+
/** Appended to a linked file the load hook hands back to Bun untouched; see the hook. */
|
|
82
|
+
const OWN_FILE_QUERY = "?nifra-single-copy"
|
|
83
|
+
|
|
88
84
|
/** Runaway guards. A real `node_modules` top level is hundreds of entries, a repo walk is a few. */
|
|
89
85
|
const MAX_SCANNED_ENTRIES = 4_096
|
|
90
86
|
const MAX_ANCESTOR_DIRS = 16
|
|
@@ -233,6 +229,11 @@ export interface SingleCopyRegistration {
|
|
|
233
229
|
readonly run: boolean
|
|
234
230
|
/** `[test] preload`: covers `bun test`, the surface that runs app sources unbundled. */
|
|
235
231
|
readonly test: boolean
|
|
232
|
+
/**
|
|
233
|
+
* Tables whose `preload` names the registrar although Bun reads it from neither place: below
|
|
234
|
+
* `[install]` the line is `install.preload`, so `bun run` loads every copy while the file looks armed.
|
|
235
|
+
*/
|
|
236
|
+
readonly ignored?: readonly string[]
|
|
236
237
|
/** The `bunfig.toml` that was read, if one exists. */
|
|
237
238
|
readonly config?: string
|
|
238
239
|
}
|
|
@@ -286,6 +287,7 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
|
|
|
286
287
|
let section = ""
|
|
287
288
|
let run = false
|
|
288
289
|
let test = false
|
|
290
|
+
const ignored: string[] = []
|
|
289
291
|
for (const line of text.split(/\r?\n/)) {
|
|
290
292
|
const match = PRELOAD_ASSIGNMENT.exec(line)
|
|
291
293
|
if (match?.groups === undefined) continue
|
|
@@ -298,8 +300,9 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
|
|
|
298
300
|
continue
|
|
299
301
|
if (section === "test") test = true
|
|
300
302
|
else if (section === "") run = true
|
|
303
|
+
else if (!ignored.includes(section)) ignored.push(section)
|
|
301
304
|
}
|
|
302
|
-
return { run, test, config }
|
|
305
|
+
return ignored.length === 0 ? { run, test, config } : { run, test, ignored, config }
|
|
303
306
|
}
|
|
304
307
|
|
|
305
308
|
/** The repository a linked checkout resolves its own dependencies from: its nearest `.git` ancestor.
|
|
@@ -598,6 +601,8 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
|
|
|
598
601
|
// hook has to return contents for everything it matches, and re-emitting a CommonJS entry as an
|
|
599
602
|
// ES module is how `Export named 'useEffect' not found` happens.
|
|
600
603
|
const filter = new RegExp(`^(?:${roots})[/\\\\].*${REDIRECTABLE.source}`)
|
|
604
|
+
// `Bun.plugin` leaves `config` unset although the type says otherwise; only `Bun.build` sets it.
|
|
605
|
+
const bundling = build.config !== undefined
|
|
601
606
|
build.onLoad({ filter }, (args) => {
|
|
602
607
|
for (const redirect of plan.redirects) {
|
|
603
608
|
const target = counterpart(redirect, args.path)
|
|
@@ -616,10 +621,13 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
|
|
|
616
621
|
detail: `${relative(owner.from, args.path)} has no file at the same path in the app's copy, so it loads from the linked copy and its module state is not shared`,
|
|
617
622
|
})
|
|
618
623
|
}
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
|
|
624
|
+
// The file loads as it is, in its own module format. A bundler takes `undefined` for that. A
|
|
625
|
+
// runtime `onLoad` must return an object, and source returned from here loads as an ES module,
|
|
626
|
+
// which empties a CommonJS file's exports - so the runtime gets a re-export of the file under a
|
|
627
|
+
// query the filter's `$` cannot match, which Bun then loads natively from its own directory.
|
|
628
|
+
// There its `__filename` and `import.meta.url` carry the query; the bundler cannot resolve it.
|
|
629
|
+
if (bundling) return undefined
|
|
630
|
+
return { contents: reexport(`${args.path}${OWN_FILE_QUERY}`), loader: "js" }
|
|
623
631
|
})
|
|
624
632
|
},
|
|
625
633
|
}
|
|
@@ -646,12 +654,20 @@ const processSet = (key: symbol): Set<string> => {
|
|
|
646
654
|
const copyLine = (label: string, path: string | undefined, version: string | undefined): string =>
|
|
647
655
|
` ${label}: ${path ?? "(not installed for the app)"}${version === undefined ? "" : ` (${version})`}`
|
|
648
656
|
|
|
649
|
-
/**
|
|
657
|
+
/**
|
|
658
|
+
* One report per package: both copies, both versions, the reason, and the fix. A version skew comes
|
|
659
|
+
* from the plan, which reads installs, so it says installed: nothing may ever load that copy, and a
|
|
660
|
+
* runtime `onLoad` cannot watch for it without re-emitting CommonJS files as ES modules.
|
|
661
|
+
*/
|
|
650
662
|
const describeSkips = (name: string, skips: readonly SingleCopySkip[]): string => {
|
|
651
663
|
const first = skips[0]
|
|
652
664
|
const reason = first?.reason ?? "version-skew"
|
|
665
|
+
const effect =
|
|
666
|
+
reason === "version-skew"
|
|
667
|
+
? "a second copy is installed at another version, and whatever loads it does not share this copy's module state."
|
|
668
|
+
: "a second copy loads, so its module state is not shared."
|
|
653
669
|
const lines = [
|
|
654
|
-
`[nifra] single-copy: ${name} is NOT deduplicated (${reason}) -
|
|
670
|
+
`[nifra] single-copy: ${name} is NOT deduplicated (${reason}) - ${effect}`,
|
|
655
671
|
copyLine("app copy", first?.to, first?.toVersion),
|
|
656
672
|
]
|
|
657
673
|
for (const skip of skips) {
|
|
@@ -683,7 +699,7 @@ const describeSkewGroup = (skips: readonly SingleCopySkip[]): string => {
|
|
|
683
699
|
if (first === undefined || skips.length === 1) return describeSkips(first?.package ?? "", skips)
|
|
684
700
|
const names = skips.map((skip) => skip.package).sort(codeUnitOrder)
|
|
685
701
|
return [
|
|
686
|
-
`[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each
|
|
702
|
+
`[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each is installed at another version, and whatever loads one does not share the app copy's module state.`,
|
|
687
703
|
copyLine(
|
|
688
704
|
"app copies",
|
|
689
705
|
first.to === undefined ? undefined : modulesDirOf(first.to),
|
package/src/transport-codec.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* remains the zero-configuration fast path; rich wire is explicit and preserves non-JSON values.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { jsonText } from "./server/json-text.ts"
|
|
7
|
+
|
|
6
8
|
const DEFAULT_MAX_BYTES = 16 * 1024 * 1024
|
|
7
9
|
const TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/
|
|
8
10
|
|
|
@@ -81,7 +83,7 @@ export const plainJsonCodec: TransportCodec = Object.freeze({
|
|
|
81
83
|
version: 1,
|
|
82
84
|
mediaType: "application/json",
|
|
83
85
|
encode(value: unknown): string {
|
|
84
|
-
const encoded =
|
|
86
|
+
const encoded = jsonText(value)
|
|
85
87
|
if (encoded === undefined) throw new TransportCodecError("value is not JSON serializable")
|
|
86
88
|
return encoded
|
|
87
89
|
},
|