@znight/http 0.5.0 → 0.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/README.md +15 -0
- package/dist/adapter-6qC95ViO.d.mts +2163 -0
- package/dist/compiler.d.mts +1 -1
- package/dist/compiler.mjs +1 -1
- package/dist/express.d.mts +1 -1
- package/dist/express.mjs +4 -1
- package/dist/express.mjs.map +1 -1
- package/dist/fastify.d.mts +1 -1
- package/dist/fastify.mjs +4 -1
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.d.mts +1 -1
- package/dist/hono.mjs +38 -3
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +3 -1117
- package/dist/index.mjs +263 -269
- package/dist/index.mjs.map +1 -1
- package/dist/{node-DnYv_AMI.mjs → node-npGnu7IW.mjs} +5 -2
- package/dist/node-npGnu7IW.mjs.map +1 -0
- package/dist/node.d.mts +1 -1
- package/dist/node.mjs +1 -1
- package/dist/{node-io-BkdRSQeg.mjs → upgrade-io-dTMDDmBi.mjs} +625 -27
- package/dist/upgrade-io-dTMDDmBi.mjs.map +1 -0
- package/package.json +4 -4
- package/src/adapter.ts +7 -0
- package/src/adapters/express.ts +4 -0
- package/src/adapters/fastify.ts +4 -0
- package/src/adapters/hono.ts +48 -2
- package/src/adapters/node-io.ts +8 -0
- package/src/adapters/node.ts +4 -0
- package/src/adapters/upgrade-io.ts +255 -0
- package/src/index.ts +14 -0
- package/src/pipeline.ts +108 -2
- package/src/plugin-routes.ts +6 -0
- package/src/plugin.ts +197 -16
- package/src/upgrade.ts +341 -0
- package/dist/adapter-DmhQGqAR.d.mts +0 -895
- package/dist/node-DnYv_AMI.mjs.map +0 -1
- package/dist/node-io-BkdRSQeg.mjs.map +0 -1
package/src/adapters/hono.ts
CHANGED
|
@@ -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
|
-
|
|
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) => {
|
package/src/adapters/node-io.ts
CHANGED
|
@@ -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,
|
package/src/adapters/node.ts
CHANGED
|
@@ -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/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:
|
|
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
|
package/src/plugin-routes.ts
CHANGED
|
@@ -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). */
|