@nifrajs/core 1.3.1 → 1.5.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.
@@ -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) => this.applyOnResponse(response, this.takeResponseRequest(source)))
1607
- : this.applyOnResponse(outcome, this.takeResponseRequest(source))
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