@znight/http 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znight/http",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono",
5
5
  "license": "MIT",
6
6
  "author": "Ugho STEPHAN",
@@ -79,7 +79,7 @@
79
79
  "access": "public"
80
80
  },
81
81
  "dependencies": {
82
- "@znight/core": "0.2.2"
82
+ "@znight/core": "0.3.0"
83
83
  },
84
84
  "peerDependencies": {
85
85
  "@hono/node-server": "^2.1.5",
@@ -87,7 +87,7 @@
87
87
  "fastify": "^5.12.7",
88
88
  "hono": "^4.13.14",
89
89
  "light-my-request": "^6.8.0",
90
- "@znight/compiler": "^0.2.2"
90
+ "@znight/compiler": "^0.3.0"
91
91
  },
92
92
  "peerDependenciesMeta": {
93
93
  "@hono/node-server": {
@@ -118,7 +118,7 @@
118
118
  "valibot": "1.5.0",
119
119
  "zod": "4.6.7",
120
120
  "light-my-request": "6.8.0",
121
- "@znight/compiler": "0.2.2"
121
+ "@znight/compiler": "0.3.0"
122
122
  },
123
123
  "znight": {
124
124
  "apiVersion": 1,
@@ -3,7 +3,7 @@
3
3
  * health or metrics endpoints), registered on every adapter in the same pipeline as the compiled
4
4
  * controllers.
5
5
  */
6
- import type { Container, Plugin } from '@znight/core'
6
+ import type { Container, CoreLogger, Plugin } from '@znight/core'
7
7
  import type { HttpContext } from './context.ts'
8
8
  import type { RouteMetaEntry } from './decorators.ts'
9
9
  import type { HttpMethod } from './metadata.ts'
@@ -71,6 +71,14 @@ export interface PluginHttpRoutesContext {
71
71
  */
72
72
  export interface PluginHttpRoutes {
73
73
  httpRoutes(context: PluginHttpRoutesContext): readonly PluginRoute[]
74
+ /**
75
+ * Called once when `HttpPlugin` starts draining (its `onStop`, whatever the plugin order), after
76
+ * it stopped accepting connections and started closing its event streams: end the long-lived
77
+ * responses `HttpPlugin` does not know of (a stream another server produced), so that the drain
78
+ * does not wait for them until `drainTimeoutMs`. Requests still in flight are served. The drain
79
+ * waits for a returned promise too (within `drainTimeoutMs`); a failure is logged.
80
+ */
81
+ onHttpDrain?(): void | Promise<void>
74
82
  }
75
83
 
76
84
  /** A validated plugin route (internal). */
@@ -122,6 +130,31 @@ export function collectPluginRoutes(
122
130
  return collected
123
131
  }
124
132
 
133
+ /**
134
+ * Calls `onHttpDrain()` on every other plugin implementing it, in plugin order: the promises the
135
+ * drain waits for (they never reject: a failure is logged).
136
+ */
137
+ export function drainPlugins(plugins: readonly Plugin[], self: Plugin, logger: CoreLogger): Promise<void>[] {
138
+ const pending: Promise<void>[] = []
139
+ for (const plugin of plugins) {
140
+ if (plugin === self) continue
141
+ const drain = (plugin as Plugin & Partial<PluginHttpRoutes>).onHttpDrain
142
+ if (typeof drain !== 'function') continue
143
+ const failed = (error: unknown): void => {
144
+ logger.error(`HttpPlugin: onHttpDrain() of the plugin "${String(plugin.name)}" failed`, { err: error })
145
+ }
146
+ try {
147
+ const result: unknown = drain.call(plugin)
148
+ if (typeof (result as PromiseLike<void> | undefined)?.then === 'function') {
149
+ pending.push(Promise.resolve(result as PromiseLike<void>).then(undefined, failed))
150
+ }
151
+ } catch (error) {
152
+ failed(error)
153
+ }
154
+ }
155
+ return pending
156
+ }
157
+
125
158
  function validateRoute(instance: object, plugin: string, value: unknown, prefix: string): CollectedPluginRoute {
126
159
  if (value === null || typeof value !== 'object') throw invalid(plugin, `invalid route ${describe(value)}`)
127
160
  const route = value as Partial<PluginRoute>
package/src/plugin.ts CHANGED
@@ -56,7 +56,7 @@ import {
56
56
  randomRequestId,
57
57
  type SseSettings,
58
58
  } from './pipeline.ts'
59
- import { collectPluginRoutes } from './plugin-routes.ts'
59
+ import { collectPluginRoutes, drainPlugins } from './plugin-routes.ts'
60
60
  import { resolveTrustProxy } from './proxy.ts'
61
61
  import { RouteTable } from './router.ts'
62
62
  import { resolveSecurityHeaders } from './security-headers.ts'
@@ -552,6 +552,8 @@ export class HttpPlugin implements Plugin {
552
552
  runtime.pipeline.draining = true
553
553
  // event streams never end by themselves: let their sources finish, then send them a jittered retry
554
554
  runtime.pipeline.closeStreams('shutdown')
555
+ // the streams other plugins serve on their routes (a GraphQL driver's subscriptions)
556
+ const drains = drainPlugins(ctx.app.plugins, this, ctx.logger)
555
557
  const timeoutMs = runtime.options.drainTimeoutMs
556
558
  let timer: ReturnType<typeof setTimeout> | undefined
557
559
  // no timer for Infinity; larger delays than setTimeout supports are clamped (they would fire at once)
@@ -561,7 +563,9 @@ export class HttpPlugin implements Plugin {
561
563
  }
562
564
  })
563
565
  try {
564
- const drained = Promise.all([runtime.adapter.close(), runtime.pipeline.whenIdle()]).then(() => 'drained' as const)
566
+ const drained = Promise.all([runtime.adapter.close(), runtime.pipeline.whenIdle(), ...drains]).then(
567
+ () => 'drained' as const,
568
+ )
565
569
  if ((await Promise.race([drained, timeout])) === 'timeout') {
566
570
  ctx.logger.warn(`HTTP server: closing ${runtime.pipeline.inflight} in-flight request(s) after ${timeoutMs} ms`)
567
571
  await runtime.adapter.close({ force: true })