@znight/http 0.5.1 → 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/dist/index.mjs CHANGED
@@ -1,15 +1,15 @@
1
- import { n as RouteTable, t as nodeAdapter } from "./node-DnYv_AMI.mjs";
2
- import { $ as TooManyRequestsException, A as parseSetCookie, B as BadGatewayException, C as DEFAULT_COOKIE_OPTIONS, D as givenCookieKey, E as encodeCookieValue, F as discardStream, G as GoneException, H as ConflictException, I as isByteStream, J as MethodNotAllowedException, K as HttpException, L as isStreamSource, M as serializeCookie, N as serializeResponseCookie, O as isCookieName, P as captureContext, Q as ServiceUnavailableException, R as toByteStream, T as decodeCookieValue, U as ForbiddenException, V as BadRequestException, W as GatewayTimeoutException, X as NotImplementedException, Y as NotFoundException, Z as PayloadTooLargeException, at as issue, ct as isValidStatus, et as UnauthorizedException, g as isEventStreamType, it as isHttpException, j as serializeClearCookie, k as parseCookieHeader, lt as statusText, nt as UnsupportedMediaTypeException, ot as toIssueDetail, q as InternalServerErrorException, rt as ValidationException, st as isBodylessStatus, tt as UnprocessableEntityException, w as checkCookie, z as trackStream } from "./node-io-BkdRSQeg.mjs";
1
+ import { n as RouteTable, t as nodeAdapter } from "./node-npGnu7IW.mjs";
2
+ import { A as Req, At as ConflictException, B as isGroupOf, Bt as ServiceUnavailableException, C as HttpCode, Ct as discardStream, D as Post, Dt as trackStream, E as Patch, Et as toByteStream, F as UploadedFiles, Ft as InternalServerErrorException, Gt as ValidationException, Ht as UnauthorizedException, I as UseGuard, It as MethodNotAllowedException, Jt as toIssueDetail, Kt as isHttpException, L as UseInterceptor, Lt as NotFoundException, M as SignedCookie, Mt as GatewayTimeoutException, N as Sse, Nt as GoneException, O as Put, Ot as BadGatewayException, P as UploadedFile, Pt as HttpException, R as Validate, Rt as NotImplementedException, S as Headers, St as captureContext, T as Param, Tt as isStreamSource, Ut as UnprocessableEntityException, Vt as TooManyRequestsException, Wt as UnsupportedMediaTypeException, Xt as isValidStatus, Yt as isBodylessStatus, Zt as statusText, _ as Delete, _t as parseCookieHeader, b as Head, bt as serializeCookie, c as checkHandshake, d as All, dt as DEFAULT_COOKIE_OPTIONS, f as Body, ft as checkCookie, g as Ctx, gt as isCookieName, h as Cookie, ht as givenCookieKey, j as Res, jt as ForbiddenException, k as Query, kt as BadRequestException, l as collectPluginUpgrades, m as Controller, mt as encodeCookieValue, n as MemoryUpgradeRequest, nt as injectResponse, o as replyBytes, ot as normalizeInject, p as BodyLimit, pt as decodeCookieValue, qt as issue, rt as isEventStreamType, s as PendingUpgradeState, v as GROUP_NAME, vt as parseSetCookie, w as Options, wt as isByteStream, x as Header, xt as serializeResponseCookie, y as Get, yt as serializeClearCookie, z as createRouteDecorator, zt as PayloadTooLargeException } from "./upgrade-io-dTMDDmBi.mjs";
3
+ import { RoutePathError, compareRoutePaths, compareStrings, decodePathValue, joinPaths, matchRoutePath, normalizePath, parseRoutePath } from "./path.mjs";
3
4
  import { a as isMediaTypePattern, i as contentTypeFor, n as parseByteSize, o as matchesMediaType, r as OCTET_STREAM, s as mediaTypeEssence, t as formatByteSize } from "./size-D8rsQcV0.mjs";
4
5
  import { a as isThenable, i as isBun, n as errorMessage, r as fastRecord, t as emptyRecord } from "./utils-DIWdzVht.mjs";
5
- import { RoutePathError, compareRoutePaths, compareStrings, decodePathValue, joinPaths, matchRoutePath, normalizePath, parseRoutePath } from "./path.mjs";
6
6
  import { Readable } from "node:stream";
7
+ import { ErrorCode, ZnightError, displayName, resolveOptions } from "@znight/core";
7
8
  import { Buffer as Buffer$1 } from "node:buffer";
8
9
  import { openAsBlob } from "node:fs";
9
10
  import { realpath, stat } from "node:fs/promises";
10
11
  import { basename, isAbsolute, relative, resolve } from "node:path";
11
12
  import { fileURLToPath } from "node:url";
12
- import { ErrorCode, ZnightError, displayName, resolveOptions } from "@znight/core";
13
13
  import crypto$1, { createHmac, createSecretKey } from "node:crypto";
