@znight/http 0.5.1 → 0.6.1

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.
@@ -19,12 +19,14 @@ import type {
19
19
  InjectStreamResponse,
20
20
  } from '../adapter.ts'
21
21
  import type { HeaderValue } from '../exceptions.ts'
22
+ import type { AdapterUpgrades, UpgradeHandlerOptions } from '../upgrade.ts'
22
23
 
23
24
  import { type Context, Hono, type Next } from 'hono'
24
25
 
25
26
  import { BadRequestException } from '../exceptions.ts'
26
27
  import { toHonoPath } from '../path.ts'
27
28
  import { discardStream } from '../stream.ts'
29
+ import { isWebSocketUpgrade } from '../upgrade.ts'
28
30
  import { isBun } from '../utils.ts'
29
31
  import { ConnectionTracker, closeServer, nodeBodyStream, onResponseAbort, readNodeBody } from './node-io.ts'
30
32
  import {
@@ -36,6 +38,7 @@ import {
36
38
  serverAddress,
37
39
  webBodyStream,
38
40
  } from './shared.ts'
41
+ import { bunWebSocketOption, FetchUpgradeRequest, NodeUpgrades, refusalResponse } from './upgrade-io.ts'
39
42
 
40
43
  /** Options of {@link honoAdapter}. */
41
44
  export interface HonoAdapterOptions {
@@ -104,11 +107,27 @@ interface BunRuntime {
104
107
  options: HonoServeOptions & {
105
108
  port: number
106
109
  hostname: string
107
- fetch: (request: Request, server: BunServer) => Response | Promise<Response>
110
+ fetch: (request: Request, server: BunServer) => Response | undefined | Promise<Response | undefined>
111
+ websocket?: unknown
108
112
  },
109
113
  ): BunServer
110
114
  }
111
115
 
116
+ /**
117
+ * The upgrade capability of the Hono adapter (RFC 0003): `Bun.serve()` upgrades on Bun (checked in
118
+ * the fetch handler before Hono routing), `node:http` upgrades of `@hono/node-server` on Node.js.
119
+ */
120
+ class HonoUpgrades implements AdapterUpgrades {
121
+ readonly kind: 'node' | 'bun' = isBun() ? 'bun' : 'node'
122
+ readonly node: NodeUpgrades = new NodeUpgrades()
123
+ options: UpgradeHandlerOptions | undefined
124
+
125
+ enable(options: UpgradeHandlerOptions): void {
126
+ this.options = options
127
+ this.node.enable(options)
128
+ }
129
+ }
130
+
112
131
  /** Default `idleTimeout` of `Bun.serve()`, in seconds. */
113
132
  const BUN_IDLE_TIMEOUT = 10
114
133
 
@@ -180,6 +199,8 @@ interface ReadHandlers {
180
199
  class HonoHttpAdapter implements HonoAdapter {
181
200
  readonly name = 'hono' as const
182
201
  readonly app: Hono
202
+ /** WebSocket upgrades (RFC 0003). */
203
+ readonly upgrades: HonoUpgrades = new HonoUpgrades()
183
204
  readonly #serveOptions: HonoServeOptions
184
205
  /** Number of routes of `app` registered by the adapter (see {@link #middleware}). */
185
206
  #routes = 0
@@ -411,6 +432,27 @@ class HonoHttpAdapter implements HonoAdapter {
411
432
  return response
412
433
  }
413
434
 
435
+ /**
436
+ * A WebSocket upgrade of a gateway path received by `Bun.serve()`, run before Hono routing:
437
+ * `undefined` for any other request; otherwise the refusal, or `undefined` once upgraded.
438
+ */
439
+ #bunUpgrade(
440
+ options: UpgradeHandlerOptions,
441
+ request: Request,
442
+ server: BunServer,
443
+ ): Promise<Response | undefined> | undefined {
444
+ const upgrade = request.headers.get('upgrade')
445
+ if (upgrade === null || !isWebSocketUpgrade({ upgrade })) return undefined
446
+ const url = pathAndQuery(request.url)
447
+ if (!options.match(pathOf(url))) return undefined
448
+ const address = server.requestIP?.(request)?.address ?? undefined
449
+ const adapterRequest = new FetchUpgradeRequest(request, url, fetchHeaders(request.headers), address)
450
+ return options.handle(adapterRequest, { kind: 'bun', request, server }).then(
451
+ reply => (reply === undefined ? undefined : refusalResponse(reply)),
452
+ () => new Response(null, { status: 500 }),
453
+ )
454
+ }
455
+
414
456
  async listen(port: number, host: string): Promise<HttpServerAddress> {
415
457
  this.#prepare()
416
458
  this.#closing = undefined
@@ -418,15 +460,18 @@ class HonoHttpAdapter implements HonoAdapter {
418
460
  if (isBun() && bun !== undefined) {
419
461
  const options = this.#serveOptions
420
462
  this.#idleTimeout = typeof options.idleTimeout === 'number' ? options.idleTimeout : BUN_IDLE_TIMEOUT
463
+ const upgrades = this.upgrades.options
421
464
  const server = bun.serve({
422
465
  // Znight enforces the body limits (route @BodyLimit, plugin bodyLimit): not Bun's 128 MiB default
423
466
  maxRequestBodySize: Number.MAX_SAFE_INTEGER,
424
467
  ...options,
468
+ ...(upgrades === undefined ? {} : { websocket: bunWebSocketOption(upgrades.bunWebSocket) }),
425
469
  port,
426
470
  hostname: host,
427
471
  // without middleware, the response of a Znight route is the one Bun sends (no wrapper needed)
428
472
  fetch: (request, server) =>
429
- this.#middleware() ? this.#bunFetch(request, server) : this.app.fetch(request, server),
473
+ (upgrades === undefined ? undefined : this.#bunUpgrade(upgrades, request, server)) ??
474
+ (this.#middleware() ? this.#bunFetch(request, server) : this.app.fetch(request, server)),
430
475
  })
431
476
  this.#bun = server
432
477
  return serverAddress(server.hostname, server.port)
@@ -441,6 +486,7 @@ class HonoHttpAdapter implements HonoAdapter {
441
486
  )
442
487
  }
443
488
  const server = nodeServer.createAdaptorServer({ fetch: this.app.fetch }) as Server
489
+ this.upgrades.node.attach(server)
444
490
  this.#node = server
445
491
  this.#tracker = new ConnectionTracker(server)
446
492
  await new Promise<void>((resolve, reject) => {
@@ -445,6 +445,14 @@ export class ConnectionTracker {
445
445
  }
446
446
  }
447
447
 
448
+ /**
449
+ * Marks a socket taken over by a WebSocket transport (RFC 0003) as busy: a graceful close of the
450
+ * server leaves it to its transport, which closes it with a close frame; a forced close destroys it.
451
+ */
452
+ export function markUpgraded(socket: object): void {
453
+ ;(socket as TrackedSocket)[ACTIVE] = 1
454
+ }
455
+
448
456
  /** Closes a `node:http` server: graceful (idle connections ended), then forced when asked. */
449
457
  export function closeServer(
450
458
  server: Server,
@@ -20,6 +20,7 @@ import { createServer, type IncomingMessage, type Server, type ServerOptions, ty
20
20
  import { RouteTable } from '../router.ts'
21
21
  import { ConnectionTracker, closeServer, nodeAdapterRequest, writeNodeFailure, writeNodeReply } from './node-io.ts'
22
22
  import { bufferInjectStream, injectNodeStream, serverAddress } from './shared.ts'
23
+ import { NodeUpgrades } from './upgrade-io.ts'
23
24
 
24
25
  /** Options of {@link nodeAdapter}. */
25
26
  export interface NodeAdapterOptions {
@@ -55,6 +56,8 @@ export function nodeAdapter(options: NodeAdapterOptions = {}): NodeAdapter {
55
56
 
56
57
  class NodeHttpAdapter implements NodeAdapter {
57
58
  readonly name = 'node' as const
59
+ /** WebSocket upgrades (RFC 0003), routed by the server of {@link listen}. */
60
+ readonly upgrades: NodeUpgrades = new NodeUpgrades()
58
61
  readonly #routes = new RouteTable<AdapterHandler>()
59
62
  readonly #options: NodeAdapterOptions
60
63
  #fallback: FallbackHandler | undefined
@@ -107,6 +110,7 @@ class NodeHttpAdapter implements NodeAdapter {
107
110
  this.#tracker ??= new ConnectionTracker(server, { requests: false })
108
111
  this.#closing = undefined
109
112
  if (!server.listeners('request').includes(this.handler)) server.on('request', this.handler)
113
+ this.upgrades.attach(server)
110
114
  await new Promise<void>((resolve, reject) => {
111
115
  const onError = (error: Error): void => reject(error)
112
116
  server.once('error', onError)
@@ -0,0 +1,255 @@
1
+ /**
2
+ * WebSocket upgrades on the adapters (RFC 0003 §8.2.3, internal): the upgrade capability of
3
+ * `node:http` servers, the request of a handshake as an {@link AdapterRequest} (Node.js, Fetch,
4
+ * in memory) and the raw HTTP refusal written on a socket.
5
+ */
6
+ import type { IncomingMessage, Server } from 'node:http'
7
+ import type { Duplex } from 'node:stream'
8
+ import type { AdapterReply, AdapterRequest } from '../adapter.ts'
9
+ import type { HeaderValue } from '../exceptions.ts'
10
+ import type { AdapterUpgrades, BunSocketHandlers, UpgradeHandlerOptions } from '../upgrade.ts'
11
+
12
+ import { statusText } from '../status.ts'
13
+ import { isWebSocketUpgrade } from '../upgrade.ts'
14
+ import { markUpgraded, normalizeNodeHeaders } from './node-io.ts'
15
+ import { concat } from './shared.ts'
16
+
17
+ const EMPTY = new Uint8Array(0)
18
+
19
+ /** A handshake has no body: Znight never reads one. */
20
+ const NO_BODY: Promise<Uint8Array> = Promise.resolve(EMPTY)
21
+
22
+ /** The `IncomingMessage` of a `node:http` upgrade as an {@link AdapterRequest}. */
23
+ class NodeUpgradeRequest implements AdapterRequest {
24
+ readonly method: string
25
+ readonly url: string
26
+ readonly headers: Readonly<Record<string, string | undefined>>
27
+ readonly ip: string | undefined
28
+ readonly remoteAddress: string | undefined
29
+ readonly encrypted: boolean
30
+ readonly raw: unknown
31
+ readonly rawResponse: unknown
32
+
33
+ constructor(request: IncomingMessage, socket: Duplex) {
34
+ const peer = (socket as { readonly remoteAddress?: string }).remoteAddress ?? request.socket?.remoteAddress
35
+ this.method = request.method ?? 'GET'
36
+ this.url = request.url ?? '/'
37
+ this.headers = normalizeNodeHeaders(request.headers)
38
+ this.ip = peer
39
+ this.remoteAddress = peer
40
+ this.encrypted = (request.socket as { readonly encrypted?: unknown } | undefined)?.encrypted === true
41
+ this.raw = request
42
+ this.rawResponse = socket
43
+ }
44
+
45
+ readBody(): Promise<Uint8Array> {
46
+ return NO_BODY
47
+ }
48
+
49
+ hasBody(): boolean {
50
+ return false
51
+ }
52
+ }
53
+
54
+ /** The Fetch `Request` of a `Bun.serve()` upgrade as an {@link AdapterRequest}. */
55
+ export class FetchUpgradeRequest implements AdapterRequest {
56
+ readonly method: string
57
+ readonly url: string
58
+ readonly headers: Readonly<Record<string, string | undefined>>
59
+ readonly ip: string | undefined
60
+ readonly remoteAddress: string | undefined
61
+ readonly encrypted: boolean
62
+ readonly raw: Request
63
+ readonly rawResponse: unknown = undefined
64
+
65
+ constructor(request: Request, url: string, headers: Record<string, string>, address: string | undefined) {
66
+ this.method = request.method
67
+ this.url = url
68
+ this.headers = headers
69
+ this.ip = address
70
+ this.remoteAddress = address
71
+ this.encrypted = request.url.startsWith('https:')
72
+ this.raw = request
73
+ }
74
+
75
+ readBody(): Promise<Uint8Array> {
76
+ return NO_BODY
77
+ }
78
+
79
+ hasBody(): boolean {
80
+ return false
81
+ }
82
+ }
83
+
84
+ /** An in-memory handshake (`HttpPlugin.upgradeInMemory()`) as an {@link AdapterRequest}. */
85
+ export class MemoryUpgradeRequest implements AdapterRequest {
86
+ readonly method: string
87
+ readonly url: string
88
+ readonly headers: Readonly<Record<string, string | undefined>>
89
+ readonly ip = '127.0.0.1'
90
+ readonly remoteAddress = '127.0.0.1'
91
+ readonly encrypted = false
92
+ readonly raw: unknown = undefined
93
+ readonly rawResponse: unknown = undefined
94
+
95
+ constructor(method: string, url: string, headers: Record<string, string>) {
96
+ this.method = method
97
+ this.url = url
98
+ this.headers = headers
99
+ }
100
+
101
+ readBody(): Promise<Uint8Array> {
102
+ return NO_BODY
103
+ }
104
+
105
+ hasBody(): boolean {
106
+ return false
107
+ }
108
+ }
109
+
110
+ /** The bytes of a reply body (refusals are small: read whole). */
111
+ export async function replyBytes(reply: AdapterReply): Promise<Uint8Array> {
112
+ const body = reply.body
113
+ if (body === null) return EMPTY
114
+ if (body instanceof Uint8Array) return body
115
+ if (body instanceof Blob) return new Uint8Array(await body.arrayBuffer())
116
+ const chunks: Uint8Array[] = []
117
+ let size = 0
118
+ for await (const chunk of body as ReadableStream<Uint8Array>) {
119
+ chunks.push(chunk)
120
+ size += chunk.byteLength
121
+ }
122
+ return concat(chunks, size)
123
+ }
124
+
125
+ /** Whether a header value can be written on a raw socket (no CR/LF injection). */
126
+ function safeHeader(value: string): boolean {
127
+ return !/[\r\n]/.test(value)
128
+ }
129
+
130
+ /**
131
+ * Writes a refused handshake as a raw HTTP/1.1 response on the upgrade socket, then ends it
132
+ * (`connection: close`). Never throws.
133
+ */
134
+ export async function writeRawRefusal(socket: Duplex, reply: AdapterReply): Promise<void> {
135
+ try {
136
+ const body = await replyBytes(reply)
137
+ let head = `HTTP/1.1 ${reply.status} ${statusText(reply.status)}\r\n`
138
+ const headers: Record<string, HeaderValue> = { ...reply.headers }
139
+ headers['connection'] = 'close'
140
+ headers['content-length'] = String(body.byteLength)
141
+ for (const [name, value] of Object.entries(headers)) {
142
+ for (const item of typeof value === 'string' ? [value] : value) {
143
+ if (safeHeader(name) && safeHeader(item)) head += `${name}: ${item}\r\n`
144
+ }
145
+ }
146
+ head += '\r\n'
147
+ if (socket.destroyed) return
148
+ socket.write(head)
149
+ if (body.byteLength > 0) socket.write(body)
150
+ socket.end()
151
+ // a client that never closes its side: destroy after a short grace
152
+ setTimeout(() => socket.destroy(), 1_000).unref?.()
153
+ } catch {
154
+ socket.destroy()
155
+ }
156
+ }
157
+
158
+ /** The fallback reply when the pipeline failed (never expected). */
159
+ const FAILURE: AdapterReply = Object.freeze({ status: 500, headers: {}, body: null })
160
+
161
+ /**
162
+ * The upgrade capability of an adapter built on a `node:http` server (RFC 0003 §8.2.3): upgrade
163
+ * requests of the gateway paths are routed to the pipeline through `shouldUpgradeCallback`
164
+ * (composed with the server's own) and an `'upgrade'` listener; other upgrade requests are left to
165
+ * the server (its own listeners, or ordinary requests).
166
+ */
167
+ export class NodeUpgrades implements AdapterUpgrades {
168
+ readonly kind = 'node' as const
169
+ #options: UpgradeHandlerOptions | undefined
170
+ readonly #attached = new WeakSet<Server>()
171
+
172
+ /** Whether {@link enable} was called. */
173
+ get enabled(): boolean {
174
+ return this.#options !== undefined
175
+ }
176
+
177
+ enable(options: UpgradeHandlerOptions): void {
178
+ this.#options = options
179
+ }
180
+
181
+ /** Routes the gateway upgrades of `server` to the pipeline (once per server; no-op when not enabled). */
182
+ attach(server: Server): void {
183
+ const options = this.#options
184
+ if (options === undefined || this.#attached.has(server)) return
185
+ this.#attached.add(server)
186
+ const matches = (request: IncomingMessage): boolean => {
187
+ if (!isWebSocketUpgrade(request.headers as Record<string, string | undefined>)) return false
188
+ const url = request.url ?? '/'
189
+ const query = url.indexOf('?')
190
+ return options.match(query === -1 ? url : url.slice(0, query))
191
+ }
192
+ const target = server as Server & { shouldUpgradeCallback?: (request: IncomingMessage) => boolean }
193
+ const previous = target.shouldUpgradeCallback
194
+ const composable = typeof previous === 'function'
195
+ const listener = (request: IncomingMessage, socket: Duplex, head: Uint8Array): void => {
196
+ if (!matches(request)) {
197
+ // without shouldUpgradeCallback (Node.js < 24.12), every upgrade reaches the listeners: answer
198
+ // the ones nobody else handles
199
+ if (!composable && server.listenerCount('upgrade') === 1) {
200
+ void writeRawRefusal(socket, { status: 404, headers: {}, body: null })
201
+ }
202
+ return
203
+ }
204
+ socket.on('error', () => socket.destroy())
205
+ markUpgraded(socket)
206
+ const native = { kind: 'node', request, socket, head } as const
207
+ options.handle(new NodeUpgradeRequest(request, socket), native).then(
208
+ reply => {
209
+ if (reply !== undefined) void writeRawRefusal(socket, reply)
210
+ },
211
+ () => void writeRawRefusal(socket, FAILURE),
212
+ )
213
+ }
214
+ if (previous !== undefined) {
215
+ target.shouldUpgradeCallback = function (this: Server, request: IncomingMessage): boolean {
216
+ if (matches(request)) return true
217
+ // the server's callback without Znight's listener (Node's default counts the listeners)
218
+ const self = this ?? server
219
+ const view = Object.create(self, {
220
+ listenerCount: {
221
+ value: (name: string | symbol): number => self.listenerCount(name) - (name === 'upgrade' ? 1 : 0),
222
+ },
223
+ }) as Server
224
+ return previous.call(view, request) === true
225
+ }
226
+ }
227
+ server.on('upgrade', listener)
228
+ }
229
+ }
230
+
231
+ /**
232
+ * The `websocket` option of `Bun.serve()`: the plugins' settings, and handlers dispatching every
233
+ * event to the {@link BunSocketHandlers} given as `data` to `server.upgrade()`.
234
+ */
235
+ export function bunWebSocketOption(settings: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
236
+ type Socket = { readonly data: BunSocketHandlers }
237
+ return Object.freeze({
238
+ ...settings,
239
+ open: (ws: Socket): void => ws.data.open(ws),
240
+ message: (ws: Socket, message: string | Uint8Array): void => ws.data.message(ws, message),
241
+ close: (ws: Socket, code: number, reason: string): void => ws.data.close(ws, code, reason),
242
+ drain: (ws: Socket): void => ws.data.drain(ws),
243
+ pong: (ws: Socket): void => ws.data.pong?.(ws),
244
+ })
245
+ }
246
+
247
+ /** Builds the `Response` of a refused `Bun.serve()` upgrade. */
248
+ export async function refusalResponse(reply: AdapterReply): Promise<Response> {
249
+ const headers = new Headers()
250
+ for (const [name, value] of Object.entries(reply.headers)) {
251
+ for (const item of typeof value === 'string' ? [value] : value) headers.append(name, item)
252
+ }
253
+ const body = await replyBytes(reply)
254
+ return new Response(body.byteLength === 0 ? null : body, { status: reply.status, headers })
255
+ }
package/src/decorators.ts CHANGED
@@ -286,7 +286,7 @@ export function BodyLimit(_size: ByteSize): ClassMarker & MethodMarker {
286
286
  return classOrMethodMarker
287
287
  }
288
288
 
289
- /** Injects the native request of the adapter (escape hatch; the compiler warns). */
289
+ /** Injects the native request of the adapter (escape hatch). */
290
290
  export function Req(): ParameterMarker {
291
291
  return parameterMarker
292
292
  }
package/src/index.ts CHANGED
@@ -59,6 +59,20 @@ export type { ByteSize } from './size.ts'
59
59
  export type { SseBody, SseEvent, SseOptions, SseSource } from './sse.ts'
60
60
  export type { ParseSseOptions, SseItem, SseMessage } from './sse-parse.ts'
61
61
  export type { StreamSource } from './stream.ts'
62
+ export type {
63
+ AdapterUpgrades,
64
+ BunSocketHandlers,
65
+ BunUpgrade,
66
+ MemoryUpgrade,
67
+ NativeUpgrade,
68
+ NodeUpgrade,
69
+ PendingUpgrade,
70
+ PluginHttpUpgrades,
71
+ PluginHttpUpgradesContext,
72
+ PluginUpgradeRoute,
73
+ PluginUpgrades,
74
+ UpgradeHandlerOptions,
75
+ } from './upgrade.ts'
62
76
 
63
77
  export { type NodeAdapter, type NodeAdapterOptions, nodeAdapter } from './adapters/node.ts'
64
78
  export { isJsonType, type MediaType, parseBody, parseMediaType } from './body.ts'
package/src/pipeline.ts CHANGED
@@ -8,6 +8,7 @@ import type { CanActivate, ExceptionMapper, HttpInterceptor } from './interfaces
8
8
  import type { HttpRouteDefinition, HttpSseDefinition } from './metadata.ts'
9
9
  import type { TrustPolicy } from './proxy.ts'
10
10
  import type { RouteTable } from './router.ts'
11
+ import type { NativeUpgrade } from './upgrade.ts'
11
12
  import type { BodyPlan } from './uploads.ts'
12
13
 
13
14
  import { Buffer } from 'node:buffer'
@@ -46,6 +47,7 @@ import { isSseBody, isSseSource, type SseOptions, SseSession, type SseSource } f
46
47
  import { isBodylessStatus } from './status.ts'
47
48
  import { type ContextRunner, captureContext, discardStream, type TrackedStream, trackStream } from './stream.ts'
48
49
  import { HttpTokens } from './tokens.ts'
50
+ import { PendingUpgradeState } from './upgrade.ts'
49
51
  import { emptyRecord, errorMessage, fastRecord, isBun, isThenable } from './utils.ts'
50
52
 
51
53
  /** A dependency of the pipeline: an instance resolved at bootstrap, or a token resolved per request. */
@@ -73,6 +75,24 @@ export interface RoutePlan {
73
75
  readonly redirect?: boolean
74
76
  }
75
77
 
78
+ /** An upgrade route: its plan, and how long the handshake may take (internal). */
79
+ export interface UpgradePlan {
80
+ readonly plan: RoutePlan
81
+ /** `408` after this delay (ms); `undefined`: no limit. */
82
+ readonly timeoutMs: number | undefined
83
+ }
84
+
85
+ /**
86
+ * The pending upgrade of a handshake context (RFC 0003): set by {@link Pipeline.upgrade} for the
87
+ * upgrade routes only, so that ordinary requests pay nothing.
88
+ */
89
+ const UPGRADES = new WeakMap<object, PendingUpgradeState>()
90
+
91
+ /** The pending upgrade of the handshake whose context is `ctx` (internal, upgrade routes). */
92
+ export function pendingUpgradeOf(ctx: HttpContext): PendingUpgradeState | undefined {
93
+ return UPGRADES.get(ctx)
94
+ }
95
+
76
96
  /** Pipeline settings (resolved plugin options). */
77
97
  export interface PipelineOptions {
78
98
  readonly adapter: string
@@ -241,6 +261,8 @@ export class Pipeline {
241
261
  readonly #streams = new Set<SseSession>()
242
262
  /** Set while the server drains: responses ask clients to close their connection. */
243
263
  draining = false
264
+ /** The upgrade routes (RFC 0003), `undefined` when no plugin declares one. */
265
+ upgrades: RouteTable<UpgradePlan> | undefined = undefined
244
266
 
245
267
  constructor(
246
268
  container: Container,
@@ -781,6 +803,70 @@ export class Pipeline {
781
803
  }
782
804
  }
783
805
 
806
+ /**
807
+ * Serves a WebSocket handshake (RFC 0003 §8.2.2): the upgrade route of its raw path runs through
808
+ * the pipeline. Resolves with the reply to write (a refusal), or `undefined` once the transport
809
+ * took the connection; the request scope then stays open until the connection closes.
810
+ */
811
+ upgrade(request: AdapterRequest, native: NativeUpgrade): Promise<AdapterReply | undefined> {
812
+ const startTime = performance.now()
813
+ try {
814
+ const url = splitUrl(request.url)
815
+ const upgrade = this.upgrades?.find('GET', url.path)
816
+ if (upgrade === undefined) return this.#fallback(request, undefined, startTime)
817
+ const plan = upgrade.plan
818
+ const raw = matchRoutePath(plan.path, url.path) ?? emptyRecord<string>()
819
+ const params = emptyRecord<string>()
820
+ let malformed: string | undefined
821
+ for (const name of Object.keys(raw)) {
822
+ const decoded = decodePathValue(raw[name] as string)
823
+ if (decoded === undefined) malformed ??= name
824
+ else params[name] = decoded
825
+ }
826
+ const target: Target =
827
+ malformed === undefined
828
+ ? { kind: 'route', plan }
829
+ : { kind: 'error', error: new BadRequestException(`Malformed URL parameter "${malformed}"`) }
830
+ let pending: PendingUpgradeState | undefined
831
+ const replied = this.#serve(request, url, plan.info, params, target, startTime, plan.body, ctx => {
832
+ pending = new PendingUpgradeState(native, () => this.#upgradeHeaders(ctx))
833
+ UPGRADES.set(ctx, pending)
834
+ })
835
+ const settled = replied.then(reply => (pending?.accepted === true ? undefined : reply))
836
+ const timeoutMs = upgrade.timeoutMs
837
+ if (timeoutMs === undefined) return settled
838
+ let timer: ReturnType<typeof setTimeout> | undefined
839
+ const expired = new Promise<AdapterReply>(resolve => {
840
+ timer = setTimeout(() => {
841
+ if (pending !== undefined) pending.expired = true
842
+ const body = { statusCode: 408, error: 'Request Timeout', message: 'WebSocket handshake timed out' }
843
+ resolve(adapterReply(serializeError(408, body, 'GET'), null))
844
+ }, timeoutMs)
845
+ })
846
+ return Promise.race([settled, expired]).finally(() => clearTimeout(timer))
847
+ } catch (error) {
848
+ return Promise.reject(error)
849
+ }
850
+ }
851
+
852
+ /** The headers of a `101` response: those set on the context, its cookies and the request id. */
853
+ #upgradeHeaders(ctx: RequestContext): Readonly<Record<string, HeaderValue>> {
854
+ const headers: Record<string, HeaderValue> = { ...ctx.responseHeaders }
855
+ const cookies = ctx.cookieLines
856
+ if (cookies.length > 0) {
857
+ const existing = headers['set-cookie']
858
+ const raw: string[] = []
859
+ if (typeof existing === 'string') raw.push(existing)
860
+ else if (existing !== undefined) raw.push(...existing)
861
+ headers['set-cookie'] = [...raw, ...cookies.map(cookie => cookie.line)]
862
+ }
863
+ const idHeader = this.#options.requestIdHeader
864
+ if (idHeader !== undefined && headers[idHeader] === undefined && (this.#validIds || isHeaderValue(ctx.requestId))) {
865
+ headers[idHeader] = ctx.requestId
866
+ }
867
+ return headers
868
+ }
869
+
784
870
  #fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<AdapterReply> {
785
871
  const body = this.#options.body
786
872
  if (error !== undefined) {
@@ -799,7 +885,7 @@ export class Pipeline {
799
885
  const allowed = this.#table.allowedMethods(path)
800
886
  let target: Target
801
887
  if (allowed.length === 0) {
802
- target = { kind: 'error', error: new NotFoundException(`Cannot ${request.method} ${truncate(path)}`) }
888
+ target = { kind: 'error', error: this.#unmatched(request.method, path) }
803
889
  } else if (request.method === 'OPTIONS') {
804
890
  target = { kind: 'options', allow: allowHeader(allowed) }
805
891
  } else {
@@ -818,6 +904,19 @@ export class Pipeline {
818
904
  return this.#serve(request, url, undefined, emptyRecord(), target, startTime, body)
819
905
  }
820
906
 
907
+ /**
908
+ * The error of a request no route matched: `404`, or `426 Upgrade Required` for a plain `GET` of
909
+ * a WebSocket gateway path (RFC 0003 §8.2.1).
910
+ */
911
+ #unmatched(method: string, path: string): HttpException {
912
+ if ((method === 'GET' || method === 'HEAD') && this.upgrades?.find('GET', path) !== undefined) {
913
+ return new HttpException(426, 'This endpoint only accepts WebSocket connections', undefined, {
914
+ headers: { upgrade: 'websocket', connection: 'upgrade' },
915
+ })
916
+ }
917
+ return new NotFoundException(`Cannot ${method} ${truncate(path)}`)
918
+ }
919
+
821
920
  /** A CORS preflight (with the `cors` option): answered without running any route. */
822
921
  #isPreflight(request: AdapterRequest): boolean {
823
922
  return this.#options.cors !== undefined && isPreflight(request.method, request.headers)
@@ -838,12 +937,14 @@ export class Pipeline {
838
937
  target: Target,
839
938
  startTime: number,
840
939
  body: BodyPlan,
940
+ prepare?: (ctx: RequestContext) => void,
841
941
  ): Promise<AdapterReply> {
842
942
  this.#enter()
843
943
  let x: Exchange
844
944
  try {
845
945
  const requestId = this.#requestId(request)
846
946
  const ctx = this.#context(request, requestId, url.path, url.query, route, params, body)
947
+ if (prepare !== undefined) prepare(ctx)
847
948
  // an event stream requested while draining is served (guards run) with its signal already
848
949
  // aborted: a well-behaved source ends at once and the client gets a jittered retry
849
950
  if (this.draining && target.kind === 'route' && target.plan.definition.sse !== undefined) ctx.abort('shutdown')
@@ -958,6 +1059,11 @@ export class Pipeline {
958
1059
  }
959
1060
  const streaming = this.#send(x, outcome, recheck, run)
960
1061
  if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
1062
+ else if (outcome === RAW) {
1063
+ // an accepted WebSocket handshake: the connection lives in this scope until it closes
1064
+ const held = UPGRADES.get(ctx)?.held
1065
+ if (held !== undefined) await held
1066
+ }
961
1067
  }
962
1068
 
963
1069
  /**
@@ -993,7 +1099,7 @@ export class Pipeline {
993
1099
  const event = x.event
994
1100
  if (outcome === RAW) {
995
1101
  ctx.commit()
996
- const status = x.request.rawStatus?.() ?? 200
1102
+ const status = UPGRADES.get(ctx)?.accepted === true ? 101 : (x.request.rawStatus?.() ?? 200)
997
1103
  event.status ??= status
998
1104
  x.answer({ status, headers: {}, body: null, raw: true })
999
1105
  return undefined
@@ -9,6 +9,7 @@ import type { RouteMetaEntry } from './decorators.ts'
9
9
  import type { HttpMethod } from './metadata.ts'
10
10
  import type { GuardRef } from './plugin.ts'
11
11
  import type { ByteSize } from './size.ts'
12
+ import type { PluginHttpUpgradesContext, PluginUpgrades } from './upgrade.ts'
12
13
 
13
14
  import { ErrorCode, ZnightError } from '@znight/core'
14
15
 
@@ -79,6 +80,11 @@ export interface PluginHttpRoutes {
79
80
  * waits for a returned promise too (within `drainTimeoutMs`); a failure is logged.
80
81
  */
81
82
  onHttpDrain?(): void | Promise<void>
83
+ /**
84
+ * WebSocket upgrade routes of the plugin (RFC 0003, `@znight/websocket`): see
85
+ * {@link PluginHttpUpgrades}. Called once in `onBootstrap`, after `httpRoutes()`.
86
+ */
87
+ httpUpgrades?(context: PluginHttpUpgradesContext): PluginUpgrades
82
88
  }
83
89
 
84
90
  /** A validated plugin route (internal). */