@nifrajs/middleware 2.9.1 → 2.11.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.
@@ -0,0 +1,161 @@
1
+ import { definePlugin, pathnameOf } from "@nifrajs/core/server";
2
+ const JSON_TYPE = /^(?:application\/json|[^/]+\/[^;]+\+json)(?:\s*;|$)/i;
3
+ const DEFAULT_MAX_BYTES = 64 * 1024;
4
+ const STATUS_TITLES = {
5
+ 400: "Bad Request",
6
+ 401: "Unauthorized",
7
+ 403: "Forbidden",
8
+ 404: "Not Found",
9
+ 405: "Method Not Allowed",
10
+ 406: "Not Acceptable",
11
+ 408: "Request Timeout",
12
+ 409: "Conflict",
13
+ 410: "Gone",
14
+ 411: "Length Required",
15
+ 412: "Precondition Failed",
16
+ 413: "Content Too Large",
17
+ 415: "Unsupported Media Type",
18
+ 422: "Unprocessable Content",
19
+ 429: "Too Many Requests",
20
+ 500: "Internal Server Error",
21
+ 501: "Not Implemented",
22
+ 502: "Bad Gateway",
23
+ 503: "Service Unavailable",
24
+ 504: "Gateway Timeout",
25
+ 507: "Insufficient Storage",
26
+ };
27
+ function isRecord(value) {
28
+ return typeof value === "object" && value !== null;
29
+ }
30
+ function readEnvelope(body, contentType, maxBytes) {
31
+ if (contentType === null || !JSON_TYPE.test(contentType))
32
+ return undefined;
33
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
34
+ if (bytes.byteLength > maxBytes)
35
+ return undefined;
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ if (!isRecord(parsed) || parsed.ok !== false || typeof parsed.error !== "string") {
44
+ return undefined;
45
+ }
46
+ return {
47
+ error: parsed.error,
48
+ ...(Object.hasOwn(parsed, "issues") ? { issues: parsed.issues } : {}),
49
+ };
50
+ }
51
+ async function readCappedBody(body, maxBytes) {
52
+ const reader = body.getReader();
53
+ const chunks = [];
54
+ let size = 0;
55
+ try {
56
+ while (true) {
57
+ const result = await reader.read();
58
+ if (result.done) {
59
+ const bytes = new Uint8Array(size);
60
+ let offset = 0;
61
+ for (const chunk of chunks) {
62
+ bytes.set(chunk, offset);
63
+ offset += chunk.byteLength;
64
+ }
65
+ return bytes;
66
+ }
67
+ if (size + result.value.byteLength > maxBytes) {
68
+ void reader.cancel().catch(() => { });
69
+ return undefined;
70
+ }
71
+ chunks.push(result.value);
72
+ size += result.value.byteLength;
73
+ }
74
+ }
75
+ catch {
76
+ void reader.cancel().catch(() => { });
77
+ return undefined;
78
+ }
79
+ }
80
+ function problemType(typeBase, code) {
81
+ if (typeBase === undefined)
82
+ return "about:blank";
83
+ return `${typeBase.replace(/\/+$/, "")}/${encodeURIComponent(code)}`;
84
+ }
85
+ function problemDocument(status, envelope, typeBase, instance) {
86
+ const document = {
87
+ type: problemType(typeBase, envelope.error),
88
+ title: STATUS_TITLES[status] ?? "HTTP Error",
89
+ status,
90
+ code: envelope.error,
91
+ };
92
+ if (envelope.issues !== undefined)
93
+ document.issues = envelope.issues;
94
+ if (instance !== undefined)
95
+ document.instance = instance;
96
+ return document;
97
+ }
98
+ function isAlreadyProblemDetails(contentType) {
99
+ return contentType?.toLowerCase().startsWith("application/problem+json") === true;
100
+ }
101
+ /**
102
+ * Opt-in RFC 9457 formatting for Nifra's structured error envelope.
103
+ *
104
+ * The default `{ ok: false, error }` response remains unchanged. This plugin only converts that
105
+ * framework envelope, so custom JSON errors and already-formatted problem documents pass through.
106
+ * Raw JSON error Responses are supported through a capped clone; encoded responses are never drained.
107
+ */
108
+ export function problemDetails(options = {}) {
109
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
110
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
111
+ throw new Error("problemDetails: maxBytes must be a non-negative safe integer");
112
+ }
113
+ const instance = options.includeInstance === true;
114
+ const typeBase = options.typeBase;
115
+ return definePlugin("problemDetails", (app) => app.use({
116
+ onResponseBody(body, headers, req, status) {
117
+ if (status < 400 ||
118
+ status >= 600 ||
119
+ headers.has("content-encoding") ||
120
+ isAlreadyProblemDetails(headers.get("content-type"))) {
121
+ return undefined;
122
+ }
123
+ const envelope = readEnvelope(body, headers.get("content-type"), maxBytes);
124
+ if (envelope === undefined)
125
+ return undefined;
126
+ const document = problemDocument(status, envelope, typeBase, instance ? pathnameOf(req.url) : undefined);
127
+ headers.set("content-type", "application/problem+json");
128
+ headers.delete("content-length");
129
+ return JSON.stringify(document);
130
+ },
131
+ async onResponseRaw(response, req) {
132
+ if (response.status < 400 ||
133
+ response.status >= 600 ||
134
+ response.body === null ||
135
+ response.headers.has("content-encoding") ||
136
+ isAlreadyProblemDetails(response.headers.get("content-type"))) {
137
+ return response;
138
+ }
139
+ let bytes;
140
+ try {
141
+ const clone = response.clone();
142
+ if (clone.body === null)
143
+ return response;
144
+ bytes = await readCappedBody(clone.body, maxBytes);
145
+ }
146
+ catch {
147
+ return response;
148
+ }
149
+ if (bytes === undefined)
150
+ return response;
151
+ const envelope = readEnvelope(bytes, response.headers.get("content-type"), maxBytes);
152
+ if (envelope === undefined)
153
+ return response;
154
+ const headers = new Headers(response.headers);
155
+ headers.set("content-type", "application/problem+json");
156
+ headers.delete("content-length");
157
+ return new Response(JSON.stringify(problemDocument(response.status, envelope, typeBase, instance ? pathnameOf(req.url) : undefined)), { status: response.status, statusText: response.statusText, headers });
158
+ },
159
+ }));
160
+ }
161
+ //# sourceMappingURL=problem-details.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"problem-details.js","sourceRoot":"","sources":["../src/problem-details.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAE/D,MAAM,SAAS,GAAG,sDAAsD,CAAA;AACxE,MAAM,iBAAiB,GAAG,EAAE,GAAG,IAAI,CAAA;AAEnC,MAAM,aAAa,GAAqC;IACtD,GAAG,EAAE,aAAa;IAClB,GAAG,EAAE,cAAc;IACnB,GAAG,EAAE,WAAW;IAChB,GAAG,EAAE,WAAW;IAChB,GAAG,EAAE,oBAAoB;IACzB,GAAG,EAAE,gBAAgB;IACrB,GAAG,EAAE,iBAAiB;IACtB,GAAG,EAAE,UAAU;IACf,GAAG,EAAE,MAAM;IACX,GAAG,EAAE,iBAAiB;IACtB,GAAG,EAAE,qBAAqB;IAC1B,GAAG,EAAE,mBAAmB;IACxB,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,uBAAuB;IAC5B,GAAG,EAAE,mBAAmB;IACxB,GAAG,EAAE,uBAAuB;IAC5B,GAAG,EAAE,iBAAiB;IACtB,GAAG,EAAE,aAAa;IAClB,GAAG,EAAE,qBAAqB;IAC1B,GAAG,EAAE,iBAAiB;IACtB,GAAG,EAAE,sBAAsB;CAC5B,CAAA;AAgBD,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,CAAA;AACpD,CAAC;AAED,SAAS,YAAY,CACnB,IAAyB,EACzB,WAA0B,EAC1B,QAAgB;IAEhB,IAAI,WAAW,KAAK,IAAI,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC;QAAE,OAAO,SAAS,CAAA;IAC1E,MAAM,KAAK,GAAG,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IAC9E,IAAI,KAAK,CAAC,UAAU,GAAG,QAAQ;QAAE,OAAO,SAAS,CAAA;IACjD,IAAI,MAAe,CAAA;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAA;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;IACD,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,EAAE,KAAK,KAAK,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,EAAE,CAAC;QACjF,OAAO,SAAS,CAAA;IAClB,CAAC;IACD,OAAO;QACL,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACtE,CAAA;AACH,CAAC;AAED,KAAK,UAAU,cAAc,CAC3B,IAAgC,EAChC,QAAgB;IAEhB,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAA;IAC/B,MAAM,MAAM,GAAiB,EAAE,CAAA;IAC/B,IAAI,IAAI,GAAG,CAAC,CAAA;IAEZ,IAAI,CAAC;QACH,OAAO,IAAI,EAAE,CAAC;YACZ,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAA;YAClC,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;gBAChB,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,IAAI,CAAC,CAAA;gBAClC,IAAI,MAAM,GAAG,CAAC,CAAA;gBACd,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;oBAC3B,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;oBACxB,MAAM,IAAI,KAAK,CAAC,UAAU,CAAA;gBAC5B,CAAC;gBACD,OAAO,KAAK,CAAA;YACd,CAAC;YAED,IAAI,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,UAAU,GAAG,QAAQ,EAAE,CAAC;gBAC9C,KAAK,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;gBACpC,OAAO,SAAS,CAAA;YAClB,CAAC;YAED,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;YACzB,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,UAAU,CAAA;QACjC,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,KAAK,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;QACpC,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC;AAED,SAAS,WAAW,CAAC,QAA4B,EAAE,IAAY;IAC7D,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,aAAa,CAAA;IAChD,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAA;AACtE,CAAC;AAED,SAAS,eAAe,CACtB,MAAc,EACd,QAAuB,EACvB,QAA4B,EAC5B,QAA4B;IAE5B,MAAM,QAAQ,GAA4B;QACxC,IAAI,EAAE,WAAW,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,CAAC;QAC3C,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,IAAI,YAAY;QAC5C,MAAM;QACN,IAAI,EAAE,QAAQ,CAAC,KAAK;KACrB,CAAA;IACD,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS;QAAE,QAAQ,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAA;IACpE,IAAI,QAAQ,KAAK,SAAS;QAAE,QAAQ,CAAC,QAAQ,GAAG,QAAQ,CAAA;IACxD,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,SAAS,uBAAuB,CAAC,WAA0B;IACzD,OAAO,WAAW,EAAE,WAAW,EAAE,CAAC,UAAU,CAAC,0BAA0B,CAAC,KAAK,IAAI,CAAA;AACnF,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,UAAiC,EAAE;IAChE,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,iBAAiB,CAAA;IACtD,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;QACpD,MAAM,IAAI,KAAK,CAAC,8DAA8D,CAAC,CAAA;IACjF,CAAC;IACD,MAAM,QAAQ,GAAG,OAAO,CAAC,eAAe,KAAK,IAAI,CAAA;IACjD,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAA;IAEjC,OAAO,YAAY,CAAC,gBAAgB,EAAE,CAAC,GAAG,EAAE,EAAE,CAC5C,GAAG,CAAC,GAAG,CAAC;QACN,cAAc,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM;YACvC,IACE,MAAM,GAAG,GAAG;gBACZ,MAAM,IAAI,GAAG;gBACb,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC;gBAC/B,uBAAuB,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,EACpD,CAAC;gBACD,OAAO,SAAS,CAAA;YAClB,CAAC;YACD,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,EAAE,QAAQ,CAAC,CAAA;YAC1E,IAAI,QAAQ,KAAK,SAAS;gBAAE,OAAO,SAAS,CAAA;YAC5C,MAAM,QAAQ,GAAG,eAAe,CAC9B,MAAM,EACN,QAAQ,EACR,QAAQ,EACR,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAC3C,CAAA;YACD,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,0BAA0B,CAAC,CAAA;YACvD,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAA;YAChC,OAAO,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAA;QACjC,CAAC;QACD,KAAK,CAAC,aAAa,CAAC,QAAQ,EAAE,GAAG;YAC/B,IACE,QAAQ,CAAC,MAAM,GAAG,GAAG;gBACrB,QAAQ,CAAC,MAAM,IAAI,GAAG;gBACtB,QAAQ,CAAC,IAAI,KAAK,IAAI;gBACtB,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC;gBACxC,uBAAuB,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,EAC7D,CAAC;gBACD,OAAO,QAAQ,CAAA;YACjB,CAAC;YACD,IAAI,KAA6B,CAAA;YACjC,IAAI,CAAC;gBACH,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,EAAE,CAAA;gBAC9B,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI;oBAAE,OAAO,QAAQ,CAAA;gBACxC,KAAK,GAAG,MAAM,cAAc,CAAC,KAAK,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;YACpD,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,QAAQ,CAAA;YACjB,CAAC;YACD,IAAI,KAAK,KAAK,SAAS;gBAAE,OAAO,QAAQ,CAAA;YACxC,MAAM,QAAQ,GAAG,YAAY,CAAC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,EAAE,QAAQ,CAAC,CAAA;YACpF,IAAI,QAAQ,KAAK,SAAS;gBAAE,OAAO,QAAQ,CAAA;YAC3C,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAA;YAC7C,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,0BAA0B,CAAC,CAAA;YACvD,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAA;YAChC,OAAO,IAAI,QAAQ,CACjB,IAAI,CAAC,SAAS,CACZ,eAAe,CACb,QAAQ,CAAC,MAAM,EACf,QAAQ,EACR,QAAQ,EACR,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAC3C,CACF,EACD,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,QAAQ,CAAC,UAAU,EAAE,OAAO,EAAE,CACtE,CAAA;QACH,CAAC;KACF,CAAC,CACH,CAAA;AACH,CAAC"}
@@ -0,0 +1,19 @@
1
+ import { type ByteRange, type ByteRangeResult, parseByteRange } from "@nifrajs/core/range";
2
+ export { type ByteRange, type ByteRangeResult, parseByteRange };
3
+ export interface RangeResponseOptions {
4
+ /** Media type for the complete representation and each single-range response. */
5
+ readonly contentType?: string;
6
+ /** Entity tag used for conditional requests and `If-Range`. */
7
+ readonly etag?: string;
8
+ /** Validator used for conditional requests and date-form `If-Range`. */
9
+ readonly lastModified?: Date;
10
+ /** Optional cache policy copied to the response. */
11
+ readonly cacheControl?: string;
12
+ }
13
+ /**
14
+ * Create a standards-shaped byte representation response with Range, If-Range, and conditional
15
+ * validator handling. The input is already caller-owned memory; this helper does not read or retain
16
+ * external state. Multiple ranges use `multipart/byteranges` and adjacent ranges are coalesced.
17
+ */
18
+ export declare function rangeResponse(request: Request, body: string | ArrayBuffer | ArrayBufferView | Uint8Array, options?: RangeResponseOptions): Response;
19
+ //# sourceMappingURL=range.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"range.d.ts","sourceRoot":"","sources":["../src/range.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAK1F,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,cAAc,EAAE,CAAA;AAE/D,MAAM,WAAW,oBAAoB;IACnC,iFAAiF;IACjF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,wEAAwE;IACxE,QAAQ,CAAC,YAAY,CAAC,EAAE,IAAI,CAAA;IAC5B,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAC/B;AA+GD;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,eAAe,GAAG,UAAU,EACzD,OAAO,GAAE,oBAAyB,GACjC,QAAQ,CAuCV"}
package/dist/range.js ADDED
@@ -0,0 +1,144 @@
1
+ import { parseByteRange } from "@nifrajs/core/range";
2
+ // The parser lives in core because `@nifrajs/web`'s `public/` handler needs the identical answer for
3
+ // files on disk, and web cannot depend on this package. Re-exported here so the middleware surface
4
+ // stays one import.
5
+ export { parseByteRange };
6
+ const TEXT = new TextEncoder();
7
+ function bytesOf(body) {
8
+ if (typeof body === "string")
9
+ return TEXT.encode(body);
10
+ if (body instanceof Uint8Array)
11
+ return body;
12
+ if (body instanceof ArrayBuffer)
13
+ return new Uint8Array(body);
14
+ return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
15
+ }
16
+ function httpDate(date) {
17
+ const time = date.getTime();
18
+ if (!Number.isFinite(time))
19
+ throw new RangeError("rangeResponse: lastModified must be a valid Date");
20
+ return new Date(time).toUTCString();
21
+ }
22
+ function validators(options) {
23
+ const headers = new Headers();
24
+ if (options.contentType !== undefined)
25
+ headers.set("content-type", options.contentType);
26
+ if (options.etag !== undefined)
27
+ headers.set("etag", options.etag);
28
+ if (options.lastModified !== undefined)
29
+ headers.set("last-modified", httpDate(options.lastModified));
30
+ if (options.cacheControl !== undefined)
31
+ headers.set("cache-control", options.cacheControl);
32
+ return headers;
33
+ }
34
+ function emptyResponse(status, headers) {
35
+ headers.delete("content-length");
36
+ headers.delete("content-type");
37
+ return new Response(null, { status, headers });
38
+ }
39
+ function matchesIfNoneMatch(value, etag) {
40
+ const normalized = etag.startsWith("W/") ? etag.slice(2) : etag;
41
+ return value.split(",").some((candidate) => {
42
+ const item = candidate.trim();
43
+ if (item === "*")
44
+ return true;
45
+ const comparable = item.startsWith("W/") ? item.slice(2) : item;
46
+ return comparable === normalized;
47
+ });
48
+ }
49
+ function ifRangeMatches(value, options) {
50
+ if (value === null)
51
+ return true;
52
+ const item = value.trim();
53
+ if (options.etag !== undefined)
54
+ return !item.startsWith("W/") && item === options.etag;
55
+ if (options.lastModified === undefined)
56
+ return false;
57
+ const time = Date.parse(item);
58
+ return (Number.isFinite(time) &&
59
+ Math.floor(time / 1000) >= Math.floor(options.lastModified.getTime() / 1000));
60
+ }
61
+ function shouldReturnNotModified(request, options) {
62
+ if (request.method !== "GET" && request.method !== "HEAD")
63
+ return false;
64
+ const ifNoneMatch = request.headers.get("if-none-match");
65
+ // RFC conditional precedence: an If-None-Match header suppresses date evaluation even when this
66
+ // representation has no ETag to compare. A non-matching tag must not be upgraded to a 304 by an
67
+ // older If-Modified-Since value.
68
+ if (ifNoneMatch !== null) {
69
+ return options.etag !== undefined && matchesIfNoneMatch(ifNoneMatch, options.etag);
70
+ }
71
+ // If-Modified-Since remains a valid fallback when the request did not send If-None-Match.
72
+ const lastModified = options.lastModified?.getTime();
73
+ const ifModifiedSince = request.headers.get("if-modified-since");
74
+ return (lastModified !== undefined &&
75
+ Number.isFinite(lastModified) &&
76
+ ifModifiedSince !== null &&
77
+ Number.isFinite(Date.parse(ifModifiedSince)) &&
78
+ Math.floor(Date.parse(ifModifiedSince) / 1000) >= Math.floor(lastModified / 1000));
79
+ }
80
+ function responseBody(bytes, request) {
81
+ return request.method === "HEAD" ? null : bytes;
82
+ }
83
+ function multipartBytes(bytes, ranges, size, contentType, boundary) {
84
+ const chunks = [];
85
+ let total = 0;
86
+ const add = (chunk) => {
87
+ chunks.push(chunk);
88
+ total += chunk.byteLength;
89
+ };
90
+ for (const range of ranges) {
91
+ add(TEXT.encode(`--${boundary}\r\n`));
92
+ add(TEXT.encode(`Content-Range: bytes ${range.start}-${range.end}/${size}\r\n`));
93
+ add(TEXT.encode(`Content-Type: ${contentType}\r\n\r\n`));
94
+ add(bytes.subarray(range.start, range.end + 1));
95
+ add(TEXT.encode("\r\n"));
96
+ }
97
+ add(TEXT.encode(`--${boundary}--\r\n`));
98
+ const body = new Uint8Array(total);
99
+ let offset = 0;
100
+ for (const chunk of chunks) {
101
+ body.set(chunk, offset);
102
+ offset += chunk.byteLength;
103
+ }
104
+ return body;
105
+ }
106
+ /**
107
+ * Create a standards-shaped byte representation response with Range, If-Range, and conditional
108
+ * validator handling. The input is already caller-owned memory; this helper does not read or retain
109
+ * external state. Multiple ranges use `multipart/byteranges` and adjacent ranges are coalesced.
110
+ */
111
+ export function rangeResponse(request, body, options = {}) {
112
+ const bytes = bytesOf(body);
113
+ const headers = validators(options);
114
+ headers.set("accept-ranges", "bytes");
115
+ if (shouldReturnNotModified(request, options))
116
+ return emptyResponse(304, headers);
117
+ const requestedRange = request.method === "GET" || request.method === "HEAD" ? request.headers.get("range") : null;
118
+ const range = requestedRange !== null && ifRangeMatches(request.headers.get("if-range"), options)
119
+ ? parseByteRange(requestedRange, bytes.byteLength)
120
+ : { kind: "none" };
121
+ if (range.kind === "unsatisfiable") {
122
+ headers.set("content-range", `bytes */${bytes.byteLength}`);
123
+ headers.set("content-length", "0");
124
+ return new Response(null, { status: 416, headers });
125
+ }
126
+ if (range.kind === "none") {
127
+ headers.set("content-length", String(bytes.byteLength));
128
+ return new Response(responseBody(bytes, request), { status: 200, headers });
129
+ }
130
+ const contentType = options.contentType ?? "application/octet-stream";
131
+ if (range.ranges.length === 1) {
132
+ const selected = range.ranges[0];
133
+ const selectedBytes = bytes.subarray(selected.start, selected.end + 1);
134
+ headers.set("content-range", `bytes ${selected.start}-${selected.end}/${bytes.byteLength}`);
135
+ headers.set("content-length", String(selectedBytes.byteLength));
136
+ return new Response(responseBody(selectedBytes, request), { status: 206, headers });
137
+ }
138
+ const boundary = `nifra-${crypto.randomUUID().replaceAll("-", "")}`;
139
+ const selectedBytes = multipartBytes(bytes, range.ranges, bytes.byteLength, contentType, boundary);
140
+ headers.set("content-type", `multipart/byteranges; boundary=${boundary}`);
141
+ headers.set("content-length", String(selectedBytes.byteLength));
142
+ return new Response(responseBody(selectedBytes, request), { status: 206, headers });
143
+ }
144
+ //# sourceMappingURL=range.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"range.js","sourceRoot":"","sources":["../src/range.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/middleware",
3
- "version": "2.9.1",
3
+ "version": "2.11.0",
4
4
  "description": "Composable middleware for nifra - auth, CSRF, JWT/JWKS, IP restriction, CORS, body limits, response cache, timing, and operational helpers.",
