@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/README.md +15 -0
- package/dist/adapter-Dh3UF0ot.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-DVEO_lrR.mjs} +5 -2
- package/dist/node-DVEO_lrR.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-CdQG7pZg.mjs} +625 -27
- package/dist/upgrade-io-CdQG7pZg.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/decorators.ts +1 -1
- 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/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
|
|
508
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|