@nifrajs/core 5.6.0 → 5.7.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 +93 -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 +14 -6
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +21 -10
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +94 -37
- 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 +15 -4
- package/src/server/server.ts +114 -54
- 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
|
|
|
@@ -778,12 +787,15 @@ function compareMounts<Env>(a: FetchMount<Env>, b: FetchMount<Env>): number {
|
|
|
778
787
|
return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order
|
|
779
788
|
}
|
|
780
789
|
|
|
790
|
+
/** Rebase a mounted request's path without losing the authentication mark used by child caches. */
|
|
781
791
|
function stripMountPrefix(request: Request, prefix: string): Request {
|
|
782
792
|
if (prefix === "/") return request
|
|
783
793
|
const url = new URL(request.url)
|
|
784
794
|
const rest = url.pathname.slice(prefix.length)
|
|
785
795
|
url.pathname = rest === "" ? "/" : rest
|
|
786
|
-
|
|
796
|
+
const childRequest = new Request(url.href, request)
|
|
797
|
+
if (isAuthenticatedRequest(request)) markAuthenticatedRequest(childRequest)
|
|
798
|
+
return childRequest
|
|
787
799
|
}
|
|
788
800
|
|
|
789
801
|
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
@@ -1269,8 +1281,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1269
1281
|
private readonly acceptInboundDeadlines: boolean
|
|
1270
1282
|
private readonly maxInboundDeadlineMs: number
|
|
1271
1283
|
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
1272
|
-
private readonly gracefulSignals:
|
|
1284
|
+
private readonly gracefulSignals: StopOptions | undefined
|
|
1273
1285
|
private readonly stopHooks: StopHook[]
|
|
1286
|
+
private readonly stoppingHooks: StopHook[]
|
|
1274
1287
|
private readonly fetchMounts: FetchMount<EnvOf<Ctx>>[]
|
|
1275
1288
|
private mountOrder: number
|
|
1276
1289
|
/** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
|
|
@@ -1365,6 +1378,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1365
1378
|
this.staticResponseHeaders,
|
|
1366
1379
|
// Response hooks write next, and they run for this same request object.
|
|
1367
1380
|
ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
|
|
1381
|
+
this.onResponseHooks.length === 0,
|
|
1368
1382
|
)
|
|
1369
1383
|
private readonly responseRequests: WeakMap<Request, Request>
|
|
1370
1384
|
/** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
|
|
@@ -1433,6 +1447,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1433
1447
|
this.gracefulSignals =
|
|
1434
1448
|
options.gracefulSignals === true ? {} : options.gracefulSignals || undefined
|
|
1435
1449
|
this.stopHooks = []
|
|
1450
|
+
this.stoppingHooks = []
|
|
1436
1451
|
this.fetchMounts = []
|
|
1437
1452
|
this.mountOrder = 0
|
|
1438
1453
|
this.preRouteMountCount = 0
|
|
@@ -1508,6 +1523,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1508
1523
|
return this
|
|
1509
1524
|
}
|
|
1510
1525
|
|
|
1526
|
+
/**
|
|
1527
|
+
* Register a callback awaited as soon as `stop()` begins, while the server still serves: readiness
|
|
1528
|
+
* should fail from here, and work that outlives a request (a poller, a queue consumer) should stop
|
|
1529
|
+
* taking more. Hung callbacks are bounded.
|
|
1530
|
+
*/
|
|
1531
|
+
onStopping(fn: StopHook): this {
|
|
1532
|
+
this.assertConfigurable("onStopping()")
|
|
1533
|
+
if (typeof fn !== "function") throw new TypeError("onStopping callback must be a function")
|
|
1534
|
+
this.stoppingHooks.push(fn)
|
|
1535
|
+
return this
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1511
1538
|
/**
|
|
1512
1539
|
* Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
|
|
1513
1540
|
* routes, so routes can move one at a time while the remaining legacy surface stays live.
|
|
@@ -2570,6 +2597,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2570
2597
|
schema: RouteSchema | undefined,
|
|
2571
2598
|
handler: (context: never) => unknown,
|
|
2572
2599
|
): CatalogRoute {
|
|
2600
|
+
schema = withTextSlots(schema)
|
|
2573
2601
|
// Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
|
|
2574
2602
|
// app: the module that registered the route, read where a static scan cannot see the path.
|
|
2575
2603
|
if (
|
|
@@ -2617,6 +2645,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2617
2645
|
lanes,
|
|
2618
2646
|
routeAssurance,
|
|
2619
2647
|
} = compiled
|
|
2648
|
+
// A schema that writes its answer its own way (`t.bytes()`) wraps every stage that can answer.
|
|
2649
|
+
const stages = withAnswerSteps(schema, method, path, {
|
|
2650
|
+
handler,
|
|
2651
|
+
beforeHandle: this.beforeHandleHooks,
|
|
2652
|
+
afterHandle: this.afterHandleHooks,
|
|
2653
|
+
onError: this.onErrorHooks,
|
|
2654
|
+
})
|
|
2655
|
+
handler = stages.handler
|
|
2656
|
+
const { beforeHandle, afterHandle, onError } = stages
|
|
2620
2657
|
if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
2621
2658
|
handler = this.idempotencyRuntime.wrapHandler(handler)
|
|
2622
2659
|
}
|
|
@@ -2644,8 +2681,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2644
2681
|
? this.buildFusedBodyDeriveBeforeAfterRunner(
|
|
2645
2682
|
handler as unknown as InternalHandler,
|
|
2646
2683
|
this.derives[0]!,
|
|
2647
|
-
|
|
2648
|
-
|
|
2684
|
+
beforeHandle[0]!,
|
|
2685
|
+
afterHandle[0],
|
|
2649
2686
|
schema?.body as StandardSchemaV1,
|
|
2650
2687
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2651
2688
|
)
|
|
@@ -2653,7 +2690,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2653
2690
|
? this.buildFusedDeriveBeforeRunner(
|
|
2654
2691
|
handler as unknown as InternalHandler,
|
|
2655
2692
|
this.derives[0]!,
|
|
2656
|
-
|
|
2693
|
+
beforeHandle[0]!,
|
|
2657
2694
|
undefined,
|
|
2658
2695
|
schema?.query,
|
|
2659
2696
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
@@ -2662,8 +2699,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2662
2699
|
? this.buildFusedDeriveBeforeRunner(
|
|
2663
2700
|
handler as unknown as InternalHandler,
|
|
2664
2701
|
this.derives[0]!,
|
|
2665
|
-
|
|
2666
|
-
|
|
2702
|
+
beforeHandle[0]!,
|
|
2703
|
+
afterHandle[0],
|
|
2667
2704
|
schema?.query,
|
|
2668
2705
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2669
2706
|
)
|
|
@@ -2689,8 +2726,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2689
2726
|
? this.buildFusedBodyDeriveBeforeAfter(
|
|
2690
2727
|
handler as unknown as InternalHandler,
|
|
2691
2728
|
this.derives[0]!,
|
|
2692
|
-
|
|
2693
|
-
|
|
2729
|
+
beforeHandle[0]!,
|
|
2730
|
+
afterHandle[0],
|
|
2694
2731
|
schema?.body as StandardSchemaV1,
|
|
2695
2732
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2696
2733
|
)
|
|
@@ -2698,7 +2735,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2698
2735
|
? this.buildFusedDeriveBefore(
|
|
2699
2736
|
handler as unknown as InternalHandler,
|
|
2700
2737
|
this.derives[0]!,
|
|
2701
|
-
|
|
2738
|
+
beforeHandle[0]!,
|
|
2702
2739
|
schema?.query,
|
|
2703
2740
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2704
2741
|
)
|
|
@@ -2706,8 +2743,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2706
2743
|
? this.buildFusedDeriveBeforeAfter(
|
|
2707
2744
|
handler as unknown as InternalHandler,
|
|
2708
2745
|
this.derives[0]!,
|
|
2709
|
-
|
|
2710
|
-
|
|
2746
|
+
beforeHandle[0]!,
|
|
2747
|
+
afterHandle[0]!,
|
|
2711
2748
|
schema?.query,
|
|
2712
2749
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2713
2750
|
)
|
|
@@ -2717,9 +2754,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2717
2754
|
handler: handler as unknown as InternalHandler,
|
|
2718
2755
|
derives: this.derives,
|
|
2719
2756
|
authStages: compiled.authStages,
|
|
2720
|
-
beforeHandle
|
|
2721
|
-
afterHandle
|
|
2722
|
-
onError
|
|
2757
|
+
beforeHandle,
|
|
2758
|
+
afterHandle,
|
|
2759
|
+
onError,
|
|
2723
2760
|
decorations: routeDecorations,
|
|
2724
2761
|
hasDecorations,
|
|
2725
2762
|
bodySchema: schema?.body,
|
|
@@ -2748,9 +2785,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2748
2785
|
authStages: [...compiled.authStages],
|
|
2749
2786
|
decorations: routeDecorations,
|
|
2750
2787
|
hasDecorations,
|
|
2751
|
-
beforeHandle: [...
|
|
2752
|
-
afterHandle: [...
|
|
2753
|
-
onError: [...
|
|
2788
|
+
beforeHandle: [...beforeHandle],
|
|
2789
|
+
afterHandle: [...afterHandle],
|
|
2790
|
+
onError: [...onError],
|
|
2754
2791
|
lifecycleHookLane: lanes.lifecycleHookLane,
|
|
2755
2792
|
around: [...this.aroundHooks],
|
|
2756
2793
|
execution,
|
|
@@ -3190,6 +3227,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3190
3227
|
// Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
|
|
3191
3228
|
// its routes; dropping it would leak the resource past `stop()`.
|
|
3192
3229
|
this.stopHooks.push(...source.stopHooks)
|
|
3230
|
+
this.stoppingHooks.push(...source.stoppingHooks)
|
|
3193
3231
|
this.mcpResourceList.push(...source.mcpResourceList)
|
|
3194
3232
|
this.mcpPromptList.push(...source.mcpPromptList)
|
|
3195
3233
|
}
|
|
@@ -3995,14 +4033,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3995
4033
|
return refuse(jsonError(500, "internal_error"))
|
|
3996
4034
|
}
|
|
3997
4035
|
}
|
|
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
4036
|
const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
|
|
4007
4037
|
outcome.kind === "pass" ||
|
|
4008
4038
|
(outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
|
|
@@ -4010,7 +4040,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4010
4040
|
: outcome.kind === "reject"
|
|
4011
4041
|
? refuse(outcome.response)
|
|
4012
4042
|
: outcome
|
|
4013
|
-
|
|
4043
|
+
const run = (): MaybePromise<WebSocketUpgradeOutcome> => {
|
|
4044
|
+
const child = upgrade()
|
|
4045
|
+
return child instanceof Promise ? child.then(settle) : settle(child)
|
|
4046
|
+
}
|
|
4047
|
+
// Only a child's miss may fall through, never an authentication refusal.
|
|
4048
|
+
return mount.authStages.length === 0
|
|
4049
|
+
? run()
|
|
4050
|
+
: thenMaybe(
|
|
4051
|
+
authenticateMount(mount.authStages, request, platform, this.logger),
|
|
4052
|
+
(refusal) => (refusal === undefined ? run() : refuse(refusal)),
|
|
4053
|
+
)
|
|
4014
4054
|
}
|
|
4015
4055
|
return dispatch(0)
|
|
4016
4056
|
}
|
|
@@ -4517,25 +4557,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4517
4557
|
if (mount === undefined) return fallback ?? jsonError(404, "not_found")
|
|
4518
4558
|
const selected = mount
|
|
4519
4559
|
const run = (): MaybePromise<Response> => {
|
|
4560
|
+
const reused = selected.stripPrefix ? selected.wsPassed?.get(request) : undefined
|
|
4520
4561
|
const childRequest = selected.stripPrefix
|
|
4521
|
-
? (
|
|
4562
|
+
? (reused ?? stripMountPrefix(request, selected.path))
|
|
4522
4563
|
: 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
|
-
)
|
|
4564
|
+
// HTTP authentication may have marked the original since the WebSocket pass.
|
|
4565
|
+
if (reused !== undefined && isAuthenticatedRequest(request)) {
|
|
4566
|
+
markAuthenticatedRequest(reused)
|
|
4567
|
+
}
|
|
4568
|
+
const outcome = selected.handler(childRequest, platform)
|
|
4569
|
+
if (!replayable || !selected.fallbackOn404) return outcome
|
|
4570
|
+
if (outcome instanceof Promise) {
|
|
4571
|
+
return outcome.then((response) =>
|
|
4572
|
+
response.status === 404 ? dispatch(index + 1, response) : response,
|
|
4573
|
+
)
|
|
4574
|
+
}
|
|
4575
|
+
return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
|
|
4537
4576
|
}
|
|
4538
|
-
|
|
4577
|
+
// Keep gate refusals outside the child's 404 fallthrough path.
|
|
4578
|
+
return mount.authStages.length === 0
|
|
4579
|
+
? run()
|
|
4580
|
+
: thenMaybe(
|
|
4581
|
+
authenticateMount(mount.authStages, request, platform, this.logger),
|
|
4582
|
+
(refusal) => refusal ?? run(),
|
|
4583
|
+
)
|
|
4539
4584
|
}
|
|
4540
4585
|
return dispatch(first)
|
|
4541
4586
|
}
|
|
@@ -7205,11 +7250,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
7205
7250
|
* The Bun semantics: poll `pendingRequests` (awaiting
|
|
7206
7251
|
* `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
|
|
7207
7252
|
* 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
|
-
*
|
|
7253
|
+
* New connections may be accepted during the drain window; `delayMs` is the time a load balancer
|
|
7254
|
+
* gets to stop routing here first. The `onStopping` hooks run before that delay, the `onStop`
|
|
7255
|
+
* hooks after the drain. Open WebSockets get a 1001 close when the drain starts and the same
|
|
7256
|
+
* window to finish it; a socket opened after `stop()` began is closed at once.
|
|
7211
7257
|
*/
|
|
7212
|
-
async stop({ drainMs = DEFAULT_DRAIN_MS
|
|
7258
|
+
async stop({ drainMs = DEFAULT_DRAIN_MS, delayMs = 0 }: StopOptions = {}): Promise<void> {
|
|
7213
7259
|
const onSignal = this.signalHandler
|
|
7214
7260
|
if (onSignal !== undefined) {
|
|
7215
7261
|
this.signalHandler = undefined
|
|
@@ -7218,6 +7264,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
7218
7264
|
const server = this.bunServer
|
|
7219
7265
|
if (server === undefined) return
|
|
7220
7266
|
this.bunServer = undefined
|
|
7267
|
+
await this[SERVER_LIFECYCLE]("stopping")
|
|
7268
|
+
if (delayMs > 0) await Bun.sleep(delayMs)
|
|
7221
7269
|
const sockets = this.bunSockets
|
|
7222
7270
|
this.bunSockets = undefined
|
|
7223
7271
|
if (sockets !== undefined) for (const ws of sockets) ws.close(1001)
|
|
@@ -7227,16 +7275,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
7227
7275
|
await Bun.sleep(DRAIN_POLL_MS)
|
|
7228
7276
|
}
|
|
7229
7277
|
server.stop(busy()) // force-close iff stragglers remain past the deadline
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7278
|
+
await this[SERVER_LIFECYCLE]("stopped")
|
|
7279
|
+
}
|
|
7280
|
+
|
|
7281
|
+
/**
|
|
7282
|
+
* @internal Run the `onStopping` or the `onStop` hooks, each bounded. A runtime adapter that drains
|
|
7283
|
+
* its own listener calls it on either side of the drain, as `stop()` does here.
|
|
7284
|
+
*/
|
|
7285
|
+
async [SERVER_LIFECYCLE](phase: "stopping" | "stopped"): Promise<void> {
|
|
7286
|
+
const hooks = phase === "stopping" ? this.stoppingHooks : this.stopHooks
|
|
7287
|
+
if (hooks.length === 0) return
|
|
7288
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
7233
7289
|
await Promise.race([
|
|
7234
|
-
|
|
7235
|
-
new Promise<void>((resolve) =>
|
|
7290
|
+
Promise.allSettled(hooks.map((hook) => Promise.resolve().then(hook))),
|
|
7291
|
+
new Promise<void>((resolve) => {
|
|
7292
|
+
timer = setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)
|
|
7293
|
+
}),
|
|
7236
7294
|
])
|
|
7295
|
+
// A pending bound would hold the event loop open after every hook settled.
|
|
7296
|
+
clearTimeout(timer)
|
|
7237
7297
|
}
|
|
7238
7298
|
|
|
7239
|
-
private installSignalHandlers(drain:
|
|
7299
|
+
private installSignalHandlers(drain: StopOptions): void {
|
|
7240
7300
|
// Drain, then let the process exit naturally - the stopped server no longer
|
|
7241
7301
|
// holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
|
|
7242
7302
|
// 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
|
},
|