@nifrajs/core 1.4.0 → 1.6.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/dist/diff.d.ts +66 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +264 -0
- package/dist/diff.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/reflection.d.ts +2 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +1 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +8 -0
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +2 -0
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js.map +1 -1
- package/dist/server/registry.d.ts +8 -0
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/server.d.ts +40 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +88 -4
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +16 -0
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +13 -0
- package/dist/server/sse.js.map +1 -1
- package/package.json +6 -1
- package/src/diff.ts +383 -0
- package/src/index.ts +5 -1
- package/src/reflection.ts +3 -0
- package/src/server/context.ts +8 -0
- package/src/server/contract.ts +5 -0
- package/src/server/registry.ts +9 -0
- package/src/server/server.ts +125 -4
- package/src/server/sse.ts +29 -0
package/src/server/server.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { Context, Platform, ResponseControls, RouteSchema } from "./context
|
|
|
11
11
|
import { type CookieOptions, parseCookies, serializeCookie } from "./cookies.ts"
|
|
12
12
|
import { jsonLogger, type Logger } from "./logger.ts"
|
|
13
13
|
import type { AddRoute, EmptyRegistry, OutputOf, Registry, RouteInfoFor } from "./registry.ts"
|
|
14
|
+
import { type SSEInit, sse, type TypedSSEStream, typedSSEStream } from "./sse.ts"
|
|
14
15
|
import type {
|
|
15
16
|
StandardWebSocket,
|
|
16
17
|
TopicRegistry,
|
|
@@ -133,6 +134,7 @@ type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromi
|
|
|
133
134
|
export type OnRequestResult = Response | Request | undefined
|
|
134
135
|
type RawOnRequest = (req: Request) => MaybePromise<OnRequestResult>
|
|
135
136
|
type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
|
|
137
|
+
type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
136
138
|
|
|
137
139
|
interface RouteEntry {
|
|
138
140
|
readonly handler: InternalHandler
|
|
@@ -350,9 +352,18 @@ export interface Middleware {
|
|
|
350
352
|
readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
|
|
351
353
|
readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
|
|
352
354
|
readonly onResponse?: (response: Response, req: Request) => MaybePromise<Response>
|
|
355
|
+
readonly onResponseFinalized?: (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
353
356
|
readonly onError?: (error: unknown, context: Context) => MaybePromise<unknown>
|
|
354
357
|
}
|
|
355
358
|
|
|
359
|
+
/** The terminal response-pipeline outcome observed after every transforming `onResponse` hook. */
|
|
360
|
+
export interface ResponseFinalization {
|
|
361
|
+
/** The final response, or the last response available when a transformation failed. */
|
|
362
|
+
readonly response: Response
|
|
363
|
+
/** A response-hook failure. Terminal observers run fail-open before the error is rethrown. */
|
|
364
|
+
readonly error?: unknown
|
|
365
|
+
}
|
|
366
|
+
|
|
356
367
|
// A plugin operates over arbitrary Server shapes; `any` here is the standard framework escape hatch
|
|
357
368
|
// (the precise threading happens at the `use` call site, which is generic over the *concrete* `this`).
|
|
358
369
|
// biome-ignore lint/suspicious/noExplicitAny: plugins are generic over any Server's Registry/Context
|
|
@@ -1111,6 +1122,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1111
1122
|
private readonly aroundHooks: RawAround[]
|
|
1112
1123
|
private readonly onRequestHooks: RawOnRequest[]
|
|
1113
1124
|
private readonly onResponseHooks: RawOnResponse[]
|
|
1125
|
+
private readonly onResponseFinalizedHooks: RawOnResponseFinalized[]
|
|
1114
1126
|
private readonly responseRequests: WeakMap<Request, Request>
|
|
1115
1127
|
/** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
|
|
1116
1128
|
private readonly appliedPlugins: Set<string>
|
|
@@ -1139,6 +1151,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1139
1151
|
this.aroundHooks = []
|
|
1140
1152
|
this.onRequestHooks = []
|
|
1141
1153
|
this.onResponseHooks = []
|
|
1154
|
+
this.onResponseFinalizedHooks = []
|
|
1142
1155
|
this.responseRequests = new WeakMap()
|
|
1143
1156
|
this.appliedPlugins = new Set()
|
|
1144
1157
|
this.mcpResourceList = []
|
|
@@ -1200,6 +1213,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1200
1213
|
return this
|
|
1201
1214
|
}
|
|
1202
1215
|
|
|
1216
|
+
/** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
|
|
1217
|
+
onResponseFinalized(
|
|
1218
|
+
fn: (outcome: ResponseFinalization, req: Request) => MaybePromise<void>,
|
|
1219
|
+
): this {
|
|
1220
|
+
this.onResponseFinalizedHooks.push(fn)
|
|
1221
|
+
return this
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1203
1224
|
/**
|
|
1204
1225
|
* Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) — it
|
|
1205
1226
|
* registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
|
|
@@ -1240,6 +1261,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1240
1261
|
if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
|
|
1241
1262
|
if (arg.afterHandle !== undefined) this.afterHandle(arg.afterHandle)
|
|
1242
1263
|
if (arg.onResponse !== undefined) this.onResponse(arg.onResponse)
|
|
1264
|
+
if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
|
|
1243
1265
|
if (arg.onError !== undefined) this.onError(arg.onError)
|
|
1244
1266
|
return this
|
|
1245
1267
|
}
|
|
@@ -1261,6 +1283,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1261
1283
|
return this.route("GET", path, schemaOrHandler, handler)
|
|
1262
1284
|
}
|
|
1263
1285
|
|
|
1286
|
+
/**
|
|
1287
|
+
* Register a **typed SSE route** — a GET endpoint streaming `text/event-stream` whose event
|
|
1288
|
+
* payloads are contracted by `schema.sse`. The handler receives the validated context plus a
|
|
1289
|
+
* {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
|
|
1290
|
+
* JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
|
|
1291
|
+
* `.subscribe(onEvent)` for the route with the same payload type — end-to-end typed streaming.
|
|
1292
|
+
*
|
|
1293
|
+
* const app = server().sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
|
|
1294
|
+
* async (c, stream) => {
|
|
1295
|
+
* stream.send({ id: 1, title: "hello" }) // typed
|
|
1296
|
+
* await waitForDisconnect(stream.signal)
|
|
1297
|
+
* },
|
|
1298
|
+
* { keepAlive: 15_000 })
|
|
1299
|
+
*
|
|
1300
|
+
* // client: const off = api.feed.subscribe((post) => console.log(post.title))
|
|
1301
|
+
*
|
|
1302
|
+
* `init` passes through to the underlying {@link sse} helper (`keepAlive`, extra headers). The
|
|
1303
|
+
* connection closes when the handler resolves, `stream.close()` runs, or the client disconnects
|
|
1304
|
+
* (`stream.signal`). Query/body schemas validate exactly as on any other route.
|
|
1305
|
+
*/
|
|
1306
|
+
sse<Path extends string, S extends RouteSchema & { sse: StandardSchemaV1 }>(
|
|
1307
|
+
path: Path,
|
|
1308
|
+
schema: S,
|
|
1309
|
+
run: (
|
|
1310
|
+
context: Context<Path, S> & Ctx,
|
|
1311
|
+
stream: TypedSSEStream<InferOutput<S["sse"]>>,
|
|
1312
|
+
) => void | Promise<void>,
|
|
1313
|
+
init?: SSEInit,
|
|
1314
|
+
): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>, Ctx> {
|
|
1315
|
+
const handler = (context: Context<Path, S> & Ctx): Response =>
|
|
1316
|
+
sse(context, (stream) => run(context, typedSSEStream(stream)), init)
|
|
1317
|
+
return this.route("GET", path, schema, handler as unknown as ErasedHandler) as Server<
|
|
1318
|
+
AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>,
|
|
1319
|
+
Ctx
|
|
1320
|
+
>
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1264
1323
|
post<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
|
|
1265
1324
|
path: Path,
|
|
1266
1325
|
schema: S,
|
|
@@ -1597,14 +1656,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1597
1656
|
RESPONSE_TIMEOUT,
|
|
1598
1657
|
true,
|
|
1599
1658
|
)
|
|
1600
|
-
if (this.onResponseHooks.length === 0) {
|
|
1659
|
+
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
1601
1660
|
return outcome
|
|
1602
1661
|
}
|
|
1603
1662
|
// onResponse sees every response — success, validation error, 404/405, timeout, onRequest
|
|
1604
1663
|
// short-circuit; normalize to a promise, then thread through the hooks.
|
|
1605
1664
|
return outcome instanceof Promise
|
|
1606
|
-
? outcome.then((response) =>
|
|
1607
|
-
|
|
1665
|
+
? outcome.then((response) =>
|
|
1666
|
+
this.applyOnResponseAndFinalize(response, this.takeResponseRequest(source)),
|
|
1667
|
+
)
|
|
1668
|
+
: this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source))
|
|
1608
1669
|
}
|
|
1609
1670
|
|
|
1610
1671
|
/**
|
|
@@ -1625,7 +1686,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1625
1686
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1626
1687
|
): MaybePromise<NodeServeOutcome> {
|
|
1627
1688
|
// onResponse hooks transform a Response, so they force the Web path; wrap its result.
|
|
1628
|
-
if (this.onResponseHooks.length > 0) {
|
|
1689
|
+
if (this.onResponseHooks.length > 0 || this.onResponseFinalizedHooks.length > 0) {
|
|
1629
1690
|
const response = this.fetchSource(source, platform)
|
|
1630
1691
|
return response instanceof Promise
|
|
1631
1692
|
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
@@ -2428,6 +2489,66 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2428
2489
|
return current
|
|
2429
2490
|
}
|
|
2430
2491
|
|
|
2492
|
+
private applyOnResponseAndFinalize(response: Response, req: Request): MaybePromise<Response> {
|
|
2493
|
+
try {
|
|
2494
|
+
const transformed = this.applyOnResponse(response, req)
|
|
2495
|
+
return transformed instanceof Promise
|
|
2496
|
+
? transformed.then(
|
|
2497
|
+
(settled) => this.completeResponseFinalization({ response: settled }, req),
|
|
2498
|
+
(error) => this.failResponseFinalization(response, error, req),
|
|
2499
|
+
)
|
|
2500
|
+
: this.completeResponseFinalization({ response: transformed }, req)
|
|
2501
|
+
} catch (error) {
|
|
2502
|
+
return this.failResponseFinalization(response, error, req)
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
private completeResponseFinalization(
|
|
2507
|
+
outcome: ResponseFinalization,
|
|
2508
|
+
req: Request,
|
|
2509
|
+
): MaybePromise<Response> {
|
|
2510
|
+
const notified = this.notifyResponseFinalized(outcome, req)
|
|
2511
|
+
return notified instanceof Promise ? notified.then(() => outcome.response) : outcome.response
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
private failResponseFinalization(
|
|
2515
|
+
response: Response,
|
|
2516
|
+
error: unknown,
|
|
2517
|
+
req: Request,
|
|
2518
|
+
): Promise<never> | never {
|
|
2519
|
+
const notified = this.notifyResponseFinalized({ response, error }, req)
|
|
2520
|
+
if (notified instanceof Promise) {
|
|
2521
|
+
return notified.then(() => {
|
|
2522
|
+
throw error
|
|
2523
|
+
})
|
|
2524
|
+
}
|
|
2525
|
+
throw error
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
/** Notify terminal observers in order while isolating both sync and async failures. */
|
|
2529
|
+
private notifyResponseFinalized(outcome: ResponseFinalization, req: Request): MaybePromise<void> {
|
|
2530
|
+
let pending: Promise<void> | undefined
|
|
2531
|
+
for (const hook of this.onResponseFinalizedHooks) {
|
|
2532
|
+
if (pending !== undefined) {
|
|
2533
|
+
pending = pending.then(async () => {
|
|
2534
|
+
try {
|
|
2535
|
+
await hook(outcome, req)
|
|
2536
|
+
} catch {
|
|
2537
|
+
// Terminal observation must never change request behavior.
|
|
2538
|
+
}
|
|
2539
|
+
})
|
|
2540
|
+
continue
|
|
2541
|
+
}
|
|
2542
|
+
try {
|
|
2543
|
+
const result = hook(outcome, req)
|
|
2544
|
+
if (result instanceof Promise) pending = result.catch(() => {})
|
|
2545
|
+
} catch {
|
|
2546
|
+
// Terminal observation must never change request behavior.
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
return pending
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2431
2552
|
/** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
|
|
2432
2553
|
private async continueOnResponse(
|
|
2433
2554
|
response: Response,
|
package/src/server/sse.ts
CHANGED
|
@@ -57,6 +57,35 @@ export interface SSEInit {
|
|
|
57
57
|
readonly keepAlive?: number
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The stream handed to an `app.sse()` handler: `send` takes the route's TYPED event payload and
|
|
62
|
+
* serializes it (JSON) into the SSE `data:` field — the compile-time half of the `sse` contract.
|
|
63
|
+
*/
|
|
64
|
+
export interface TypedSSEStream<Event> {
|
|
65
|
+
/** Send one typed event. `init` sets the optional SSE frame fields (`event`, `id`, `retry`). */
|
|
66
|
+
send(event: Event, init?: Pick<SSEMessage, "event" | "id" | "retry">): void
|
|
67
|
+
/** Send a `: comment` line (e.g. an explicit ping). */
|
|
68
|
+
comment(text: string): void
|
|
69
|
+
/** End the stream now. */
|
|
70
|
+
close(): void
|
|
71
|
+
/** Aborts when the client disconnects — use it to tear down subscriptions/loops. */
|
|
72
|
+
readonly signal: AbortSignal
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Wrap a raw {@link SSEStream} in the typed, JSON-serializing surface `app.sse()` hands out. */
|
|
76
|
+
export function typedSSEStream<Event>(stream: SSEStream): TypedSSEStream<Event> {
|
|
77
|
+
return {
|
|
78
|
+
send(event, init = {}) {
|
|
79
|
+
stream.send({ ...init, data: JSON.stringify(event) })
|
|
80
|
+
},
|
|
81
|
+
comment(text) {
|
|
82
|
+
stream.send({ comment: text })
|
|
83
|
+
},
|
|
84
|
+
close: stream.close,
|
|
85
|
+
signal: stream.signal,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
60
89
|
const CRLF = /[\r\n]/g
|
|
61
90
|
const LINE_SPLIT = /\r\n|\r|\n/
|
|
62
91
|
|