@zudojs/http 1.3.0 → 1.4.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.
Files changed (68) hide show
  1. package/README.md +221 -0
  2. package/dist/httpAdapter/node/httpNode.adapter.d.ts +2 -1
  3. package/dist/httpAdapter/node/httpNode.adapter.js +17 -2
  4. package/dist/httpAdapter/node/httpNode.request.js +6 -0
  5. package/dist/httpAdapter/node/httpNode.type.d.ts +14 -0
  6. package/dist/httpClient/httpClient.retry.d.ts +17 -12
  7. package/dist/httpClient/httpClient.retry.js +35 -10
  8. package/dist/httpClient/httpClient.type.d.ts +14 -0
  9. package/dist/httpErrors/httpError.base.js +2 -2
  10. package/dist/httpErrors/httpError.util.d.ts +8 -0
  11. package/dist/httpErrors/httpError.util.js +12 -0
  12. package/dist/httpFetchMount/httpFetchMount.core.d.ts +25 -0
  13. package/dist/httpFetchMount/httpFetchMount.core.js +84 -0
  14. package/dist/httpFetchMount/httpFetchMount.request.d.ts +21 -0
  15. package/dist/httpFetchMount/httpFetchMount.request.js +100 -0
  16. package/dist/httpFetchMount/httpFetchMount.type.d.ts +56 -0
  17. package/dist/httpFetchMount/httpFetchMount.type.js +5 -0
  18. package/dist/httpFetchMount/index.d.ts +11 -0
  19. package/dist/httpFetchMount/index.js +10 -0
  20. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.d.ts +6 -3
  21. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.js +34 -6
  22. package/dist/httpMiddleware/httpMiddleware.type.d.ts +9 -1
  23. package/dist/httpMiddleware/pipeline/httpPipeline.execution.js +22 -45
  24. package/dist/httpMiddleware/pipeline/httpPipeline.guardResponse.d.ts +36 -0
  25. package/dist/httpMiddleware/pipeline/httpPipeline.guardResponse.js +57 -0
  26. package/dist/httpMiddleware/pipeline/httpPipeline.helper.d.ts +2 -1
  27. package/dist/httpMiddleware/pipeline/httpPipeline.helper.js +10 -0
  28. package/dist/httpMiddleware/pipeline/index.d.ts +1 -0
  29. package/dist/httpMiddleware/pipeline/index.js +1 -0
  30. package/dist/httpOpenApi/httpOpenApi.document.d.ts +44 -0
  31. package/dist/httpOpenApi/httpOpenApi.document.js +61 -0
  32. package/dist/httpOpenApi/httpOpenApi.mount.d.ts +31 -0
  33. package/dist/httpOpenApi/httpOpenApi.mount.js +58 -0
  34. package/dist/httpOpenApi/httpOpenApi.type.d.ts +54 -0
  35. package/dist/httpOpenApi/httpOpenApi.type.js +5 -0
  36. package/dist/httpOpenApi/index.d.ts +13 -0
  37. package/dist/httpOpenApi/index.js +12 -0
  38. package/dist/httpOpenApi/routeTable/index.d.ts +11 -0
  39. package/dist/httpOpenApi/routeTable/index.js +11 -0
  40. package/dist/httpOpenApi/routeTable/routeTable.collect.d.ts +19 -0
  41. package/dist/httpOpenApi/routeTable/routeTable.collect.js +89 -0
  42. package/dist/httpOpenApi/routeTable/routeTable.merge.d.ts +15 -0
  43. package/dist/httpOpenApi/routeTable/routeTable.merge.js +37 -0
  44. package/dist/httpOpenApi/routeTable/routeTable.template.d.ts +30 -0
  45. package/dist/httpOpenApi/routeTable/routeTable.template.js +67 -0
  46. package/dist/httpRequest/httpRequest.context.d.ts +8 -0
  47. package/dist/httpRequest/httpRequest.context.js +12 -0
  48. package/dist/httpRequest/index.d.ts +1 -0
  49. package/dist/httpRequest/index.js +1 -0
  50. package/dist/httpRequest/requestId/httpRequest.requestId.d.ts +25 -0
  51. package/dist/httpRequest/requestId/httpRequest.requestId.js +34 -0
  52. package/dist/httpRequest/requestId/index.d.ts +7 -0
  53. package/dist/httpRequest/requestId/index.js +7 -0
  54. package/dist/httpResponse/httpResponse.writer.js +15 -0
  55. package/dist/httpRouter/core/factory/httpRoute.factory.base.d.ts +6 -3
  56. package/dist/httpRouter/core/factory/httpRoute.factory.base.js +24 -11
  57. package/dist/httpRouter/core/group/httpRouterGroup.core.js +13 -0
  58. package/dist/httpRouter/core/register/httpRouter.register.js +4 -1
  59. package/dist/httpRouter/core/types/httpRouter.type.d.ts +31 -1
  60. package/dist/httpRouter/core/util/httpRoute.util.d.ts +8 -0
  61. package/dist/httpRouter/core/util/httpRoute.util.js +16 -0
  62. package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +20 -3
  63. package/dist/httpSecurity/httpSecurity.config.js +4 -1
  64. package/dist/httpServer/factory/httpServer.factory.d.ts +10 -9
  65. package/dist/httpServer/factory/httpServer.factory.js +8 -0
  66. package/dist/index.d.ts +2 -0
  67. package/dist/index.js +2 -0
  68. package/package.json +11 -8
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Serving web-standard fetch handlers from an `@zudojs/http` router.
3
+ */
4
+ import { webResponseToContext } from "../httpResponse/httpResponse.fromWeb.js";
5
+ import { normalizeRoutePattern } from "../httpRouter/core/util/httpRoute.util.js";
6
+ import { contextUrl, toWebRequest } from "./httpFetchMount.request.js";
7
+ /** The wildcard every mount registers; its name is reserved for mounts. */
8
+ const MOUNT_WILDCARD = "*fetchMountPath";
9
+ function segments(path) {
10
+ return path.split("/").filter((segment) => segment !== "");
11
+ }
12
+ /**
13
+ * Removes the first `count` path segments, keeping a trailing slash.
14
+ *
15
+ * Counted in segments rather than characters so it holds for a mount under
16
+ * a group prefix, a parameterised mount path and a case-insensitive match.
17
+ */
18
+ function splitPrefix(pathname, count) {
19
+ const parts = segments(pathname);
20
+ const prefix = `/${parts.slice(0, count).join("/")}`;
21
+ const rest = parts.slice(count);
22
+ if (rest.length === 0)
23
+ return [prefix, "/"];
24
+ return [prefix, `/${rest.join("/")}${pathname.endsWith("/") ? "/" : ""}`];
25
+ }
26
+ /**
27
+ * Serves a web-standard `(request: Request) => Promise<Response>` handler —
28
+ * an `@zudojs/rpc` server, `@zudojs/api` operations, any fetch-style app —
29
+ * under `basePath` of an `@zudojs/http` router or router group.
30
+ *
31
+ * The handler receives a `Request` with the original method, headers
32
+ * (connection-scoped ones removed), body and query, and a `signal` that
33
+ * aborts when the client disconnects. Its `Response` is streamed back with
34
+ * status, status text and headers intact, every `Set-Cookie` kept separate.
35
+ * A handler that throws, or returns something that is not a `Response`,
36
+ * fails the request like any other route (500 unless the error carries a
37
+ * status).
38
+ *
39
+ * ```ts
40
+ * mountFetchHandler(router, "/rpc", createRPCFetchHandler(rpcServer));
41
+ * ```
42
+ *
43
+ * @returns A function that removes the mount.
44
+ */
45
+ export function mountFetchHandler(target, basePath, handler, options = {}) {
46
+ if (typeof handler !== "function") {
47
+ throw new TypeError("mountFetchHandler requires a handler function.");
48
+ }
49
+ const base = normalizeRoutePattern(basePath);
50
+ const pattern = base === "/" ? `/${MOUNT_WILDCARD}` : `${base}/${MOUNT_WILDCARD}`;
51
+ const strip = options.stripPrefix ?? true;
52
+ const routeHandler = async (context) => {
53
+ const url = contextUrl(context.request, options.origin);
54
+ const headers = {};
55
+ if (strip) {
56
+ const mounted = segments(context.route.path).length - 1;
57
+ const [prefix, rest] = splitPrefix(url.pathname, mounted);
58
+ url.pathname = rest;
59
+ if (mounted > 0)
60
+ headers["x-forwarded-prefix"] = prefix;
61
+ }
62
+ const request = toWebRequest(context.request, {
63
+ url,
64
+ signal: context.signal,
65
+ headers,
66
+ });
67
+ const response = await handler(request);
68
+ if (!(response instanceof Response)) {
69
+ throw new TypeError(`The fetch handler mounted at ${base} returned ${typeof response}, not a Response.`);
70
+ }
71
+ return webResponseToContext(response);
72
+ };
73
+ const routeOptions = {
74
+ openapi: options.openapi ?? false,
75
+ ...(options.name === undefined ? {} : { name: options.name }),
76
+ ...(options.middleware ? { middleware: options.middleware } : {}),
77
+ };
78
+ const removers = (options.methods ?? ["*"]).map((method) => target.on(method, pattern, routeHandler, routeOptions));
79
+ return () => {
80
+ for (const remove of removers)
81
+ remove();
82
+ };
83
+ }
84
+ //# sourceMappingURL=httpFetchMount.core.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Conversion of a request context into a WHATWG `Request`.
3
+ */
4
+ import type { HttpRequestContext } from "../httpRequest/httpRequest.context.js";
5
+ import type { ToWebRequestOptions } from "./httpFetchMount.type.js";
6
+ /**
7
+ * The context's URL as an absolute URL. The request target is never read
8
+ * as an authority; the origin is `origin` when given, else the context's.
9
+ */
10
+ export declare function contextUrl(context: HttpRequestContext, origin?: string): URL;
11
+ /**
12
+ * Builds a web `Request` from a request context.
13
+ *
14
+ * Headers are copied except connection-scoped ones; the body is the one the
15
+ * adapter read (bytes, text, a stream, or a parsed value re-encoded as JSON,
16
+ * labelled `application/json` unless it already had a JSON content type)
17
+ * and is never attached to `GET` / `HEAD`; the signal aborts the request
18
+ * when the client disconnects.
19
+ */
20
+ export declare function toWebRequest(context: HttpRequestContext, options?: ToWebRequestOptions): Request;
21
+ //# sourceMappingURL=httpFetchMount.request.d.ts.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Conversion of a request context into a WHATWG `Request`.
3
+ */
4
+ import { parseRequestTarget } from "../httpRequest/target/httpRequest.target.js";
5
+ /**
6
+ * Connection-scoped headers. They describe the hop between the client and
7
+ * this server, not the request, and `content-length` is recomputed from the
8
+ * body actually passed on.
9
+ */
10
+ const HOP_BY_HOP = new Set([
11
+ "connection",
12
+ "keep-alive",
13
+ "proxy-connection",
14
+ "transfer-encoding",
15
+ "te",
16
+ "trailer",
17
+ "upgrade",
18
+ "content-length",
19
+ ]);
20
+ const DEFAULT_ORIGIN = "http://localhost";
21
+ const JSON_TYPE = /^application\/(?:[\w.+-]+\+)?json(?:\s*;|$)/i;
22
+ /**
23
+ * The origin of the web request: `pinned` when the caller configured one,
24
+ * otherwise the context's own (from the `Host` header, or a trusted
25
+ * proxy's `X-Forwarded-Host`), otherwise `http://localhost`.
26
+ */
27
+ function resolveOrigin(context, pinned) {
28
+ for (const candidate of [pinned, context.origin, DEFAULT_ORIGIN]) {
29
+ if (candidate === undefined)
30
+ continue;
31
+ try {
32
+ return new URL(candidate).origin;
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ }
38
+ return DEFAULT_ORIGIN;
39
+ }
40
+ /**
41
+ * The context's URL as an absolute URL. The request target is never read
42
+ * as an authority; the origin is `origin` when given, else the context's.
43
+ */
44
+ export function contextUrl(context, origin) {
45
+ const target = parseRequestTarget(context.url);
46
+ const url = new URL(resolveOrigin(context, origin));
47
+ url.pathname = target.pathname;
48
+ url.search = target.search;
49
+ return url;
50
+ }
51
+ function toBody(body, headers) {
52
+ if (body === undefined || body === null)
53
+ return undefined;
54
+ if (typeof body === "string")
55
+ return body;
56
+ if (body instanceof Uint8Array)
57
+ return body;
58
+ if (body instanceof ArrayBuffer)
59
+ return body;
60
+ if (body instanceof ReadableStream ||
61
+ body instanceof Blob ||
62
+ body instanceof FormData ||
63
+ body instanceof URLSearchParams) {
64
+ return body;
65
+ }
66
+ if (!JSON_TYPE.test(headers.get("content-type") ?? "")) {
67
+ headers.set("content-type", "application/json");
68
+ }
69
+ return JSON.stringify(body);
70
+ }
71
+ /**
72
+ * Builds a web `Request` from a request context.
73
+ *
74
+ * Headers are copied except connection-scoped ones; the body is the one the
75
+ * adapter read (bytes, text, a stream, or a parsed value re-encoded as JSON,
76
+ * labelled `application/json` unless it already had a JSON content type)
77
+ * and is never attached to `GET` / `HEAD`; the signal aborts the request
78
+ * when the client disconnects.
79
+ */
80
+ export function toWebRequest(context, options = {}) {
81
+ const method = context.method.toUpperCase();
82
+ const headers = new Headers();
83
+ for (const [name, value] of Object.entries(context.headers)) {
84
+ if (!HOP_BY_HOP.has(name.toLowerCase()))
85
+ headers.append(name, value);
86
+ }
87
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
88
+ headers.set(name, value);
89
+ }
90
+ const body = method === "GET" || method === "HEAD" ? undefined : toBody(context.body, headers);
91
+ const init = {
92
+ method,
93
+ headers,
94
+ ...(body === undefined ? {} : { body }),
95
+ ...(body instanceof ReadableStream ? { duplex: "half" } : {}),
96
+ ...(options.signal ? { signal: options.signal } : {}),
97
+ };
98
+ return new Request(options.url ?? contextUrl(context, options.origin), init);
99
+ }
100
+ //# sourceMappingURL=httpFetchMount.request.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Types for serving web-standard fetch handlers from a router.
3
+ */
4
+ import type { HttpMethod, HttpRouteOpenAPI, RouteOptions, RouterHandler } from "../httpRouter/core/types/httpRouter.type.js";
5
+ import type { HttpMiddleware } from "../httpMiddleware/httpMiddleware.type.js";
6
+ /** A web-standard handler: `Request` in, `Response` out. */
7
+ export type HttpFetchHandler = (request: Request) => Response | Promise<Response>;
8
+ /** Anything routes can be registered on: an `HttpRouter` or a router group. */
9
+ export interface HttpFetchMountTarget {
10
+ on(method: HttpMethod | "*", path: string, handler: RouterHandler, options?: RouteOptions): () => void;
11
+ }
12
+ /** Options for `mountFetchHandler`. */
13
+ export interface MountFetchHandlerOptions {
14
+ /** Methods routed to the handler. Default: every method. */
15
+ readonly methods?: readonly HttpMethod[];
16
+ /**
17
+ * Remove the mount path from the URL the handler sees, so a handler
18
+ * mounted at `/rpc` receives `/users.get` for `/rpc/users.get`. The
19
+ * removed prefix is passed as `x-forwarded-prefix`. Default: `true`.
20
+ */
21
+ readonly stripPrefix?: boolean;
22
+ /**
23
+ * Origin of the handler's `request.url`, e.g. `https://api.example.com`.
24
+ * When set it is used for every request. When not, the origin comes from
25
+ * the request — its `Host` header, or `X-Forwarded-Host` from a trusted
26
+ * proxy — which the client chooses; set this whenever the handler builds
27
+ * absolute URLs (redirects, callback or reset links) or checks
28
+ * `Origin` against its own. Falls back to `http://localhost`.
29
+ */
30
+ readonly origin?: string;
31
+ /** Middleware run before the handler. */
32
+ readonly middleware?: readonly HttpMiddleware[];
33
+ /** Route name. */
34
+ readonly name?: string;
35
+ /**
36
+ * OpenAPI documentation for the mount. Default: `false` — a mounted
37
+ * handler documents its own operations (see `@zudojs/openapi`'s
38
+ * `createOpenAPIDocumentFromRoutes`).
39
+ */
40
+ readonly openapi?: HttpRouteOpenAPI;
41
+ }
42
+ /** Options for `toWebRequest`. */
43
+ export interface ToWebRequestOptions {
44
+ /** Overrides the request URL (absolute). */
45
+ readonly url?: string | URL;
46
+ /** Aborts the web request; normally the router context's signal. */
47
+ readonly signal?: AbortSignal;
48
+ /**
49
+ * Origin of the web request. Overrides the context's protocol and host
50
+ * (which come from the client's `Host` header) when set.
51
+ */
52
+ readonly origin?: string;
53
+ /** Extra headers set on the web request, replacing same-named ones. */
54
+ readonly headers?: Readonly<Record<string, string>>;
55
+ }
56
+ //# sourceMappingURL=httpFetchMount.type.d.ts.map
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Types for serving web-standard fetch handlers from a router.
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=httpFetchMount.type.js.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @zudojs/http/httpFetchMount
3
+ *
4
+ * Serves web-standard fetch handlers (`Request` → `Response`) from an
5
+ * `@zudojs/http` router: `mountFetchHandler` registers one under a path,
6
+ * `toWebRequest` converts a request context into a `Request` on its own.
7
+ */
8
+ export { mountFetchHandler } from "./httpFetchMount.core.js";
9
+ export { toWebRequest, contextUrl } from "./httpFetchMount.request.js";
10
+ export type { HttpFetchHandler, HttpFetchMountTarget, MountFetchHandlerOptions, ToWebRequestOptions, } from "./httpFetchMount.type.js";
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @zudojs/http/httpFetchMount
3
+ *
4
+ * Serves web-standard fetch handlers (`Request` → `Response`) from an
5
+ * `@zudojs/http` router: `mountFetchHandler` registers one under a path,
6
+ * `toWebRequest` converts a request context into a `Request` on its own.
7
+ */
8
+ export { mountFetchHandler } from "./httpFetchMount.core.js";
9
+ export { toWebRequest, contextUrl } from "./httpFetchMount.request.js";
10
+ //# sourceMappingURL=index.js.map
@@ -39,9 +39,12 @@ export type RateLimitMiddlewareOptions = RateLimiterOptions | {
39
39
  readonly limiter: HttpRateLimiter;
40
40
  };
