@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.
- package/README.md +10 -0
- package/dist/conditional.d.ts +9 -0
- package/dist/conditional.d.ts.map +1 -0
- package/dist/conditional.js +62 -0
- package/dist/conditional.js.map +1 -0
- package/dist/content-negotiation.d.ts +15 -0
- package/dist/content-negotiation.d.ts.map +1 -0
- package/dist/content-negotiation.js +130 -0
- package/dist/content-negotiation.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/multipart.d.ts +20 -0
- package/dist/multipart.d.ts.map +1 -0
- package/dist/multipart.js +132 -0
- package/dist/multipart.js.map +1 -0
- package/dist/problem-details.d.ts +17 -0
- package/dist/problem-details.d.ts.map +1 -0
- package/dist/problem-details.js +161 -0
- package/dist/problem-details.js.map +1 -0
- package/dist/range.d.ts +19 -0
- package/dist/range.d.ts.map +1 -0
- package/dist/range.js +144 -0
- package/dist/range.js.map +1 -0
- package/package.json +3 -3
- package/src/conditional.ts +70 -0
- package/src/content-negotiation.ts +139 -0
- package/src/index.ts +24 -0
- package/src/multipart.ts +166 -0
- package/src/problem-details.ts +208 -0
- package/src/range.ts +176 -0
|
@@ -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":"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"}
|
package/dist/range.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
49
|
+
"@nifrajs/schema": "2.11.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@nifrajs/core": "^2.
|
|
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,
|