5
5
  "keywords": [
6
6
  "bun",
@@ -46,10 +46,10 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@nifrajs/schema": "2.9.1"
49
+ "@nifrajs/schema": "2.11.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@nifrajs/core": "^2.9.1"
52
+ "@nifrajs/core": "^2.11.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@nifrajs/core": "workspace:*",
@@ -0,0 +1,70 @@
1
+ import { withHeaders } from "./_utils.ts"
2
+
3
+ export interface ConditionalResponseOptions {
4
+ /** Entity tag to attach and compare using HTTP weak comparison for GET/HEAD. */
5
+ readonly etag?: string
6
+ /** Last-modified validator to attach and compare at one-second HTTP date precision. */
7
+ readonly lastModified?: Date
8
+ }
9
+
10
+ function lastModifiedHeader(date: Date | undefined): string | undefined {
11
+ if (date === undefined) return undefined
12
+ if (!Number.isFinite(date.getTime())) {
13
+ throw new RangeError("conditionalResponse: lastModified must be a valid Date")
14
+ }
15
+ return date.toUTCString()
16
+ }
17
+
18
+ function weakTag(value: string): string {
19
+ return value.startsWith("W/") ? value.slice(2) : value
20
+ }
21
+
22
+ function matchesIfNoneMatch(value: string, etag: string): boolean {
23
+ const comparable = weakTag(etag)
24
+ return value.split(",").some((candidate) => {
25
+ const item = candidate.trim()
26
+ return item === "*" || weakTag(item) === comparable
27
+ })
28
+ }
29
+
30
+ function isFresh(request: Request, options: ConditionalResponseOptions): boolean {
31
+ if (request.method !== "GET" && request.method !== "HEAD") return false
32
+ const ifNoneMatch = request.headers.get("if-none-match")
33
+ if (ifNoneMatch !== null && options.etag !== undefined) {
34
+ return matchesIfNoneMatch(ifNoneMatch, options.etag)
35
+ }
36
+ if (ifNoneMatch !== null) return false
37
+ const modified = options.lastModified?.getTime()
38
+ const since = request.headers.get("if-modified-since")
39
+ return (
40
+ modified !== undefined &&
41
+ since !== null &&
42
+ Number.isFinite(modified) &&
43
+ Number.isFinite(Date.parse(since)) &&
44
+ Math.floor(Date.parse(since) / 1000) >= Math.floor(modified / 1000)
45
+ )
46
+ }
47
+
48
+ /** Apply validators to a response and produce a standards-compliant 304 when it is fresh. */
49
+ export function conditionalResponse(
50
+ request: Request,
51
+ response: Response,
52
+ options: ConditionalResponseOptions = {},
53
+ ): Response {
54
+ const modified = lastModifiedHeader(options.lastModified)
55
+ if (response.status < 200 || response.status >= 300) return response
56
+ if (!isFresh(request, options)) {
57
+ if (options.etag === undefined && modified === undefined) return response
58
+ return withHeaders(response, (headers) => {
59
+ if (options.etag !== undefined) headers.set("etag", options.etag)
60
+ if (modified !== undefined) headers.set("last-modified", modified)
61
+ })
62
+ }
63
+ const headers = new Headers(response.headers)
64
+ if (options.etag !== undefined) headers.set("etag", options.etag)
65
+ if (modified !== undefined) headers.set("last-modified", modified)
66
+ headers.delete("content-length")
67
+ headers.delete("content-type")
68
+ void response.body?.cancel().catch(() => {})
69
+ return new Response(null, { status: 304, headers })
70
+ }
@@ -0,0 +1,139 @@
1
+ export interface ContentPreference {
2
+ readonly type: string
3
+ readonly subtype: string
4
+ readonly q: number
5
+ readonly specificity: 0 | 1 | 2
6
+ }
7
+
8
+ interface ParsedMediaType extends ContentPreference {
9
+ readonly parameters: ReadonlyMap<string, string>
10
+ }
11
+
12
+ const MAX_ACCEPT_HEADER_LENGTH = 16 * 1024
13
+ const MAX_MEDIA_RANGES = 128
14
+
15
+ function splitHeader(value: string): string[] | undefined {
16
+ if (value.length > MAX_ACCEPT_HEADER_LENGTH) return undefined
17
+ const parts: string[] = []
18
+ let start = 0
19
+ let quoted = false
20
+ for (let index = 0; index < value.length; index++) {
21
+ const character = value[index]
22
+ if (character === '"' && value[index - 1] !== "\\") quoted = !quoted
23
+ if (character === "," && !quoted) {
24
+ parts.push(value.slice(start, index))
25
+ if (parts.length >= MAX_MEDIA_RANGES) return undefined
26
+ start = index + 1
27
+ }
28
+ }
29
+ parts.push(value.slice(start))
30
+ return parts
31
+ }
32
+
33
+ function parseMediaType(value: string, qDefault = 1): ParsedMediaType | undefined {
34
+ const segments = value.split(";")
35
+ const media = segments.shift()?.trim().toLowerCase() ?? ""
36
+ const slash = media.indexOf("/")
37
+ if (slash <= 0 || slash === media.length - 1) return undefined
38
+ const type = media.slice(0, slash)
39
+ const subtype = media.slice(slash + 1)
40
+ if (!/^[\w!#$&^_.+-]+$/.test(type) || !/^[\w!#$&^_.+*-]+$/.test(subtype)) return undefined
41
+
42
+ let q = qDefault
43
+ const parameters = new Map<string, string>()
44
+ let afterQuality = false
45
+ for (const segment of segments) {
46
+ const separator = segment.indexOf("=")
47
+ if (separator < 1) continue
48
+ const key = segment.slice(0, separator).trim().toLowerCase()
49
+ const raw = segment.slice(separator + 1).trim()
50
+ const parameter = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw
51
+ if (key === "q") {
52
+ if (afterQuality) continue
53
+ const parsed = Number(parameter)
54
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) return undefined
55
+ q = parsed
56
+ afterQuality = true
57
+ } else if (!afterQuality) {
58
+ parameters.set(key, parameter.toLowerCase())
59
+ }
60
+ }
61
+ const specificity: 0 | 1 | 2 = type === "*" ? 0 : subtype === "*" ? 1 : 2
62
+ return { type, subtype, q, specificity, parameters }
63
+ }
64
+
65
+ /** Parse an `Accept` header, preserving quality and wildcard specificity. */
66
+ export function parseAcceptHeader(value: string | null): readonly ContentPreference[] {
67
+ if (value === null || value.trim() === "") return []
68
+ const preferences: ContentPreference[] = []
69
+ for (const item of splitHeader(value) ?? []) {
70
+ const parsed = parseMediaType(item)
71
+ if (parsed !== undefined) preferences.push(parsed)
72
+ }
73
+ return preferences
74
+ }
75
+
76
+ function parsedAcceptHeader(value: string): readonly ParsedMediaType[] {
77
+ const preferences: ParsedMediaType[] = []
78
+ for (const item of splitHeader(value) ?? []) {
79
+ const parsed = parseMediaType(item)
80
+ if (parsed !== undefined) preferences.push(parsed)
81
+ }
82
+ return preferences
83
+ }
84
+
85
+ function offeredMatches(offer: ParsedMediaType, preference: ParsedMediaType): boolean {
86
+ if (preference.type !== "*" && preference.type !== offer.type) return false
87
+ if (preference.subtype !== "*" && preference.subtype !== offer.subtype) return false
88
+ for (const [key, value] of preference.parameters) {
89
+ if (offer.parameters.get(key) !== value) return false
90
+ }
91
+ return true
92
+ }
93
+
94
+ function acceptHeaderOf(value: string | null | Headers | Request): string | null {
95
+ if (typeof value === "string" || value === null) return value
96
+ return value instanceof Request ? value.headers.get("accept") : value.get("accept")
97
+ }
98
+
99
+ /**
100
+ * Select the best offered media type for an `Accept` header. The returned string is the original
101
+ * offered value, so parameters such as `profile` are preserved. An absent/empty header accepts the
102
+ * first offer; an explicit `q=0` or no matching offer returns `undefined`.
103
+ */
104
+ export function negotiateContentType(
105
+ accept: string | null | Headers | Request,
106
+ offered: readonly string[],
107
+ ): string | undefined {
108
+ if (offered.length === 0) return undefined
109
+ const header = acceptHeaderOf(accept)
110
+ if (header === null || header.trim() === "") return offered[0]
111
+ const preferences = parsedAcceptHeader(header)
112
+ let selected:
113
+ | {
114
+ readonly value: string
115
+ readonly q: number
116
+ readonly specificity: number
117
+ readonly index: number
118
+ }
119
+ | undefined
120
+
121
+ offered.forEach((value, index) => {
122
+ const parsed = parseMediaType(value, 0)
123
+ if (parsed === undefined) return
124
+ let best: ParsedMediaType | undefined
125
+ for (const preference of preferences) {
126
+ if (!offeredMatches(parsed, preference)) continue
127
+ if (best === undefined || preference.specificity > best.specificity) best = preference
128
+ }
129
+ if (best === undefined || best.q === 0) return
130
+ if (
131
+ selected === undefined ||
132
+ best.q > selected.q ||
133
+ (best.q === selected.q && best.specificity > selected.specificity)
134
+ ) {
135
+ selected = { value, q: best.q, specificity: best.specificity, index }
136
+ }
137
+ })
138
+ return selected?.value
139
+ }
package/src/index.ts CHANGED
@@ -37,6 +37,15 @@ export {
37
37
  export { type CacheControlOptions, cacheControl } from "./cache-control.ts"
38
38
  export { type Composable, combine, namedCombine } from "./combine.ts"
39
39
  export { type CompressionOptions, compression } from "./compression.ts"
40
+ export {
41
+ type ConditionalResponseOptions,
42
+ conditionalResponse,
43
+ } from "./conditional.ts"
44
+ export {
45
+ type ContentPreference,
46
+ negotiateContentType,
47
+ parseAcceptHeader,
48
+ } from "./content-negotiation.ts"
40
49
  export { type CorsOptions, cors } from "./cors.ts"
41
50
  export {
42
51
  type CsrfOptions,
@@ -84,6 +93,13 @@ export {
84
93
  } from "./language.ts"
85
94
  export { type LoggerOptions, logger, type RequestLogFields } from "./logger.ts"
86
95
  export { type MethodOverrideOptions, methodOverride } from "./method-override.ts"
96
+ export {
97
+ type MultipartBody,
98
+ type MultipartHeadersInit,
99
+ type MultipartPart,
100
+ type MultipartResponseOptions,
101
+ multipartResponse,
102
+ } from "./multipart.ts"
87
103
  export {
88
104
  buildOpenApiDocument,
89
105
  type OpenApiInfo,
@@ -97,6 +113,14 @@ export {
97
113
  } from "./openapi.ts"
98
114
  export { type PoweredByOptions, poweredBy } from "./powered-by.ts"
99
115
  export { type PrettyJsonOptions, prettyJson } from "./pretty-json.ts"
116
+ export { type ProblemDetailsOptions, problemDetails } from "./problem-details.ts"
117
+ export {
118
+ type ByteRange,
119
+ type ByteRangeResult,
120
+ parseByteRange,
121
+ type RangeResponseOptions,
122
+ rangeResponse,
123
+ } from "./range.ts"
100
124
  export {
101
125
  MemoryStore,
102
126
  type MemoryStoreOptions,