14
14
  // src/multipart.ts
15
15
  /** Default maximum size of the headers of one part. */
@@ -1169,252 +1169,6 @@ const EMPTY_HEADERS = Object.freeze({});
1169
1169
  const NO_COOKIES$1 = Object.freeze([]);
1170
1170
  const NO_LINES = Object.freeze([]);
1171
1171
  const NO_FILES$1 = Object.freeze(emptyRecord());
1172
- // src/decorators.ts
1173
- function marker() {}
1174
- const classMarker = marker;
1175
- const methodMarker = marker;
1176
- const parameterMarker = marker;
1177
- const classOrMethodMarker = marker;
1178
- /**
1179
- * Declares a controller: a provider (singleton by default) whose decorated methods are routes.
1180
- *
1181
- * ```ts
1182
- * @Controller('/users')
1183
- * export class UserController {
1184
- * constructor(private readonly users: UserService) {}
1185
- *
1186
- * @Get('/:id')
1187
- * find(@Param('id') id: number) {
1188
- * return this.users.findById(id)
1189
- * }
1190
- * }
1191
- * ```
1192
- *
1193
- * @param pathOrOptions static: base path, or `{ path?, scope? }`.
1194
- */
1195
- function Controller(_pathOrOptions) {
1196
- return classMarker;
1197
- }
1198
- /** `GET` route. `path` (static) is relative to the controller path; default: the controller path. */
1199
- function Get(_path) {
1200
- return methodMarker;
1201
- }
1202
- /** `POST` route (default status 201). */
1203
- function Post(_path) {
1204
- return methodMarker;
1205
- }
1206
- /** `PUT` route. */
1207
- function Put(_path) {
1208
- return methodMarker;
1209
- }
1210
- /** `PATCH` route. */
1211
- function Patch(_path) {
1212
- return methodMarker;
1213
- }
1214
- /** `DELETE` route. */
1215
- function Delete(_path) {
1216
- return methodMarker;
1217
- }
1218
- /** `HEAD` route (`GET` routes already answer `HEAD` requests). */
1219
- function Head(_path) {
1220
- return methodMarker;
1221
- }
1222
- /** `OPTIONS` route (paths without one answer `OPTIONS` with `204` and an `allow` header). */
1223
- function Options(_path) {
1224
- return methodMarker;
1225
- }
1226
- /** Route for GET, HEAD, POST, PUT, PATCH, DELETE and OPTIONS (methods without a dedicated route on the same path). */
1227
- function All(_path) {
1228
- return methodMarker;
1229
- }
1230
- function Sse(_pathOrOptions, _options) {
1231
- return methodMarker;
1232
- }
1233
- function Param(_nameOrSchema, _schema) {
1234
- return parameterMarker;
1235
- }
1236
- function Query(_nameOrSchema, _schema) {
1237
- return parameterMarker;
1238
- }
1239
- function Cookie(_nameOrSchema, _schema) {
1240
- return parameterMarker;
1241
- }
1242
- /**
1243
- * Injects the **verified** value of a signed cookie (set with `{ signed: true }`, plugin option
1244
- * `cookies.secrets`): absent, tampered or signed with a retired key, the cookie does not exist
1245
- * (400 `Missing cookie "name"` unless the parameter is optional). Same coercion and schema rules as
1246
- * {@link Cookie}. A route reading a signed cookie fails the boot when `cookies.secrets` is not set.
1247
- *
1248
- * @param name static: cookie name.
1249
- * @param schema value: a Standard Schema (its output is injected).
1250
- */
1251
- function SignedCookie(_name, _schema) {
1252
- return parameterMarker;
1253
- }
1254
- /**
1255
- * Injects the parsed request body, validated with `schema` when given (the schema output is
1256
- * injected: defaults, transforms, stripped keys). A non-primitive body without schema is reported
1257
- * by the compiler (ZNIGHT017).
1258
- *
1259
- * @param schema value: a Standard Schema (Zod, Valibot, ArkType...).
1260
- */
1261
- function Body(_schema) {
1262
- return parameterMarker;
1263
- }
1264
- /**
1265
- * Injects a request header (case-insensitive `name`, coerced to the parameter type) or, without
1266
- * name, every header (`Record<string, string | undefined>`).
1267
- *
1268
- * @param name static: header name.
1269
- */
1270
- function Headers(_name) {
1271
- return parameterMarker;
1272
- }
1273
- function UploadedFile(_fieldOrOptions, _options) {
1274
- return parameterMarker;
1275
- }
1276
- function UploadedFiles(_fieldOrOptions, _options) {
1277
- return parameterMarker;
1278
- }
1279
- /**
1280
- * Sets the maximum request body size of a route, or of every route of a controller (the route wins),
1281
- * over the plugin `bodyLimit` (and over the limit computed for upload routes). `Infinity` disables
1282
- * the limit, e.g. for a route streaming its body with `ctx.bodyStream()`.
1283
- *
1284
- * ```ts
1285
- * @Put('/files/:name')
1286
- * @BodyLimit('5gb')
1287
- * async store(@Param('name') name: string, @Ctx() ctx: HttpContext) {
1288
- * await pipeline(Readable.fromWeb(ctx.bodyStream()), createWriteStream(join(DIR, safeName(name))))
1289
- * }
1290
- * ```
1291
- *
1292
- * @param size static: bytes (`10_485_760`) or a size with a unit (`'10mb'`, `'512kb'`; `kb` = 1024 bytes).
1293
- */
1294
- function BodyLimit(_size) {
1295
- return classOrMethodMarker;
1296
- }
1297
- /** Injects the native request of the adapter (escape hatch; the compiler warns). */
1298
- function Req() {
1299
- return parameterMarker;
1300
- }
1301
- /**
1302
- * Injects the native response of the adapter (escape hatch; the compiler warns). The handler then
1303
- * writes the response itself, and must have finished when its promise settles.
1304
- */
1305
- function Res() {
1306
- return parameterMarker;
1307
- }
1308
- /** Injects the {@link HttpContext} of the request. */
1309
- function Ctx() {
1310
- return parameterMarker;
1311
- }
1312
- /**
1313
- * Sets the status of successful responses (wins over `HttpResponse.status` and the defaults).
1314
- *
1315
- * @param status static: an integer between 100 and 599.
1316
- */
1317
- function HttpCode(_status) {
1318
- return methodMarker;
1319
- }
1320
- /**
1321
- * Adds a header to successful responses. Repeat the decorator for several headers.
1322
- *
1323
- * @param name static: header name.
1324
- * @param value static: header value.
1325
- */
1326
- function Header(_name, _value) {
1327
- return methodMarker;
1328
- }
1329
- /**
1330
- * Protects a controller (every route) or a route with guards (providers implementing
1331
- * {@link CanActivate}). Order: global guards → controller guards → route guards, each list in
1332
- * declaration order.
1333
- *
1334
- * @param guards value: guard classes.
1335
- */
1336
- function UseGuard(..._guards) {
1337
- return classOrMethodMarker;
1338
- }
1339
- /**
1340
- * Wraps a controller (every route) or a route with interceptors (providers implementing
1341
- * {@link HttpInterceptor}). Order: global → controller → route, the first one being the outermost.
1342
- *
1343
- * @param interceptors value: interceptor classes.
1344
- */
1345
- function UseInterceptor(..._interceptors) {
1346
- return classOrMethodMarker;
1347
- }
1348
- /**
1349
- * Validates the request body with `schema`: sugar for `@Body(schema)` on the body parameter (using
1350
- * both is ZNIGHT005).
1351
- *
1352
- * @param schema value: a Standard Schema.
1353
- */
1354
- function Validate(_schema) {
1355
- return methodMarker;
1356
- }
1357
- /**
1358
- * Grammar of an explicit decision group name (internal; the compiler extension has the same rule).
1359
- * A decorator without `group` is its own group, named after its key: any non-empty key is valid.
1360
- */
1361
- const GROUP_NAME = /^[A-Za-z][\w.-]*$/;
1362
- /** `true` when `group` is a valid group of `key`: the key itself (the default) or a valid name. */
1363
- function isGroupOf(group, key) {
1364
- return typeof group === "string" && (group === key || GROUP_NAME.test(group));
1365
- }
1366
- /**
1367
- * Creates a metadata decorator for controllers and routes. The compiler stores its argument
1368
- * (`value`, statically evaluated or an exported reference; `true` when omitted) in
1369
- * `meta[key]`, read by guards and interceptors through `ctx.route.meta` or `decorator.get(ctx)`.
1370
- *
1371
- * ```ts
1372
- * export const Public = createRouteDecorator('public', { group: 'access', required: true })
1373
- * export const Authenticated = createRouteDecorator('authenticated', { group: 'access', required: true })
1374
- * export const Roles = createRouteDecorator<readonly string[]>('roles', { group: 'access', required: true })
1375
- *
1376
- * @Controller('/books')
1377
- * @Roles(['reader']) // the decision of every route of the controller...
1378
- * export class BookController {
1379
- * @Get('/:id/cover')
1380
- * @Public() // ...unless the route has its own decision of the group
1381
- * cover() {}
1382
- * }
1383
- *
1384
- * // in a guard
1385
- * if (Public.get(ctx)) return true
1386
- * const roles = Roles.get(ctx) ?? []
1387
- * ```
1388
- *
1389
- * Decorators of one `group` are alternatives: two of them on the same class or method are
1390
- * ZNIGHT005, and the route's decision replaces the controller's (without `group`, each decorator is
1391
- * its own group: the route value wins over the controller value). With `required: true`, a route
1392
- * without decision fails the build (ZNIGHT023).
1393
- *
1394
- * @param key static: unique metadata key.
1395
- * @param options static: `{ group?, required? }`.
1396
- */
1397
- function createRouteDecorator(key, options) {
1398
- if (typeof key !== "string" || key === "") throw new TypeError(`createRouteDecorator(): the key must be a non-empty string (received ${String(key)})`);
1399
- if (options !== void 0 && (options === null || typeof options !== "object")) throw new TypeError(`createRouteDecorator('${key}'): options must be an object { group?, required? }`);
1400
- const group = options?.group ?? key;
1401
- if (!isGroupOf(group, key)) throw new TypeError(`createRouteDecorator('${key}'): "group" must be a name made of letters, digits, _ . - starting with a letter (received ${String(group)})`);
1402
- const required = options?.required ?? false;
1403
- if (typeof required !== "boolean") throw new TypeError(`createRouteDecorator('${key}'): "required" must be a boolean (received ${String(required)})`);
1404
- const decorator = (..._args) => classOrMethodMarker;
1405
- return Object.assign(decorator, {
1406
- key,
1407
- group,
1408
- required,
1409
- get: (ctx) => ctx.route?.meta[key],
1410
- meta: (...args) => Object.freeze({
1411
- key,
1412
- value: args.length === 0 ? true : args[0],
1413
- group,
1414
- required
1415
- })
1416
- });
1417
- }
1418
1172
  // src/event-channel.ts
