@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.
package/src/plugin.ts CHANGED
@@ -25,10 +25,13 @@ import type { CollectedPluginRoute } from './plugin-routes.ts'
25
25
  import type { TrustPolicy, TrustProxy } from './proxy.ts'
26
26
  import type { SecurityHeadersOptions } from './security-headers.ts'
27
27
  import type { ByteSize } from './size.ts'
28
+ import type { CollectedUpgradeRoute, MemoryUpgrade } from './upgrade.ts'
28
29
 
29
30
  import { displayName, ErrorCode, resolveOptions, ZnightError } from '@znight/core'
30
31
 
31
32
  import { nodeAdapter } from './adapters/node.ts'
33
+ import { injectResponse, normalizeInject } from './adapters/shared.ts'
34
+ import { MemoryUpgradeRequest, replyBytes } from './adapters/upgrade-io.ts'
32
35
  import { DEFAULT_COOKIE_SETTINGS, missingSecrets, signValue } from './cookie-settings.ts'
33
36
  import { type CookieSecret, CookieSigner, checkSecrets } from './cookie-signer.ts'
34
37
  import { DEFAULT_COOKIE_OPTIONS, decodeCookieValue, isCookieName, serializeResponseCookie } from './cookies.ts'
@@ -43,6 +46,7 @@ import {
43
46
  RouteGroups,
44
47
  type RouteMeta,
45
48
  } from './decisions.ts'
49
+ import { HttpException, ServiceUnavailableException } from './exceptions.ts'
46
50
  import { isHeaderName } from './headers.ts'
47
51
  import { openInjectSse } from './inject-sse.ts'
48
52
  import { isMediaTypePattern } from './mime.ts'