41
41
  /**
42
- * Creates middleware that answers `429 Too Many Requests` (with
43
- * `Retry-After`, from the `@zudojs/security` handler) once a client exceeds
44
- * its allowance, and otherwise passes the request on.
42
+ * Creates middleware that answers `429 Too Many Requests` once a client
43
+ * exceeds its allowance, and otherwise passes the request on. The 429 has a
44
+ * JSON body (`{"error":{"code":"RATE_LIMIT_EXCEEDED",...}}`) sent as
45
+ * `application/json`, and always a `Retry-After` header (the
46
+ * `@zudojs/security` handler's, or one computed from the limiter's reset
47
+ * time when a custom handler leaves it out).
45
48
  */
46
49
  export declare function createRateLimitMiddleware(options: RateLimitMiddlewareOptions): HttpMiddleware;
47
50
  //# sourceMappingURL=httpMiddleware.rateLimit.d.ts.map
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * @module httpMiddleware/builtin/rateLimit
20
20
  */
21
- import { createRateLimiter, parseClientIp, } from "@zudojs/security";
21
+ import { createRateLimiter, parseClientIp, retryAfterSeconds, } from "@zudojs/security";
22
22
  import { createResponseContext } from "../../../httpResponse/httpResponse.context.js";
23
23
  /**
24
24
  * The `ip` given to the limiter for a request whose `remoteAddress` is
@@ -27,9 +27,12 @@ import { createResponseContext } from "../../../httpResponse/httpResponse.contex
27
27
  */