1419
1173
  const DEFAULT_CAPACITY = 1e3;
1420
1174
  /**
@@ -3713,6 +3467,15 @@ const HttpTokens = Object.freeze({
3713
3467
  Plugin: "@znight/http#HttpPlugin"
3714
3468
  });
3715
3469
  // src/pipeline.ts
3470
+ /**
3471
+ * The pending upgrade of a handshake context (RFC 0003): set by {@link Pipeline.upgrade} for the
3472
+ * upgrade routes only, so that ordinary requests pay nothing.
3473
+ */
3474
+ const UPGRADES = /* @__PURE__ */ new WeakMap();
3475
+ /** The pending upgrade of the handshake whose context is `ctx` (internal, upgrade routes). */
3476
+ function pendingUpgradeOf(ctx) {
3477
+ return UPGRADES.get(ctx);
3478
+ }
3716
3479
  /** Default event stream settings. */
3717
3480
  const DEFAULT_SSE_SETTINGS = Object.freeze({
3718
3481
  heartbeat: 8e3,
@@ -3816,6 +3579,8 @@ var Pipeline = class {
3816
3579
  #streams = /* @__PURE__ */ new Set();
3817
3580
  /** Set while the server drains: responses ask clients to close their connection. */
3818
3581
  draining = false;
3582
+ /** The upgrade routes (RFC 0003), `undefined` when no plugin declares one. */
3583
+ upgrades = void 0;
3819
3584
  constructor(container, hooks, logger, options, table) {
3820
3585
  this.#container = container;
3821
3586
  this.#hooks = hooks;
@@ -4264,6 +4029,71 @@ var Pipeline = class {
4264
4029
  return Promise.reject(failure);
4265
4030
  }
4266
4031
  }
4032
+ /**
4033
+ * Serves a WebSocket handshake (RFC 0003 §8.2.2): the upgrade route of its raw path runs through
4034
+ * the pipeline. Resolves with the reply to write (a refusal), or `undefined` once the transport
4035
+ * took the connection; the request scope then stays open until the connection closes.
4036
+ */
4037
+ upgrade(request, native) {
4038
+ const startTime = performance.now();
4039
+ try {
4040
+ const url = splitUrl(request.url);
4041
+ const upgrade = this.upgrades?.find("GET", url.path);
4042
+ if (upgrade === void 0) return this.#fallback(request, void 0, startTime);
4043
+ const plan = upgrade.plan;
4044
+ const raw = matchRoutePath(plan.path, url.path) ?? emptyRecord();
4045
+ const params = emptyRecord();
4046
+ let malformed;
4047
+ for (const name of Object.keys(raw)) {
4048
+ const decoded = decodePathValue(raw[name]);
4049
+ if (decoded === void 0) malformed ??= name;
4050
+ else params[name] = decoded;
4051
+ }
4052
+ const target = malformed === void 0 ? {
4053
+ kind: "route",
4054
+ plan
4055
+ } : {
4056
+ kind: "error",
4057
+ error: new BadRequestException(`Malformed URL parameter "${malformed}"`)
4058
+ };
4059
+ let pending;
4060
+ const settled = this.#serve(request, url, plan.info, params, target, startTime, plan.body, (ctx) => {
4061
+ pending = new PendingUpgradeState(native, () => this.#upgradeHeaders(ctx));
4062
+ UPGRADES.set(ctx, pending);
4063
+ }).then((reply) => pending?.accepted === true ? void 0 : reply);
4064
+ const timeoutMs = upgrade.timeoutMs;
4065
+ if (timeoutMs === void 0) return settled;
4066
+ let timer;
4067
+ const expired = new Promise((resolve) => {
4068
+ timer = setTimeout(() => {
4069
+ if (pending !== void 0) pending.expired = true;
4070
+ resolve(adapterReply(serializeError(408, {
4071
+ statusCode: 408,
4072
+ error: "Request Timeout",
4073
+ message: "WebSocket handshake timed out"
4074
+ }, "GET"), null));
4075
+ }, timeoutMs);
4076
+ });
4077
+ return Promise.race([settled, expired]).finally(() => clearTimeout(timer));
4078
+ } catch (error) {
4079
+ return Promise.reject(error);
4080
+ }
4081
+ }
4082
+ /** The headers of a `101` response: those set on the context, its cookies and the request id. */
4083
+ #upgradeHeaders(ctx) {
4084
+ const headers = { ...ctx.responseHeaders };
4085
+ const cookies = ctx.cookieLines;
4086
+ if (cookies.length > 0) {
4087
+ const existing = headers["set-cookie"];
4088
+ const raw = [];
4089
+ if (typeof existing === "string") raw.push(existing);
4090
+ else if (existing !== void 0) raw.push(...existing);
4091
+ headers["set-cookie"] = [...raw, ...cookies.map((cookie) => cookie.line)];
4092
+ }
4093
+ const idHeader = this.#options.requestIdHeader;
4094
+ if (idHeader !== void 0 && headers[idHeader] === void 0 && (this.#validIds || isHeaderValue(ctx.requestId))) headers[idHeader] = ctx.requestId;
4095
+ return headers;
4096
+ }
4267
4097
  #fallback(request, error, startTime, reroute = true) {
4268
4098
  const body = this.#options.body;
4269
4099
  if (error !== void 0) {
@@ -4284,7 +4114,7 @@ var Pipeline = class {
4284
4114
  let target;
4285
4115
  if (allowed.length === 0) target = {
4286
4116
  kind: "error",
4287
- error: new NotFoundException(`Cannot ${request.method} ${truncate(path)}`)
4117
+ error: this.#unmatched(request.method, path)
4288
4118
  };
4289
4119
  else if (request.method === "OPTIONS") target = {
4290
4120
  kind: "options",
@@ -4296,6 +4126,17 @@ var Pipeline = class {
4296
4126
  };
4297
4127
  return this.#serve(request, url, void 0, emptyRecord(), target, startTime, body);
4298
4128
  }
4129
+ /**
4130
+ * The error of a request no route matched: `404`, or `426 Upgrade Required` for a plain `GET` of
4131
+ * a WebSocket gateway path (RFC 0003 §8.2.1).
4132
+ */
4133
+ #unmatched(method, path) {
4134
+ if ((method === "GET" || method === "HEAD") && this.upgrades?.find("GET", path) !== void 0) return new HttpException(426, "This endpoint only accepts WebSocket connections", void 0, { headers: {
4135
+ upgrade: "websocket",
4136
+ connection: "upgrade"
4137
+ } });
4138
+ return new NotFoundException(`Cannot ${method} ${truncate(path)}`);
4139
+ }
4299
4140
  /** A CORS preflight (with the `cors` option): answered without running any route. */
4300
4141
  #isPreflight(request) {
4301
4142
  return this.#options.cors !== void 0 && isPreflight(request.method, request.headers);
@@ -4309,12 +4150,13 @@ var Pipeline = class {
4309
4150
  };
4310
4151
  return this.#serve(request, url, void 0, emptyRecord(), target, startTime, this.#options.body);
4311
4152
  }
4312
- #serve(request, url, route, params, target, startTime, body) {
4153
+ #serve(request, url, route, params, target, startTime, body, prepare) {
4313
4154
  this.#enter();
4314
4155
  let x;
4315
4156
  try {
4316
4157
  const requestId = this.#requestId(request);
4317
4158
  const ctx = this.#context(request, requestId, url.path, url.query, route, params, body);
4159
+ if (prepare !== void 0) prepare(ctx);
4318
4160
  if (this.draining && target.kind === "route" && target.plan.definition.sse !== void 0) ctx.abort("shutdown");
4319
4161
  x = new Exchange(request, ctx, requestId, target, startTime);
4320
4162
  } catch (error) {
@@ -4384,6 +4226,10 @@ var Pipeline = class {
4384
4226
  } else if (failed) this.#wrapperFailed(x, hookError);
4385
4227
  const streaming = this.#send(x, outcome, recheck, run);
4386
4228
  if (streaming !== void 0) this.#streamed(x, streaming, await streaming.tracked.done);
4229
+ else if (outcome === RAW) {
4230
+ const held = UPGRADES.get(ctx)?.held;
4231
+ if (held !== void 0) await held;
4232
+ }
4387
4233
  }
4388
4234
  /**
4389
4235
  * The reply of a request whose `http:request` wrapper failed before it was handled (`error`), or
@@ -4416,7 +4262,7 @@ var Pipeline = class {
4416
4262
  const event = x.event;
4417
4263
  if (outcome === RAW) {
4418
4264
  ctx.commit();
4419
- const status = x.request.rawStatus?.() ?? 200;
4265
+ const status = UPGRADES.get(ctx)?.accepted === true ? 101 : x.request.rawStatus?.() ?? 200;
4420
4266
  event.status ??= status;
4421
4267
  x.answer({
4422
4268
  status,
@@ -5762,6 +5608,25 @@ var HttpPlugin = class HttpPlugin {
5762
5608
  injectSse(request) {
5763
5609
  return openInjectSse((stream) => this.injectStream(stream), request);
5764
5610
  }
5611
+ /**
5612
+ * Runs a WebSocket handshake in memory (RFC 0003, `WebSocketPlugin.connect()`): the same pipeline
5613
+ * as an upgrade request of the adapter (guards, hooks, the route's checks), without socket. The
5614
+ * transport receives `{ kind: 'memory', peer }`. Resolves with the refusal, or `undefined` once
5615
+ * the connection was accepted. Available after `onBootstrap`.
5616
+ */
5617
+ async upgradeInMemory(request, native) {
5618
+ const runtime = this.#require("upgradeInMemory()");
5619
+ const normalized = await normalizeInject(withSignedCookies(request, runtime.options.cookies, "upgradeInMemory()"));
5620
+ const headers = normalized.headers;
5621
+ headers["upgrade"] ??= "websocket";
5622
+ headers["connection"] ??= "Upgrade";
5623
+ headers["sec-websocket-version"] ??= "13";
5624
+ headers["sec-websocket-key"] ??= btoa(String.fromCharCode(...crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16))));
5625
+ headers["host"] ??= "localhost";
5626
+ const reply = await runtime.pipeline.upgrade(new MemoryUpgradeRequest(normalized.method, normalized.url, headers), native);
5627
+ if (reply === void 0) return void 0;
5628
+ return injectResponse(reply.status, reply.headers, await replyBytes(reply));
5629
+ }
5765
5630
  register(ctx) {
5766
5631
  if (this.#active) throw new ZnightError(ErrorCode.DuplicateProvider, "This HttpPlugin is already used by another application", { hints: ["Call HttpPlugin.configure() once per application"] });
5767
5632
  this.#active = true;
@@ -5801,16 +5666,41 @@ var HttpPlugin = class HttpPlugin {
5801
5666
  cookies: options.cookies,
5802
5667
  sse: options.sse
5803
5668
  }, table);
5804
- const { registrations, checked } = buildRegistrations(ctx, options, raw, collectPluginRoutes(ctx.app.plugins, this, {
5805
- prefix: options.prefix === "/" ? "" : options.prefix,
5669
+ const prefix = options.prefix === "/" ? "" : options.prefix;
5670
+ const pluginRoutes = collectPluginRoutes(ctx.app.plugins, this, {
5671
+ prefix,
5806
5672
  container: ctx.container
5807
- }));
5673
+ });
5674
+ const support = options.adapter.upgrades;
5675
+ const upgrades = collectPluginUpgrades(ctx.app.plugins, this, {
5676
+ prefix,
5677
+ container: ctx.container,
5678
+ adapter: options.adapter.name,
5679
+ upgrades: support?.kind
5680
+ });
5681
+ const { registrations, checked } = buildRegistrations(ctx, options, raw, pluginRoutes, upgrades.routes, pipeline);
5808
5682
  checkDecisions(checked.routes, checked.groups, options.enforcement, ctx.logger, checked.enforcers);
5809
5683
  const adapter = options.adapter;
5810
5684
  const routes = [];
5685
+ let upgradeTable;
5811
5686
  for (const registration of registrations) {
5812
- table.add(registration.method, registration.path, registration.plan);
5813
5687
  const plan = registration.plan;
5688
+ if (registration.upgrade !== void 0) {
5689
+ upgradeTable ??= new RouteTable();
5690
+ upgradeTable.add("GET", registration.path, registration.upgrade);
5691
+ routes.push(Object.freeze({
5692
+ method: "GET",
5693
+ path: registration.path.path,
5694
+ controller: displayName(plan.info.controller),
5695
+ handler: plan.info.handler,
5696
+ source: registration.source,
5697
+ meta: plan.info.meta,
5698
+ guards: registration.guards,
5699
+ upgrade: "websocket"
5700
+ }));
5701
+ continue;
5702
+ }
5703
+ table.add(registration.method, registration.path, registration.plan);
5814
5704
  adapter.registerRoute({
5815
5705
  method: registration.method,
5816
5706
  path: registration.path,
@@ -5827,6 +5717,16 @@ var HttpPlugin = class HttpPlugin {
5827
5717
  }));
5828
5718
  }
5829
5719
  adapter.setFallbackHandler((request, error) => pipeline.fallback(request, error));
5720
+ if (upgradeTable !== void 0) {
5721
+ if (support === void 0) throw new ZnightError(ErrorCode.WebSocketTransportUnavailable, `HttpPlugin: the ${adapter.name} adapter cannot serve WebSocket upgrade routes`, { hints: ["Use a built-in adapter (node, fastify, express, hono) or implement HttpAdapter.upgrades"] });
5722
+ const found = upgradeTable;
5723
+ pipeline.upgrades = found;
5724
+ support.enable({
5725
+ match: (path) => found.find("GET", path) !== void 0,
5726
+ handle: (request, native) => pipeline.upgrade(request, native),
5727
+ bunWebSocket: upgrades.bunWebSocket
5728
+ });
5729
+ }
5830
5730
  this.#runtime = {
5831
5731
  adapter,
5832
5732
  pipeline,
@@ -6068,7 +5968,7 @@ function httpMetadata(metadata) {
6068
5968
  if (http === null || typeof http !== "object" || !Array.isArray(http.controllers)) throw new ZnightError(ErrorCode.InvalidRoute, "Invalid metadata.extensions.http: expected { controllers: [...] }");
6069
5969
  return http;
6070
5970
  }
6071
- function buildRegistrations(ctx, options, raw, pluginRoutes) {
5971
+ function buildRegistrations(ctx, options, raw, pluginRoutes, upgradeRoutes, pipeline) {
6072
5972
  const { container, metadata } = ctx;
6073
5973
  const http = httpMetadata(metadata);
6074
5974
  const groups = RouteGroups.fromMetadata(http.groups);
@@ -6215,6 +6115,76 @@ function buildRegistrations(ctx, options, raw, pluginRoutes) {
6215
6115
  meta: () => meta
6216
6116
  });
6217
6117
  }
6118
+ for (const route of upgradeRoutes) {
6119
+ const label = `plugin "${route.plugin}" (${route.name})`;
6120
+ const where = `${route.path.path} (${route.name})`;
6121
+ const invalidGuard = (ref) => new ZnightError(ErrorCode.InvalidRoute, `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)})`);
6122
+ const guards = [...globalGuards, ...route.guards.map((ref) => typeof ref === "string" ? compiledGuard(ref, `guard of ${where}`) : guardEntries(container, metadata, [ref], invalidGuard)[0])];
6123
+ const values = {};
6124
+ for (const entry of route.meta) {
6125
+ groups.declare(entry, `the plugin "${route.plugin}"`);
6126
+ values[entry.key] = entry.value;
6127
+ }
6128
+ const meta = Object.freeze(values);
6129
+ pending.push(() => checkTargetMeta(meta, groupOf, `the upgrade route ${where} of the plugin "${route.plugin}"`));
6130
+ const accept = route.accept;
6131
+ const precheck = route.precheck;
6132
+ const checks = { canActivate: (ctx) => {
6133
+ checkHandshake(ctx);
6134
+ if (pipeline.draining) throw new ServiceUnavailableException("The server is shutting down", void 0, { headers: { "retry-after": "1" } });
6135
+ if (precheck === void 0) return true;
6136
+ const done = precheck(ctx);
6137
+ return done instanceof Promise ? done.then(() => true) : true;
6138
+ } };
6139
+ const definition = Object.freeze({
6140
+ method: "GET",
6141
+ path: route.relativePath,
6142
+ fullPath: route.relativePath,
6143
+ handler: route.name,
6144
+ meta,
6145
+ guards: [],
6146
+ interceptors: [],
6147
+ params: [],
6148
+ rawResponse: true,
6149
+ invoke: async (_plugin, http) => {
6150
+ const upgrade = pendingUpgradeOf(http);
6151
+ if (upgrade === void 0) throw new Error(`The upgrade route ${where} only serves upgrade requests`);
6152
+ if (upgrade.expired) throw handshakeExpired();
6153
+ await accept(http, upgrade);
6154
+ if (!upgrade.accepted) throw new Error(`accept() of the upgrade route ${where} did not take the connection`);
6155
+ }
6156
+ });
6157
+ const instance = { instance: route.instance };
6158
+ const chain = [{ instance: checks }, ...guards.map((guard) => guard.resolvable)];
6159
+ const body = bodyPlan({}, options.bodyLimit, options.uploads);
6160
+ const path = route.path;
6161
+ declared.push({
6162
+ method: "GET",
6163
+ path,
6164
+ plan: (method) => ({
6165
+ definition,
6166
+ invoke: definition.invoke,
6167
+ path,
6168
+ info: Object.freeze({
6169
+ method,
6170
+ path: path.path,
6171
+ controller: route.owner,
6172
+ handler: route.name,
6173
+ meta
6174
+ }),
6175
+ controller: instance,
6176
+ guards: chain,
6177
+ interceptors: [],
6178
+ parseBody: false,
6179
+ body
6180
+ }),
6181
+ label,
6182
+ source: route.plugin,
6183
+ guards,
6184
+ meta: () => meta,
6185
+ upgrade: { timeoutMs: route.timeoutMs }
6186
+ });
6187
+ }
6218
6188
  const enforcedGroups = /* @__PURE__ */ new Map();
6219
6189
  const enforcers = /* @__PURE__ */ new Map();
6220
6190
  const readEnforces = (cls, name, chained) => {
@@ -6247,17 +6217,31 @@ function buildRegistrations(ctx, options, raw, pluginRoutes) {
6247
6217
  ...route.source === "controller" ? {} : { plugin: route.source }
6248
6218
  };
6249
6219
  });
6250
- const registration = (route, method) => ({
6251
- method,
6252
- path: route.path,
6253
- plan: route.plan(method),
6254
- label: route.label,
6255
- source: route.source,
6256
- guards: Object.freeze(route.guards.map((guard) => guard.name))
6257
- });
6220
+ const registration = (route, method) => {
6221
+ const plan = route.plan(method);
6222
+ return {
6223
+ method,
6224
+ path: route.path,
6225
+ plan,
6226
+ label: route.label,
6227
+ source: route.source,
6228
+ guards: Object.freeze(route.guards.map((guard) => guard.name)),
6229
+ ...route.upgrade === void 0 ? {} : { upgrade: {
6230
+ plan,
6231
+ timeoutMs: route.upgrade.timeoutMs
6232
+ } }
6233
+ };
6234
+ };
6258
6235
  const explicit = [];
6259
6236
  const catchAll = [];
6260
- for (const route of declared) if (route.method === "ALL") catchAll.push(route);
6237
+ const upgradeTable = new RouteTable();
6238
+ const upgrades = [];
6239
+ for (const route of declared) if (route.upgrade !== void 0) {
6240
+ const candidate = registration(route, "GET");
6241
+ const existing = upgradeTable.add("GET", candidate.path, candidate);
6242
+ if (existing !== void 0) throw duplicateRoute(candidate, existing);
6243
+ upgrades.push(candidate);
6244
+ } else if (route.method === "ALL") catchAll.push(route);
6261
6245
  else explicit.push(registration(route, route.method));
6262
6246
  const table = new RouteTable();
6263
6247
  const accepted = [];
@@ -6279,6 +6263,7 @@ function buildRegistrations(ctx, options, raw, pluginRoutes) {
6279
6263
  catchAllShapes.set(route.path.shape, first);
6280
6264
  for (const method of CONCRETE_METHODS) add(method === "GET" ? first : registration(route, method), false);
6281
6265
  }
6266
+ accepted.push(...upgrades);
6282
6267
  const result = [...accepted];
6283
6268
  if (options.trailingSlash !== "strict") for (const candidate of accepted) {
6284
6269
  const path = candidate.path;
@@ -6293,7 +6278,7 @@ function buildRegistrations(ctx, options, raw, pluginRoutes) {
6293
6278
  wildcard: false,
6294
6279
  shape: `${path.shape}/`
6295
6280
  };
6296
- const plan = options.trailingSlash === "redirect" ? {
6281
+ const plan = options.trailingSlash === "redirect" && candidate.upgrade === void 0 ? {
6297
6282
  ...candidate.plan,
6298
6283
  path: variant,
6299
6284
  redirect: true
@@ -6301,10 +6286,15 @@ function buildRegistrations(ctx, options, raw, pluginRoutes) {
6301
6286
  ...candidate.plan,
6302
6287
  path: variant
6303
6288
  };
6289
+ const upgrade = candidate.upgrade === void 0 ? {} : { upgrade: {
6290
+ ...candidate.upgrade,
6291
+ plan
6292
+ } };
6304
6293
  result.push({
6305
6294
  ...candidate,
6306
6295
  path: variant,
6307
- plan
6296
+ plan,
6297
+ ...upgrade
6308
6298
  });
6309
6299
  }
6310
6300
  return {
@@ -6477,6 +6467,10 @@ function resolveRef(container, metadata, ref, what, method, invalid) {
6477
6467
  }
6478
6468
  throw invalid();
6479
6469
  }
6470
+ /** A handshake completed after its `408`: refused quietly (a 4xx is not logged as a server error). */
6471
+ function handshakeExpired() {
6472
+ return new HttpException(408, "WebSocket handshake timed out");
6473
+ }
6480
6474
  // src/standard-schema.ts
6481
6475
  /** Whether `value` looks like a Standard Schema v1 (has a `~standard` object with a `validate` function). */
6482
6476
  function isStandardSchema(value) {