@@ -51,10 +55,12 @@ import { compareRoutePaths, compareStrings, joinPaths, parseRoutePath, type Rout
51
55
  import {
52
56
  DEFAULT_SSE_SETTINGS,
53
57
  Pipeline,
58
+ pendingUpgradeOf,
54
59
  type Resolvable,
55
60
  type RoutePlan,
56
61
  randomRequestId,
57
62
  type SseSettings,
63
+ type UpgradePlan,
58
64
  } from './pipeline.ts'
59
65
  import { collectPluginRoutes, drainPlugins } from './plugin-routes.ts'
60
66
  import { resolveTrustProxy } from './proxy.ts'
@@ -64,6 +70,7 @@ import { parseByteSize } from './size.ts'
64
70
  import { MAX_SSE_TIMER_MS, MIN_SSE_INTERVAL_MS } from './sse.ts'
65
71
  import { isValidStatus } from './status.ts'
66
72
  import { HttpTokens } from './tokens.ts'
73
+ import { checkHandshake, collectPluginUpgrades } from './upgrade.ts'
67
74
  import { bodyPlan, DEFAULT_UPLOAD_LIMITS, type ResolvedUploadLimits, type UploadLimits } from './uploads.ts'
68
75
 
69
76
  /** How requests whose path ends with an extra `/` are handled. */
@@ -197,6 +204,8 @@ export interface HttpRouteSummary {
197
204
  readonly meta: Readonly<Record<string, unknown>>
198
205
  /** Display names of the guards, in chain order (global, controller, route). */
199
206
  readonly guards: readonly string[]
207
+ /** `'websocket'` for a WebSocket upgrade route (RFC 0003): a gateway of `@znight/websocket`. */
208
+ readonly upgrade?: 'websocket'
200
209
  }
201
210
 
202
211
  interface Resolved {
@@ -236,6 +245,8 @@ interface Registration {
236
245
  readonly method: Exclude<HttpMethod, 'ALL'>
237
246
  readonly path: RoutePath
238
247
  readonly plan: RoutePlan
248
+ /** An upgrade route (RFC 0003): registered in the upgrade table, not on the adapter. */
249
+ readonly upgrade?: UpgradePlan
239
250
  /** `BookController.list`, `plugin "openapi" (document)`. */
240
251
  readonly label: string
241
252
  readonly source: string
@@ -451,6 +462,29 @@ export class HttpPlugin implements Plugin {
451
462
  return openInjectSse(stream => this.injectStream(stream), request)
452
463
  }
453
464
 
465
+ /**
466
+ * Runs a WebSocket handshake in memory (RFC 0003, `WebSocketPlugin.connect()`): the same pipeline
467
+ * as an upgrade request of the adapter (guards, hooks, the route's checks), without socket. The
468
+ * transport receives `{ kind: 'memory', peer }`. Resolves with the refusal, or `undefined` once
469
+ * the connection was accepted. Available after `onBootstrap`.
470
+ */
471
+ async upgradeInMemory(request: InjectRequest, native: MemoryUpgrade): Promise<InjectResponse | undefined> {
472
+ const runtime = this.#require('upgradeInMemory()')
473
+ const normalized = await normalizeInject(withSignedCookies(request, runtime.options.cookies, 'upgradeInMemory()'))
474
+ const headers = normalized.headers
475
+ headers['upgrade'] ??= 'websocket'
476
+ headers['connection'] ??= 'Upgrade'
477
+ headers['sec-websocket-version'] ??= '13'
478
+ headers['sec-websocket-key'] ??= btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))))
479
+ headers['host'] ??= 'localhost'
480
+ const reply = await runtime.pipeline.upgrade(
481
+ new MemoryUpgradeRequest(normalized.method, normalized.url, headers),
482
+ native,
483
+ )
484
+ if (reply === undefined) return undefined
485
+ return injectResponse(reply.status, reply.headers, await replyBytes(reply))
486
+ }
487
+
454
488
  register(ctx: PluginContext): void {
455
489
  if (this.#active) {
456
490
  throw new ZnightError(ErrorCode.DuplicateProvider, 'This HttpPlugin is already used by another application', {
@@ -504,17 +538,40 @@ export class HttpPlugin implements Plugin {
504
538
  },
505
539
  table,
506
540
  )
507
- const pluginRoutes = collectPluginRoutes(ctx.app.plugins, this, {
508
- prefix: options.prefix === '/' ? '' : options.prefix,
541
+ const prefix = options.prefix === '/' ? '' : options.prefix
542
+ const pluginRoutes = collectPluginRoutes(ctx.app.plugins, this, { prefix, container: ctx.container })
543
+ const support = options.adapter.upgrades
544
+ const upgrades = collectPluginUpgrades(ctx.app.plugins, this, {
545
+ prefix,
509
546
  container: ctx.container,
547
+ adapter: options.adapter.name,
548
+ upgrades: support?.kind,
510
549
  })
511
- const { registrations, checked } = buildRegistrations(ctx, options, raw, pluginRoutes)
550
+ const { registrations, checked } = buildRegistrations(ctx, options, raw, pluginRoutes, upgrades.routes, pipeline)
512
551
  checkDecisions(checked.routes, checked.groups, options.enforcement, ctx.logger, checked.enforcers)
513
552
  const adapter = options.adapter
514
553
  const routes: HttpRouteSummary[] = []
554
+ let upgradeTable: RouteTable<UpgradePlan> | undefined
515
555
  for (const registration of registrations) {
516
- table.add(registration.method, registration.path, registration.plan)
517
556
  const plan = registration.plan
557
+ if (registration.upgrade !== undefined) {
558
+ upgradeTable ??= new RouteTable<UpgradePlan>()
559
+ upgradeTable.add('GET', registration.path, registration.upgrade)
560
+ routes.push(
561
+ Object.freeze({
562
+ method: 'GET',
563
+ path: registration.path.path,
564
+ controller: displayName(plan.info.controller),
565
+ handler: plan.info.handler,
566
+ source: registration.source,
567
+ meta: plan.info.meta,
568
+ guards: registration.guards,
569
+ upgrade: 'websocket' as const,
570
+ }),
571
+ )
572
+ continue
573
+ }
574
+ table.add(registration.method, registration.path, registration.plan)
518
575
  adapter.registerRoute({
519
576
  method: registration.method,
520
577
  path: registration.path,
@@ -533,6 +590,22 @@ export class HttpPlugin implements Plugin {
533
590
  )
534
591
  }
535
592
  adapter.setFallbackHandler((request, error) => pipeline.fallback(request, error))
593
+ if (upgradeTable !== undefined) {
594
+ if (support === undefined) {
595
+ throw new ZnightError(
596
+ ErrorCode.WebSocketTransportUnavailable,
597
+ `HttpPlugin: the ${adapter.name} adapter cannot serve WebSocket upgrade routes`,
598
+ { hints: ['Use a built-in adapter (node, fastify, express, hono) or implement HttpAdapter.upgrades'] },
599
+ )
600
+ }
601
+ const found = upgradeTable
602
+ pipeline.upgrades = found
603
+ support.enable({
604
+ match: path => found.find('GET', path) !== undefined,
605
+ handle: (request, native) => pipeline.upgrade(request, native),
606
+ bunWebSocket: upgrades.bunWebSocket,
607
+ })
608
+ }
536
609
  this.#runtime = { adapter, pipeline, options, routes: Object.freeze(routes) }
537
610
  }
538
611
 
@@ -882,6 +955,8 @@ interface Declared {
882
955
  readonly guards: readonly GuardEntry[]
883
956
  /** Effective metadata (available once every group is known). */
884
957
  meta(): RouteMeta
958
+ /** An upgrade route (RFC 0003): its handshake time limit. */
959
+ readonly upgrade?: { readonly timeoutMs: number | undefined }
885
960
  }
886
961
 
887
962
  function buildRegistrations(
@@ -889,6 +964,8 @@ function buildRegistrations(
889
964
  options: Resolved,
890
965
  raw: HttpPluginOptions,
891
966
  pluginRoutes: readonly CollectedPluginRoute[],
967
+ upgradeRoutes: readonly CollectedUpgradeRoute[],
968
+ pipeline: Pipeline,
892
969
  ): { readonly registrations: Registration[]; readonly checked: Checked } {
893
970
  const { container, metadata } = ctx
894
971
  const http = httpMetadata(metadata)
@@ -1071,6 +1148,91 @@ function buildRegistrations(
1071
1148
  })
1072
1149
  }
1073
1150
 
1151
+ for (const route of upgradeRoutes) {
1152
+ const label = `plugin "${route.plugin}" (${route.name})`
1153
+ const where = `${route.path.path} (${route.name})`
1154
+ const invalidGuard = (ref: unknown): ZnightError =>
1155
+ new ZnightError(
1156
+ ErrorCode.InvalidRoute,
1157
+ `HttpPlugin: plugin "${route.plugin}": the guards of the upgrade route ${where} must be provider tokens, classes or objects with a canActivate() method (received ${describeValue(ref)})`,
1158
+ )
1159
+ const guards = [
1160
+ ...globalGuards,
1161
+ ...route.guards.map(ref =>
1162
+ typeof ref === 'string'
1163
+ ? compiledGuard(ref, `guard of ${where}`)
1164
+ : (guardEntries(container, metadata, [ref], invalidGuard)[0] as GuardEntry),
1165
+ ),
1166
+ ]
1167
+ const values: Record<string, unknown> = {}
1168
+ for (const entry of route.meta) {
1169
+ groups.declare(entry, `the plugin "${route.plugin}"`)
1170
+ values[entry.key] = entry.value
1171
+ }
1172
+ const meta: RouteMeta = Object.freeze(values)
1173
+ pending.push(() => checkTargetMeta(meta, groupOf, `the upgrade route ${where} of the plugin "${route.plugin}"`))
1174
+ const accept = route.accept
1175
+ const precheck = route.precheck
1176
+ // the protocol checks, the drain and the route's own checks run before the global guards
1177
+ const checks: CanActivate = {
1178
+ canActivate: (ctx: HttpContext): boolean | Promise<boolean> => {
1179
+ checkHandshake(ctx)
1180
+ if (pipeline.draining) {
1181
+ throw new ServiceUnavailableException('The server is shutting down', undefined, {
1182
+ headers: { 'retry-after': '1' },
1183
+ })
1184
+ }
1185
+ if (precheck === undefined) return true
1186
+ const done = precheck(ctx)
1187
+ return done instanceof Promise ? done.then(() => true) : true
1188
+ },
1189
+ }
1190
+ const definition: HttpRouteDefinition = Object.freeze({
1191
+ method: 'GET',
1192
+ path: route.relativePath,
1193
+ fullPath: route.relativePath,
1194
+ handler: route.name,
1195
+ meta,
1196
+ guards: [],
1197
+ interceptors: [],
1198
+ params: [],
1199
+ rawResponse: true,
1200
+ invoke: async (_plugin: unknown, http: HttpContext): Promise<undefined> => {
1201
+ const upgrade = pendingUpgradeOf(http)
1202
+ if (upgrade === undefined) throw new Error(`The upgrade route ${where} only serves upgrade requests`)
1203
+ // answered 408 already: the late handshake is not completed
1204
+ if (upgrade.expired) throw handshakeExpired()
1205
+ await accept(http, upgrade)
1206
+ if (!upgrade.accepted) throw new Error(`accept() of the upgrade route ${where} did not take the connection`)
1207
+ return undefined
1208
+ },
1209
+ })
1210
+ const instance: Resolvable<object> = { instance: route.instance }
1211
+ const chain = [{ instance: checks }, ...guards.map(guard => guard.resolvable)]
1212
+ const body = bodyPlan({}, options.bodyLimit, options.uploads)
1213
+ const path = route.path
1214
+ declared.push({
1215
+ method: 'GET',
1216
+ path,
1217
+ plan: (method: string): RoutePlan => ({
1218
+ definition,
1219
+ invoke: definition.invoke,
1220
+ path,
1221
+ info: Object.freeze({ method, path: path.path, controller: route.owner, handler: route.name, meta }),
1222
+ controller: instance,
1223
+ guards: chain,
1224
+ interceptors: [],
1225
+ parseBody: false,
1226
+ body,
1227
+ }),
1228
+ label,
1229
+ source: route.plugin,
1230
+ guards,
1231
+ meta: () => meta,
1232
+ upgrade: { timeoutMs: route.timeoutMs },
1233
+ })
1234
+ }
1235
+
1074
1236
  // `enforces` of every guard class, read once per class. The guards of the chains declare their
1075
1237
  // groups; the guard classes of the application that sit in no chain only make their groups known,
1076
1238
  // so that a decision whose guard was forgotten everywhere (a second entrypoint, a worker, a test)
@@ -1108,18 +1270,30 @@ function buildRegistrations(
1108
1270
  }
1109
1271
  })
1110
1272
 
1111
- const registration = (route: Declared, method: Exclude<HttpMethod, 'ALL'>): Registration => ({
1112
- method,
1113
- path: route.path,
1114
- plan: route.plan(method),
1115
- label: route.label,
1116
- source: route.source,
1117
- guards: Object.freeze(route.guards.map(guard => guard.name)),
1118
- })
1273
+ const registration = (route: Declared, method: Exclude<HttpMethod, 'ALL'>): Registration => {
1274
+ const plan = route.plan(method)
1275
+ return {
1276
+ method,
1277
+ path: route.path,
1278
+ plan,
1279
+ label: route.label,
1280
+ source: route.source,
1281
+ guards: Object.freeze(route.guards.map(guard => guard.name)),
1282
+ ...(route.upgrade === undefined ? {} : { upgrade: { plan, timeoutMs: route.upgrade.timeoutMs } }),
1283
+ }
1284
+ }
1119
1285
  const explicit: Registration[] = []
1120
1286
  const catchAll: Declared[] = []
1287
+ // upgrade routes have their own table: a page and its socket may share a path
1288
+ const upgradeTable = new RouteTable<Registration>()
1289
+ const upgrades: Registration[] = []
1121
1290
  for (const route of declared) {
1122
- if (route.method === 'ALL') catchAll.push(route)
1291
+ if (route.upgrade !== undefined) {
1292
+ const candidate = registration(route, 'GET')
1293
+ const existing = upgradeTable.add('GET', candidate.path, candidate)
1294
+ if (existing !== undefined) throw duplicateRoute(candidate, existing)
1295
+ upgrades.push(candidate)
1296
+ } else if (route.method === 'ALL') catchAll.push(route)
1123
1297
  else explicit.push(registration(route, route.method))
1124
1298
  }
1125
1299
 
@@ -1144,7 +1318,8 @@ function buildRegistrations(
1144
1318
  for (const method of CONCRETE_METHODS) add(method === 'GET' ? first : registration(route, method), false)
1145
1319
  }
1146
1320
 
1147
- // trailing-slash variants
1321
+ // trailing-slash variants (an upgrade route never redirects: WebSocket clients do not follow redirects)
1322
+ accepted.push(...upgrades)
1148
1323
  const result: Registration[] = [...accepted]
1149
1324
  if (options.trailingSlash !== 'strict') {
1150
1325
  for (const candidate of accepted) {
@@ -1158,10 +1333,11 @@ function buildRegistrations(
1158
1333
  shape: `${path.shape}/`,
1159
1334
  }
1160
1335
  const plan: RoutePlan =
1161
- options.trailingSlash === 'redirect'
1336
+ options.trailingSlash === 'redirect' && candidate.upgrade === undefined
1162
1337
  ? { ...candidate.plan, path: variant, redirect: true }
1163
1338
  : { ...candidate.plan, path: variant }
1164
- result.push({ ...candidate, path: variant, plan })
1339
+ const upgrade = candidate.upgrade === undefined ? {} : { upgrade: { ...candidate.upgrade, plan } }
1340
+ result.push({ ...candidate, path: variant, plan, ...upgrade })
1165
1341
  }
1166
1342
  }
1167
1343
 
@@ -1478,3 +1654,8 @@ function resolveRef<T>(
1478
1654
  }
1479
1655
  throw invalid()
1480
1656
  }
1657
+
1658
+ /** A handshake completed after its `408`: refused quietly (a 4xx is not logged as a server error). */
1659
+ function handshakeExpired(): HttpException {
1660
+ return new HttpException(408, 'WebSocket handshake timed out')
1661
+ }
package/src/upgrade.ts ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Upgrade routes (RFC 0003 §8.2): WebSocket handshakes served by `HttpPlugin` on behalf of another
3
+ * plugin (`@znight/websocket`). The handshake is an HTTP request: it runs through the pipeline
4
+ * (`http:request` hook, request scope, global guards, the route's guards, exception mappers), then
5
+ * the plugin's `accept` takes the connection. The request scope stays open until the connection
6
+ * closes.
7
+ *
8
+ * Upgrade routes live in their own route table, consulted only for upgrade requests: they never
9
+ * change the ordinary routing, and a page and its socket may share a path.
10
+ */
11
+ import type { Token } from '@znight/core'
12
+ import type { AdapterReply, AdapterRequest } from './adapter.ts'
13
+ import type { HttpContext } from './context.ts'
14
+ import type { RouteMetaEntry } from './decorators.ts'
15
+ import type { HeaderValue } from './exceptions.ts'
16
+ import type { GuardRef } from './plugin.ts'
17
+ import type { NormalizedMetaEntry, PluginHttpRoutesContext } from './plugin-routes.ts'
18
+
19
+ import { ErrorCode, ZnightError } from '@znight/core'
20
+
21
+ import { isGroupOf } from './decorators.ts'
22
+ import { BadRequestException, HttpException } from './exceptions.ts'
23
+ import { joinPaths, normalizePath, parseRoutePath, type RoutePath, RoutePathError } from './path.ts'
24
+
25
+ /**
26
+ * The upgrade of a `node:http` server (`'upgrade'` event): every adapter built on `node:http`
27
+ * (`node`, `express`, `fastify`, `hono` on Node.js), on Node.js and on Bun.
28
+ */
29
+ export interface NodeUpgrade {
30
+ readonly kind: 'node'
31
+ /** The `IncomingMessage` of the handshake. */
32
+ readonly request: unknown
33
+ /** The `net.Socket` (a `Duplex`) of the connection. */
34
+ readonly socket: unknown
35
+ /** The first bytes after the handshake headers. */
36
+ readonly head: Uint8Array
37
+ }
38
+
39
+ /** The upgrade of a `Bun.serve()` server (`honoAdapter()` on Bun). */
40
+ export interface BunUpgrade {
41
+ readonly kind: 'bun'
42
+ /** The Fetch `Request` of the handshake. */
43
+ readonly request: Request
44
+ /** The `Bun.serve()` server (`server.upgrade(request, { data, headers })`). */
45
+ readonly server: unknown
46
+ }
47
+
48
+ /** An in-memory upgrade (`HttpPlugin.upgradeInMemory()`, tests): `peer` belongs to the caller. */
49
+ export interface MemoryUpgrade {
50
+ readonly kind: 'memory'
51
+ readonly peer: unknown
52
+ }
53
+
54
+ /** The native side of an upgrade request, completed by a transport. */
55
+ export type NativeUpgrade = NodeUpgrade | BunUpgrade | MemoryUpgrade
56
+
57
+ /** What {@link PluginUpgradeRoute.accept} receives to complete the handshake. */
58
+ export interface PendingUpgrade {
59
+ readonly native: NativeUpgrade
60
+ /**
61
+ * Headers of the `101` response: the headers and cookies the guards set on the context, the
62
+ * request id and the security headers are not included (they are HTTP response headers); read
63
+ * once, when the transport completes the handshake.
64
+ */
65
+ readonly headers: Readonly<Record<string, HeaderValue>>
66
+ /**
67
+ * The handshake timed out and was answered `408`: the transport must not complete it. Check it
68
+ * after every `await`, right before completing the handshake.
69
+ */
70
+ readonly expired: boolean
71
+ /**
72
+ * Keeps the request scope (and the in-flight count of `HttpPlugin`) open until `closed`
73
+ * settles: the connection lives in the scope of its handshake. Call it once, after the transport
74
+ * completed the handshake.
75
+ */
76
+ hold(closed: Promise<unknown>): void
77
+ }
78
+
79
+ /**
80
+ * A route answering upgrade requests (`GET` with `upgrade: websocket`), declared by another plugin
81
+ * through {@link PluginHttpUpgrades.httpUpgrades}.
82
+ */
83
+ export interface PluginUpgradeRoute {
84
+ /** Path relative to the `HttpPlugin` prefix, with the route grammar (`/chat`, `/docs/:docId`). */
85
+ readonly path: string
86
+ /** Name of the route (the gateway), shown in `HttpPlugin.routes`, logs and errors. */
87
+ readonly name: string
88
+ /** `ctx.route.controller` during the handshake (the gateway token). Default: the plugin name. */
89
+ readonly owner?: Token
90
+ /** Route metadata (`ctx.route.meta`), one entry per group, checked at boot like plugin routes. */
91
+ readonly meta?: readonly RouteMetaEntry[]
92
+ /** Guards of the handshake (classes, instances or provider tokens), after the global guards. */
93
+ readonly guards?: readonly (GuardRef | Token)[]
94
+ /** Time the handshake gets before it is answered `408` (ms). Default: none. */
95
+ readonly timeoutMs?: number
96
+ /**
97
+ * Cheap checks before the guards (origin, subprotocols, capacity), after the protocol checks of
98
+ * RFC 6455. Throw an `HttpException` to refuse.
99
+ */
100
+ precheck?(ctx: HttpContext): void | Promise<void>
101
+ /** After the guards: take the connection. Throw an `HttpException` to refuse. */
102
+ accept(ctx: HttpContext, upgrade: PendingUpgrade): void | Promise<void>
103
+ }
104
+
105
+ /** What {@link PluginHttpUpgrades.httpUpgrades} returns. */
106
+ export interface PluginUpgrades {
107
+ readonly routes: readonly PluginUpgradeRoute[]
108
+ /**
109
+ * Server-wide settings of the `websocket` option of `Bun.serve()` (`maxPayloadLength`,
110
+ * `idleTimeout`, `backpressureLimit`, `sendPings`, `perMessageDeflate`...), used by adapters
111
+ * serving with `Bun.serve()`. The handlers are set by `HttpPlugin`: they dispatch every event to
112
+ * the `data` given to `server.upgrade()`, a {@link BunSocketHandlers}.
113
+ */
114
+ readonly bunWebSocket?: Readonly<Record<string, unknown>>
115
+ }
116
+
117
+ /** What {@link PluginHttpUpgrades.httpUpgrades} receives. */
118
+ export interface PluginHttpUpgradesContext extends PluginHttpRoutesContext {
119
+ /** Name of the adapter (`node`, `fastify`...). */
120
+ readonly adapter: string
121
+ /**
122
+ * The native upgrades the adapter produces (`node`: a `node:http` server, `bun`: `Bun.serve()`),
123
+ * `undefined` when the adapter cannot upgrade connections.
124
+ */
125
+ readonly upgrades: 'node' | 'bun' | undefined
126
+ }
127
+
128
+ /**
129
+ * Implemented, next to {@link PluginHttpRoutes}, by a plugin that accepts WebSocket connections
130
+ * (`@znight/websocket`). `HttpPlugin` calls `httpUpgrades()` once in its `onBootstrap`, after
131
+ * `httpRoutes()`, whatever the plugin order, and asks its adapter to route the matching upgrade
132
+ * requests to the pipeline. `onHttpDrain()` is where the plugin closes its connections.
133
+ */
134
+ export interface PluginHttpUpgrades {
135
+ httpUpgrades(context: PluginHttpUpgradesContext): PluginUpgrades
136
+ }
137
+
138
+ /**
139
+ * The `data` a transport gives to `server.upgrade()` on `Bun.serve()`: `HttpPlugin` dispatches
140
+ * every event of the socket to it.
141
+ */
142
+ export interface BunSocketHandlers {
143
+ open(ws: unknown): void
144
+ message(ws: unknown, message: string | Uint8Array): void
145
+ close(ws: unknown, code: number, reason: string): void
146
+ drain(ws: unknown): void
147
+ pong?(ws: unknown): void
148
+ }
149
+
150
+ /** What `HttpPlugin` gives to {@link AdapterUpgrades.enable}. */
151
+ export interface UpgradeHandlerOptions {
152
+ /** Whether an upgrade request to the raw `path` (no query string) is for Znight. */
153
+ match(path: string): boolean
154
+ /**
155
+ * Runs the handshake through the pipeline. Resolves with the refusal to write (an HTTP response),
156
+ * or `undefined` once the transport took the connection. Never rejects.
157
+ */
158
+ handle(request: AdapterRequest, native: NodeUpgrade | BunUpgrade): Promise<AdapterReply | undefined>
159
+ /** The `websocket` option of `Bun.serve()` (settings and dispatching handlers), Bun adapters only. */
160
+ readonly bunWebSocket: Readonly<Record<string, unknown>>
161
+ }
162
+
163
+ /** The optional upgrade capability of an adapter (RFC 0003 §8.2.3). */
164
+ export interface AdapterUpgrades {
165
+ /** The native upgrades the adapter produces. */
166
+ readonly kind: 'node' | 'bun'
167
+ /** Called once, before `listen()`, when the application has upgrade routes. */
168
+ enable(options: UpgradeHandlerOptions): void
169
+ }
170
+
171
+ /** A validated upgrade route (internal). */
172
+ export interface CollectedUpgradeRoute {
173
+ readonly plugin: string
174
+ readonly instance: object
175
+ readonly path: RoutePath
176
+ readonly relativePath: string
177
+ readonly name: string
178
+ readonly owner: Token
179
+ readonly meta: readonly NormalizedMetaEntry[]
180
+ readonly guards: readonly unknown[]
181
+ readonly timeoutMs: number | undefined
182
+ readonly precheck: ((ctx: HttpContext) => void | Promise<void>) | undefined
183
+ readonly accept: (ctx: HttpContext, upgrade: PendingUpgrade) => void | Promise<void>
184
+ }
185
+
186
+ /** The upgrade routes of the plugins and the merged `Bun.serve()` settings (internal). */
187
+ export interface CollectedUpgrades {
188
+ readonly routes: readonly CollectedUpgradeRoute[]
189
+ readonly bunWebSocket: Readonly<Record<string, unknown>>
190
+ }
191
+
192
+ /** Calls `httpUpgrades()` on every other plugin implementing it, in plugin order, and validates the routes. */
193
+ export function collectPluginUpgrades(
194
+ plugins: readonly object[],
195
+ self: object,
196
+ context: PluginHttpUpgradesContext,
197
+ ): CollectedUpgrades {
198
+ const routes: CollectedUpgradeRoute[] = []
199
+ const bunWebSocket: Record<string, unknown> = {}
200
+ for (const plugin of plugins) {
201
+ if (plugin === self) continue
202
+ const provider = plugin as Partial<PluginHttpUpgrades> & { readonly name?: unknown }
203
+ if (typeof provider.httpUpgrades !== 'function') continue
204
+ const name = String(provider.name)
205
+ const result: unknown = provider.httpUpgrades(context)
206
+ const declared = (result as Partial<PluginUpgrades> | null | undefined)?.routes
207
+ if (!Array.isArray(declared)) {
208
+ throw invalid(name, `httpUpgrades() must return { routes: [...] } (received ${describe(result)})`)
209
+ }
210
+ for (const route of declared as unknown[]) routes.push(validateRoute(plugin, name, route, context.prefix))
211
+ const settings = (result as PluginUpgrades).bunWebSocket
212
+ if (settings !== undefined) Object.assign(bunWebSocket, settings)
213
+ }
214
+ return { routes, bunWebSocket }
215
+ }
216
+
217
+ function validateRoute(instance: object, plugin: string, value: unknown, prefix: string): CollectedUpgradeRoute {
218
+ if (value === null || typeof value !== 'object') throw invalid(plugin, `invalid upgrade route ${describe(value)}`)
219
+ const route = value as Partial<PluginUpgradeRoute>
220
+ if (typeof route.name !== 'string' || route.name === '') {
221
+ throw invalid(plugin, `the upgrade route ${String(route.path)} needs a name`)
222
+ }
223
+ const where = `${String(route.path)} (${route.name})`
224
+ if (typeof route.path !== 'string') throw invalid(plugin, `the upgrade route ${where} has no path`)
225
+ if (typeof route.accept !== 'function') throw invalid(plugin, `the upgrade route ${where} has no accept function`)
226
+ if (route.precheck !== undefined && typeof route.precheck !== 'function') {
227
+ throw invalid(plugin, `the precheck of the upgrade route ${where} must be a function`)
228
+ }
229
+ let path: RoutePath
230
+ try {
231
+ path = parseRoutePath(joinPaths(prefix, route.path))
232
+ } catch (error) {
233
+ if (!(error instanceof RoutePathError)) throw error
234
+ throw invalid(plugin, `invalid path of the upgrade route ${where}: ${error.issue.message}`, error)
235
+ }
236
+ const meta = route.meta ?? []
237
+ if (!Array.isArray(meta)) throw invalid(plugin, `the meta of the upgrade route ${where} must be an array of entries`)
238
+ const entries = (meta as unknown[]).map(entry => metaEntry(plugin, where, entry))
239
+ const guards = route.guards ?? []
240
+ if (!Array.isArray(guards)) throw invalid(plugin, `the guards of the upgrade route ${where} must be an array`)
241
+ const timeoutMs = route.timeoutMs
242
+ if (timeoutMs !== undefined && !(typeof timeoutMs === 'number' && timeoutMs > 0)) {
243
+ throw invalid(plugin, `invalid timeoutMs ${describe(timeoutMs)} of the upgrade route ${where}`)
244
+ }
245
+ return {
246
+ plugin,
247
+ instance,
248
+ path,
249
+ relativePath: normalizePath(route.path),
250
+ name: route.name,
251
+ owner: route.owner ?? plugin,
252
+ meta: entries,
253
+ guards,
254
+ timeoutMs: timeoutMs === Number.POSITIVE_INFINITY ? undefined : timeoutMs,
255
+ precheck: route.precheck?.bind(route),
256
+ accept: route.accept.bind(route),
257
+ }
258
+ }
259
+
260
+ function metaEntry(plugin: string, where: string, value: unknown): NormalizedMetaEntry {
261
+ const entry = (value ?? {}) as Partial<RouteMetaEntry>
262
+ if (value === null || typeof value !== 'object' || typeof entry.key !== 'string' || entry.key === '') {
263
+ throw invalid(plugin, `invalid meta entry ${describe(value)} of the upgrade route ${where}`)
264
+ }
265
+ const group = entry.group ?? entry.key
266
+ const required = entry.required ?? false
267
+ if (!isGroupOf(group, entry.key) || typeof required !== 'boolean') {
268
+ throw invalid(plugin, `invalid group of the meta entry "${entry.key}" of the upgrade route ${where}`)
269
+ }
270
+ return { key: entry.key, value: entry.value, group, required }
271
+ }
272
+
273
+ function invalid(plugin: string, reason: string, cause?: unknown): ZnightError {
274
+ return new ZnightError(ErrorCode.InvalidRoute, `HttpPlugin: plugin "${plugin}": ${reason}`, {
275
+ ...(cause === undefined ? {} : { cause }),
276
+ })
277
+ }
278
+
279
+ function describe(value: unknown): string {
280
+ try {
281
+ return typeof value === 'function' ? 'a function' : (JSON.stringify(value) ?? String(value))
282
+ } catch {
283
+ return typeof value
284
+ }
285
+ }
286
+
287
+ // -------------------------------------------------------------------------------------------------
288
+ // Handshake checks (RFC 6455 §4.2.1)
289
+ // -------------------------------------------------------------------------------------------------
290
+
291
+ const KEY = /^[A-Za-z0-9+/]{22}==$/
292
+
293
+ /** Whether a request asks for a WebSocket upgrade (`upgrade: websocket`, any case). */
294
+ export function isWebSocketUpgrade(headers: Readonly<Record<string, string | undefined>>): boolean {
295
+ const upgrade = headers['upgrade']
296
+ return upgrade !== undefined && upgrade.length === 9 && upgrade.toLowerCase() === 'websocket'
297
+ }
298
+
299
+ /**
300
+ * The protocol checks of a handshake: `GET`, `sec-websocket-version: 13` (`426` with the supported
301
+ * version otherwise) and a base64 16-byte `sec-websocket-key` (`400`).
302
+ */
303
+ export function checkHandshake(ctx: HttpContext): void {
304
+ if (ctx.method !== 'GET') throw new BadRequestException('A WebSocket handshake must be a GET request')
305
+ if (ctx.headers['sec-websocket-version'] !== '13') {
306
+ throw new HttpException(426, 'Unsupported WebSocket version', undefined, {
307
+ headers: { 'sec-websocket-version': '13' },
308
+ })
309
+ }
310
+ const key = ctx.headers['sec-websocket-key']
311
+ if (key === undefined || !KEY.test(key.trim())) throw new BadRequestException('Invalid Sec-WebSocket-Key')
312
+ }
313
+
314
+ /** Internal: the pending upgrade of a handshake, created by the pipeline. */
315
+ export class PendingUpgradeState implements PendingUpgrade {
316
+ readonly native: NativeUpgrade
317
+ readonly #headers: () => Readonly<Record<string, HeaderValue>>
318
+ /** The transport completed the handshake (`101`). */
319
+ accepted = false
320
+ /** The handshake timed out: the transport must not complete it. */
321
+ expired = false
322
+ held: Promise<unknown> | undefined
323
+
324
+ constructor(native: NativeUpgrade, headers: () => Readonly<Record<string, HeaderValue>>) {
325
+ this.native = native
326
+ this.#headers = headers
327
+ }
328
+
329
+ get headers(): Readonly<Record<string, HeaderValue>> {
330
+ return this.#headers()
331
+ }
332
+
333
+ hold(closed: Promise<unknown>): void {
334
+ if (this.held !== undefined) throw new Error('PendingUpgrade.hold() can only be called once')
335
+ this.accepted = true
336
+ this.held = closed.then(
337
+ () => undefined,
338
+ () => undefined,
339
+ )
340
+ }
341
+ }