28
28
  export const UNKNOWN_CLIENT_RATE_LIMIT_IP = "0.0.0.0";
29
29
  /**
30
- * Creates middleware that answers `429 Too Many Requests` (with
31
- * `Retry-After`, from the `@zudojs/security` handler) once a client exceeds
32
- * its allowance, and otherwise passes the request on.
30
+ * Creates middleware that answers `429 Too Many Requests` once a client
31
+ * exceeds its allowance, and otherwise passes the request on. The 429 has a
32
+ * JSON body (`{"error":{"code":"RATE_LIMIT_EXCEEDED",...}}`) sent as
33
+ * `application/json`, and always a `Retry-After` header (the
34
+ * `@zudojs/security` handler's, or one computed from the limiter's reset
35
+ * time when a custom handler leaves it out).
33
36
  */
34
37
  export function createRateLimitMiddleware(options) {
35
38
  const limiter = "limiter" in options ? options.limiter : createRateLimiter(options);
@@ -42,16 +45,41 @@ export function createRateLimitMiddleware(options) {
42
45
  headers: request.headers,
43
46
  };
44
47
  const rejection = { statusCode: 429, headers: {} };
45
- if (limiter.middleware(limitRequest, rejection).allowed) {
48
+ const decision = limiter.middleware(limitRequest, rejection);
49
+ if (decision.allowed) {
46
50
  return next();
47
51
  }
48
52
  const response = createResponseContext().setStatus(rejection.statusCode);
49
53
  for (const [name, value] of Object.entries(rejection.headers)) {
50
54
  response.setHeader(name.toLowerCase(), value);
51
55
  }
52
- return response.setBody(rejection.body ?? "Too Many Requests");
56
+ if (response.headers["retry-after"] === undefined) {
57
+ response.setHeader("retry-after", String(retryAfterSeconds(decision)));
58
+ }
59
+ const body = rejection.body ?? DEFAULT_REJECTION_BODY;
60
+ /*
61
+ * The limiter's body is JSON, but it went out as a bare string, which the
62
+ * writer labels `text/plain`. A handler's own content type is kept.
63
+ */
64
+ if (response.headers["content-type"] === undefined) {
65
+ response.setHeader("content-type", isJson(body) ? "application/json; charset=utf-8" : "text/plain; charset=utf-8");
66
+ }
67
+ return response.setBody(body);
53
68
  };
54
69
  }
70
+ /** Body sent when a custom limiter handler supplies none. */
71
+ const DEFAULT_REJECTION_BODY = JSON.stringify({
72
+ error: { code: "RATE_LIMIT_EXCEEDED", message: "Too Many Requests" },
73
+ });
74
+ function isJson(body) {
75
+ try {
76
+ JSON.parse(body);
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
55
83
  function clientIpOf(remoteAddress) {
56
84
  return typeof remoteAddress === "string" &&
57
85
  parseClientIp(remoteAddress) !== undefined
@@ -3,9 +3,17 @@
3
3
  *
4
4
  * @module httpMiddleware/types
5
5
  */
6
+ import type { GuardResponse } from "@zudojs/middleware";
6
7
  import type { HttpRequestContext as RequestContext } from "../httpRequest/httpRequest.context.js";
7
8
  import type { HttpResponseContext as ResponseContext } from "../httpResponse/httpResponse.context.js";
8
- export type HttpMiddlewareResult = void | Response | RequestContext | ResponseContext | Promise<void | Response | RequestContext | ResponseContext>;
9
+ /**
10
+ * What a middleware may return.
11
+ *
12
+ * A `GuardResponse` (from `createGuardResponse` in `@zudojs/middleware`) is
13
+ * sent with its own status, headers and body; it is how a guard in a
14
+ * lower-tier package, which cannot import this one, answers 401/403/404.
15
+ */
16
+ export type HttpMiddlewareResult = void | Response | RequestContext | ResponseContext | GuardResponse | Promise<void | Response | RequestContext | ResponseContext | GuardResponse>;
9
17
  export type HttpNext = () => Promise<ResponseContext>;
10
18
  export type HttpMiddleware = (context: HttpMiddlewareContext, next: HttpNext) => HttpMiddlewareResult;
11
19
  export interface HttpMiddlewareContext {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @module httpMiddleware/pipeline/execution
5
5
  */
6
- import { HttpMiddlewareError, HttpMiddlewarePipelineError, } from "../httpMiddleware.error.js";
6
+ import { HttpMiddlewareError } from "../httpMiddleware.error.js";
7
7
  import { normalizeResult } from "./httpPipeline.helper.js";
8
8
  import { list } from "./httpPipeline.registration.js";
9
9
  export async function executePipeline(entries, request, response, options = {}, pipelineOptions) {
@@ -42,56 +42,33 @@ export async function executePipeline(entries, request, response, options = {},
42
42
  }
43
43
  return dispatch(index + 1);
44
44
  };
45
- try {
46
- const result = await entry.middleware(context, next);
47
- return normalizeResult(result, response);
48
- }
49
- catch (error) {
50
- /*
51
- * Rethrow rather than returning the untouched response. Swallowing here
52
- * let an inner failure resume the *outer* frames, so middleware that
53
- * runs after `await next()` — access logging, CORS and security header
54
- * emission, audit commits — executed against a response that was about
55
- * to be discarded, and recorded the request as a success.
56
- */
57
- throw error instanceof HttpMiddlewareError
58
- ? error
59
- : new HttpMiddlewareError(`Middleware "${entry.name}" threw an error.`, {
60
- middlewareId: entry.id,
61
- middlewareName: entry.name,
62
- cause: error,
63
- });
64
- }
45
+ /*
46
+ * A failure propagates as the error that was thrown, never a wrapper and
47
+ * never swallowed. An outer middleware's `await next()` rejects with it,
48
+ * so code after `await next()` does not run unless that middleware
49
+ * catches it (`try/catch`, `try/finally`), and `instanceof NotFoundError`
50
+ * works there and in the server's `errorHandler`. It used to be wrapped
51
+ * in `HttpMiddlewareError` here and `HttpMiddlewarePipelineError` below,
52
+ * which hid the original in `cause` / `errors[0].cause`.
53
+ */
54
+ const result = await entry.middleware(context, next);
55
+ return normalizeResult(result, response);
65
56
  };
66
57
  try {
67
58
  return await dispatch(0);
68
59
  }
69
60
  catch (error) {
70
- const middlewareError = error instanceof HttpMiddlewareError
71
- ? error
72
- : new HttpMiddlewareError("HTTP middleware pipeline failed.", {
73
- cause: error,
74
- });
75
- if (pipelineOptions.onError) {
76
- try {
77
- /*
78
- * A successful `onError` is a genuine recovery and must be returned.
79
- * Previously every caught error was recorded before `onError` ran and
80
- * the recorded list was rethrown afterwards, so the handler's result
81
- * was always discarded and the option could never take effect.
82
- */
83
- const errorResult = await pipelineOptions.onError(middlewareError.cause ?? error, context);
84
- return normalizeResult(errorResult, response);
85
- }
86
- catch (handlerError) {
87
- /* The handler's own failure is reported, not swallowed. */
88
- throw new HttpMiddlewarePipelineError([
89
- middlewareError,
90
- new HttpMiddlewareError("HTTP middleware error handler threw an error.", { cause: handlerError }),
91
- ]);
92
- }
61
+ if (!pipelineOptions.onError) {
62
+ throw error;
93
63
  }
94
- throw new HttpMiddlewarePipelineError([middlewareError]);
64
+ /*
65
+ * A successful `onError` is a genuine recovery and is returned. If the
66
+ * handler throws, what it threw propagates: rethrowing the error it was
67
+ * given passes that original on, and throwing a translated error (a
68
+ * `NotFoundError` for a missing row) replaces it.
69
+ */
70
+ const errorResult = await pipelineOptions.onError(error, context);
71
+ return normalizeResult(errorResult, response);
95
72
  }
96
73
  }
97
74
  //# sourceMappingURL=httpPipeline.execution.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Turns a `@zudojs/middleware` guard response into a real response.
3
+ *
4
+ * A middleware that refuses a request — the permissions `authorize()` guard,
5
+ * the tenancy resolver — returns `createGuardResponse({ status, body })`.
6
+ * Route middleware used to ignore any returned object that was not an
7
+ * `HttpResponseContext`, so a plain `{ status: 403, body, headers }` reached
8
+ * the client as `200`: the handler did not run, but clients, caches and
9
+ * monitoring saw a success. Only the branded object is honoured; an unbranded
10
+ * object keeps its previous meaning, so data that happens to carry a
11
+ * `status` key is never read as a response.
12
+ *
13
+ * @module httpMiddleware/pipeline/guardResponse
14
+ */
15
+ import type { GuardResponse } from "@zudojs/middleware";
16
+ import { HttpResponseContext } from "../../httpResponse/httpResponse.context.js";
17
+ /**
18
+ * Writes a guard response's status, headers and body onto `target`.
19
+ *
20
+ * Headers already on `target` (set by an outer middleware before it called
21
+ * `next()`, such as CORS) are kept unless the guard response overrides them.
22
+ *
23
+ * @param target - The response that stays authoritative.
24
+ * @param guard - The guard response to apply.
25
+ * @returns `target`.
26
+ * @throws {TypeError} If a header value contains a control character.
27
+ */
28
+ export declare function applyGuardResponse(target: HttpResponseContext, guard: GuardResponse): HttpResponseContext;
29
+ /**
30
+ * Builds a fresh response context from a guard response.
31
+ *
32
+ * @param guard - The guard response to convert.
33
+ * @returns A new response context.
34
+ */
35
+ export declare function guardResponseToContext(guard: GuardResponse): HttpResponseContext;
36
+ //# sourceMappingURL=httpPipeline.guardResponse.d.ts.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Turns a `@zudojs/middleware` guard response into a real response.
3
+ *
4
+ * A middleware that refuses a request — the permissions `authorize()` guard,
5
+ * the tenancy resolver — returns `createGuardResponse({ status, body })`.
6
+ * Route middleware used to ignore any returned object that was not an
7
+ * `HttpResponseContext`, so a plain `{ status: 403, body, headers }` reached
8
+ * the client as `200`: the handler did not run, but clients, caches and
9
+ * monitoring saw a success. Only the branded object is honoured; an unbranded
10
+ * object keeps its previous meaning, so data that happens to carry a
11
+ * `status` key is never read as a response.
12
+ *
13
+ * @module httpMiddleware/pipeline/guardResponse
14
+ */
15
+ import { HttpResponseContext } from "../../httpResponse/httpResponse.context.js";
16
+ import { isValidHeaderFieldValue } from "../../httpHeaders/security/index.js";
17
+ /**
18
+ * Writes a guard response's status, headers and body onto `target`.
19
+ *
20
+ * Headers already on `target` (set by an outer middleware before it called
21
+ * `next()`, such as CORS) are kept unless the guard response overrides them.
22
+ *
23
+ * @param target - The response that stays authoritative.
24
+ * @param guard - The guard response to apply.
25
+ * @returns `target`.
26
+ * @throws {TypeError} If a header value contains a control character.
27
+ */
28
+ export function applyGuardResponse(target, guard) {
29
+ target.setStatus(guard.status);
30
+ const body = guard.body;
31
+ if (body === undefined) {
32
+ target.setBody(undefined);
33
+ }
34
+ else if (typeof body === "string" || body instanceof Uint8Array) {
35
+ target.setBody(body);
36
+ }
37
+ else {
38
+ target.json(body);
39
+ }
40
+ for (const [name, value] of Object.entries(guard.headers)) {
41
+ if (!isValidHeaderFieldValue(value)) {
42
+ throw new TypeError(`Invalid value for response header "${name}": it contains a control character or surrounding whitespace.`);
43
+ }
44
+ target.setHeader(name, value);
45
+ }
46
+ return target;
47
+ }
48
+ /**
49
+ * Builds a fresh response context from a guard response.
50
+ *
51
+ * @param guard - The guard response to convert.
52
+ * @returns A new response context.
53
+ */
54
+ export function guardResponseToContext(guard) {
55
+ return applyGuardResponse(new HttpResponseContext(), guard);
56
+ }
57
+ //# sourceMappingURL=httpPipeline.guardResponse.js.map
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * @module httpMiddleware/pipeline/helpers
5
5
  */
6
+ import { type GuardResponse } from "@zudojs/middleware";
6
7
  import type { HttpMiddlewareContext, RegisteredMiddleware } from "../httpMiddleware.type.js";
7
8
  import { HttpRequestContext as RequestContext } from "../../httpRequest/httpRequest.context.js";
8
9
  import { HttpResponseContext as ResponseContext } from "../../httpResponse/httpResponse.context.js";
@@ -20,7 +21,7 @@ export declare function nextResult(context: HttpMiddlewareContext, dispatch: (in
20
21
  * @returns The response context to continue the pipeline with.
21
22
  * @throws {HttpMiddlewareError} If no response context can be determined.
22
23
  */
23
- export declare function normalizeResult(result: void | Response | RequestContext | ResponseContext | undefined, fallback?: ResponseContext): ResponseContext;
24
+ export declare function normalizeResult(result: void | Response | RequestContext | ResponseContext | GuardResponse | undefined, fallback?: ResponseContext): ResponseContext;
24
25
  /**
25
26
  * Narrows a value to a response context.
26
27
  *
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * @module httpMiddleware/pipeline/helpers
5
5
  */
6
+ import { isGuardResponse } from "@zudojs/middleware";
6
7
  import { HttpRequestContext as RequestContext } from "../../httpRequest/httpRequest.context.js";
7
8
  import { HttpResponseContext as ResponseContext } from "../../httpResponse/httpResponse.context.js";
8
9
  import { isWebResponse, webResponseToContext, } from "../../httpResponse/httpResponse.fromWeb.js";
9
10
  import { HttpMiddlewareError } from "../httpMiddleware.error.js";
11
+ import { guardResponseToContext } from "./httpPipeline.guardResponse.js";
10
12
  export async function nextResult(context, dispatch, index) {
11
13
  return dispatch(index + 1);
12
14
  }
@@ -24,6 +26,14 @@ export async function nextResult(context, dispatch, index) {
24
26
  * @throws {HttpMiddlewareError} If no response context can be determined.
25
27
  */
26
28
  export function normalizeResult(result, fallback) {
29
+ /*
30
+ * A guard response is checked before the structural `isResponseContext`
31
+ * test, which would otherwise pass the frozen plain object through as if it
32
+ * were a response context.
33
+ */
34
+ if (isGuardResponse(result)) {
35
+ return guardResponseToContext(result);
36
+ }
27
37
  /*
28
38
  * The `Response` check must come first. `isResponseContext` tests for a
29
39
  * `headers` property, and `headers` is a getter on `Response.prototype`, so
@@ -5,6 +5,7 @@
5
5
  */
6
6
  export * from "./httpMiddleware.pipeline.js";
7
7
  export * from "./httpPipeline.execution.js";
8
+ export * from "./httpPipeline.guardResponse.js";
8
9
  export * from "./httpPipeline.helper.js";
9
10
  export * from "./httpPipeline.registration.js";
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -5,6 +5,7 @@
5
5
  */
6
6
  export * from "./httpMiddleware.pipeline.js";
7
7
  export * from "./httpPipeline.execution.js";
8
+ export * from "./httpPipeline.guardResponse.js";
8
9
  export * from "./httpPipeline.helper.js";
9
10
  export * from "./httpPipeline.registration.js";
10
11
  //# sourceMappingURL=index.js.map