diff --git a/.browserslistrc b/.browserslistrc index 02e3ec25..a694b735 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -17,7 +17,7 @@ # Generated data. # -# Last generated Mar 13, 2024 5:25 AM UTC. +# Last generated Mar 13, 2024 6:30 AM UTC. [production] node >= 20.9.0 diff --git a/.dockerignore b/.dockerignore index 64185116..c25dcc77 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,7 @@ # Generated data. # -# Last generated Mar 13, 2024 5:25 AM UTC. +# Last generated Mar 13, 2024 6:30 AM UTC. # Locals diff --git a/.env.vault b/.env.vault index c9887a7c..174d682d 100644 --- a/.env.vault +++ b/.env.vault @@ -8,12 +8,12 @@ DOTENV_VAULT_MAIN="7GW98NfL4hM4N6HbfeDXCKreVxKvcVPupFRnZ8XJ+pomaQ==" DOTENV_VAULT_MAIN_VERSION=1 # dev -DOTENV_VAULT_DEV="QxrRMZKwIbrxaVZrT8ajFeh9nSYkvqQZIFidmlXD75D54Uf+DyjABRKYPg7Gge7bUj/5H1SIQmJwSFon6PIenuWGaxLQUxOaPlgvUruNI3/m4mBTiquip+oKA+UPANYNKH04TDekFCrvlAF3W3ZpNGrvpSYk0I/QTgpkhYXJJrKBSb/VgQ/4+hrCI7uVl9XpDldQbDAUspjg+REwl10Rh1j95g7mpc5NcDTq/6xi2Ra1EN3uEpMkvBDZ2vsVX4FPTA==" -DOTENV_VAULT_DEV_VERSION=441 +DOTENV_VAULT_DEV="co1WbpptWGjjMUlHU8AIk0riWlACbPyd/aWAuGXsKTEEeqRbmtbgn0vAgJOir5ExNeXObMGiWZjP76l2ex08FTjgT/KLwzHYEby7SpUPFgMztxI1Wc6tjMsxcgSIzNYFUB5pj2OZ9wjZ3qP7xa4LzWMGgozhwpUHgX+B+BR11rBo6lLepPxA/F5Z9zCfGkMV3UXixJh2Ut9PKY+93t4kfypFKd1yiZPQUg9foQyIRRVpDWNOl9TJaIi+CqOxlL2abg==" +DOTENV_VAULT_DEV_VERSION=443 # ci -DOTENV_VAULT_CI="TFEhxUzzCRY3p6vnmIyNB/6C4Ztww1ifnh5buWck8iPK51FUkGhcTboUitOeiKoDv48iLGkWVL7/NdCxtsDoxmK27UUq8gPPCIvTAsLW7wUhM5gAiuSFhWjd9aQop33raKbfBG0TliRY2TCWLFMX1o3ZURwkj332ib7EWMuw8g==" -DOTENV_VAULT_CI_VERSION=441 +DOTENV_VAULT_CI="YjBZQDF1ELn3MYQ/xMi4PtzUf8OKy3RFJrnnwmVqpSvitKqdmh5LgA3JbkkogkqbZ69jvQGHz1usnu9CbrM8ak9nEQaATrhlnBVohDbVMb+Wf+opLZ/GDEQhTVrx7GvbQujGTvoQ6xWIcvqCl2WxPLbNN1qK37U0jOYNVLDIEA==" +DOTENV_VAULT_CI_VERSION=443 # stage DOTENV_VAULT_STAGE="aRP8su2YV4jZu3w1HZ/SLaots0IwJDFw75TCpvXEFeNp7tw=" diff --git a/.gitattributes b/.gitattributes index fb0af9af..0f07bcb1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -17,7 +17,7 @@ # Generated data. # -# Last generated Mar 13, 2024 5:25 AM UTC. +# Last generated Mar 13, 2024 6:30 AM UTC. # Default diff --git a/.gitignore b/.gitignore index d87207e6..5e971116 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ # Generated data. # -# Last generated Mar 13, 2024 5:25 AM UTC. +# Last generated Mar 13, 2024 6:30 AM UTC. # Locals diff --git a/.npmignore b/.npmignore index 9ace5e00..804b7585 100644 --- a/.npmignore +++ b/.npmignore @@ -25,7 +25,7 @@ # Generated data. # -# Last generated Mar 13, 2024 5:25 AM UTC. +# Last generated Mar 13, 2024 6:30 AM UTC. # Locals diff --git a/.prettierignore b/.prettierignore index cd4d8e10..a04dfb4d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,7 +17,7 @@ # Generated data. # -# Last generated Mar 13, 2024 5:25 AM UTC. +# Last generated Mar 13, 2024 6:30 AM UTC. # Packages diff --git a/.vscode/settings.json b/.vscode/settings.json index 7d0963d4..aaa53ec1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,7 @@ * @note This entire file will be updated automatically. * @note Instead of editing here, please review `./settings.mjs`. * - * Last generated using `./settings.mjs` Mar 13, 2024 5:25 AM UTC. + * Last generated using `./settings.mjs` Mar 13, 2024 6:30 AM UTC. */ { "editor.formatOnType": false, diff --git a/.vscodeignore b/.vscodeignore index d7215198..b8956d03 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -17,7 +17,7 @@ # Generated data. # -# Last generated Mar 13, 2024 5:25 AM UTC. +# Last generated Mar 13, 2024 6:30 AM UTC. # Locals diff --git a/package-lock.json b/package-lock.json index 9f901cab..8cb0b75a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clevercanyon/utilities", - "version": "1.0.906", + "version": "1.0.907", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clevercanyon/utilities", - "version": "1.0.906", + "version": "1.0.907", "cpu": [ "x64", "arm64" @@ -1026,9 +1026,9 @@ } }, "node_modules/@clevercanyon/utilities": { - "version": "1.0.906", - "resolved": "https://registry.npmjs.org/@clevercanyon/utilities/-/utilities-1.0.906.tgz", - "integrity": "sha512-4eA++pIxJi1R6KIkcELSUYKSJWyPvNKZsrFNSF3YZ/iQTwClvIfA9fY+WvSx3kUcu89J3wQyS4pzFRRCJqucfQ==", + "version": "1.0.907", + "resolved": "https://registry.npmjs.org/@clevercanyon/utilities/-/utilities-1.0.907.tgz", + "integrity": "sha512-YRwXGxtmoTqxY4+MBr4cuJ36gbSyXVbr1GhZ/+23ZuHjYTqgWj1aXLBw3ZUErZKGPZdN/JpiReouyLizy90YHg==", "cpu": [ "x64", "arm64" @@ -3623,9 +3623,9 @@ } }, "node_modules/@mdn/browser-compat-data": { - "version": "5.5.14", - "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.5.14.tgz", - "integrity": "sha512-K7e35i4XtNWpiOr+aPiy3UccAhFop0HsfVz9RSzlcgaaHb2aD/nN0J3uPPLedyTokMiebxN0gxkL/WXpzNQuKg==", + "version": "5.5.15", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.5.15.tgz", + "integrity": "sha512-BWm+TMK60HSepXOZcu39bDs/2sJZVetHO5w0mkuxhpkZvz0G5yGAoyimfaru8g5nK6LXXUIeX6Uk/SWzOfph3g==", "dev": true }, "node_modules/@mdx-js/esbuild": { diff --git a/package.json b/package.json index db32f4b5..b6eb64c4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.0.907", + "version": "1.0.908", "license": "GPL-3.0-or-later", "name": "@clevercanyon/utilities", "description": "Utilities for JavaScript apps running in any environment.", diff --git a/src/http.ts b/src/http.ts index 5708c4a3..6bfc99c1 100644 --- a/src/http.ts +++ b/src/http.ts @@ -44,6 +44,9 @@ export type SecurityHeaderOptions = { enableCORs?: boolean; }; +// --- +// Route utilities. + /** * HTTP route config. * @@ -61,6 +64,9 @@ export const routeConfig = (config?: RouteConfig): Required => { }) as Required; }; +// --- +// Request utilities. + /** * HTTP request config. * @@ -76,33 +82,6 @@ export const requestConfig = async (config?: RequestConfig): Promise; }; -/** - * HTTP response config. - * - * @param config Optional config options. - * - * @returns HTTP response config promise. - */ -export const responseConfig = async (config?: ResponseConfig): Promise> => { - return $obj.defaults({}, config || {}, { - status: 405, - - enableCORs: false, - varyOn: [], - cacheVersion: '', - - maxAge: null, - sMaxAge: null, - staleAge: null, - - headers: {}, - appendHeaders: {}, - - body: null, - encodeBody: null, - }) as Required; -}; - /** * Prepares an HTTP request. * @@ -164,134 +143,437 @@ export const prepareRequest = async (request: $type.Request, config?: RequestCon }; /** - * Prepares an HTTP response. + * Request has a supported method? * * @param request HTTP request. - * @param config Optional; {@see ResponseConfig}. * - * @returns Mutatable HTTP response promise. + * @returns True if request has a supported method. */ -export const prepareResponse = async (request: $type.Request, config?: ResponseConfig): Promise<$type.Response> => { - const cfg = await responseConfig(config), - url = $url.tryParse(request.url); +export const requestHasSupportedMethod = (request: $type.Request): boolean => { + return supportedRequestMethods().includes(request.method); +}; - /** - * This will also be the case when preparing a request, which triggers a response error whenever the URL is - * unparseable. In such a case, we return immediately with a 400 error status: Bad Request. - */ - if (!url /* Catches unparseable URLs. */) { - const url400 = new URL('https://0.0.0.0/'), - cfg400 = await responseConfig({ status: 400, body: responseStatusText(400) }), - needsContentBody = responseNeedsContentBody(request, cfg400.status, cfg400.body); +/** + * Request has a cacheable request method? + * + * @param request HTTP request. + * + * @returns True if request has a cacheable request method. + */ +export const requestHasCacheableMethod = $fnꓺmemo(2, (request: $type.Request): boolean => { + return requestHasSupportedMethod(request) && ['HEAD', 'GET'].includes(request.method); +}); - return new Response((needsContentBody ? cfg400.body : null) as BodyInit | null, { - status: cfg400.status, - statusText: responseStatusText(cfg400.status), - headers: await prepareResponseHeaders(request, url400, cfg400), - }); +/** + * Request is coming from an identified user? + * + * @param request HTTP request. + * + * @returns True if request is coming from an identified user. + */ +export const requestIsFromUser = $fnꓺmemo(2, (request: $type.Request): boolean => { + if (request.headers.has('authorization')) { + return true; // Authorization header. } - - /** - * This CORs approach makes implementation simpler, because we consolidate the handling of `OPTIONS` into the - * `enableCORs` flag, such that an explicit `OPTIONS` case handler does not need to be added by an implementation. - * The case of `405` (method now allowed; i.e., default status) is in conflict with CORs being enabled. Thus, the - * `OPTIONS` method is actually ok, because `enableCORs` was defined by the response config. - */ - if (cfg.enableCORs && 'OPTIONS' === request.method && (!cfg.status || 405 === cfg.status)) { - cfg.status = 204; // No content for CORs preflight requests. + if (!request.headers.has('cookie')) { + return false; // No cookies. } - if (!cfg.status /* Catches empty/invalid status. */) { - const cfg500 = await responseConfig({ status: 500, body: responseStatusText(500) }), - needsContentBody = responseNeedsContentBody(request, cfg500.status, cfg500.body); + const cookie = request.headers.get('cookie') || ''; // Contains encoded cookies. + return /(?:^\s*|;\s*)(?:ut[mx]_)?(?:author|user|customer)(?:[_-][^=;]+)?=\s*"?[^";]/iu.test(cookie); +}); - return new Response((needsContentBody ? cfg500.body : null) as BodyInit | null, { - status: cfg500.status, - statusText: responseStatusText(cfg500.status), - headers: await prepareResponseHeaders(request, url, cfg500), - }); +/** + * Request is coming from a specific via token? + * + * WARNING: We do not, by default, vary caches based on `x-via`. Therefore, _only_ use this when processing an + * uncacheable request type; e.g., `POST` request, or by explicitly declaring that a cache should vary on `x-via`. + * + * @param request HTTP request. + * @param via `x-via` token to consider. + * + * @returns True if request is coming from `x-via` token. + */ +export const requestIsVia = $fnꓺmemo(2, (request: $type.Request, via: string): boolean => { + if (!request.headers.has('x-via')) { + return false; // No `x-via` header. } + const header = request.headers.get('x-via') || ''; // Contains via tokens. + return $is.notEmpty(header) && new RegExp('(?:^|[,;])\\s*(?:' + $str.escRegExp(via) + ')\\s*(?:$|[,;])', 'ui').test(header); +}); - /** - * We only encode string body types at this time, and only gzip encoding is supported at this time. In a future - * version of this library we may decide to support other body types and/or other encoding formats. - * - * In general, we do not allow the body of HTML documents to be encoded, because doing so would hinder our ability - * to easily transform cacheable markup. The best approach for HTML is to utilize Cloudflare for on-the-fly - * encoding, because we generally don’t want an HTML response to be encoded at this layer. - */ - if (cfg.encodeBody && !$is.nul(cfg.body)) { - if (!$is.string(cfg.body)) throw Error('SHfNesyN'); - if ('gzip' !== cfg.encodeBody) throw Error('rU2H3CfG'); - if (contentIsHTML(cfg.headers)) throw Error('DVsRapCc'); - - const gzipBody = await $gzip.encode(cfg.body), // Compressed gzip byte array. - gzipContentLength = gzipBody.length; // Compressed content length in bytes. +/** + * Request expects a JSON response? + * + * WARNING: We do not vary caches based on `accept`. Therefore, you should _only_ use this when processing an + * uncacheable request type; e.g., `POST` request, or another that is uncacheable; {@see requestHasCacheableMethod()}. + * + * WARNING: This isn’t foolproof because it assumes anything of our own served from `/api` will expect JSON in the + * absense of an `accept` header. Therefore, only use in contexts where false-positives are not detrimental. + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request expects a JSON response. + */ +export const requestExpectsJSON = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + let url = _url || $url.parse(request.url); + url = $fn.try(() => $url.removeAppBasePath(url), url)(); + const acceptHeader = request.headers.get('accept') || ''; + return ( + (acceptHeader && /\b(?:application\/json)\b/iu.test(acceptHeader)) || // + (!acceptHeader && $env.isC10n() && '/' !== url.pathname && /^\/(?:api)(?:$|\/)/iu.test(url.pathname)) + ); +}); - if ($env.isCFW() /* {@see https://o5p.me/uXMnF4} */) { - const _ = globalThis as $type.Keyable, - FixedLengthStream = _.FixedLengthStream as typeof $type.cfw.FixedLengthStream, - { writable: writableStream, readable: readableStream } = new FixedLengthStream(gzipContentLength); +/** + * Request path is invalid? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request path is invalid. + */ +export const requestPathIsInvalid = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + const url = _url || $url.parse(request.url); + if ('/' === url.pathname) return false; - const writer = writableStream.getWriter(); - void writer.write(gzipBody), void writer.close(); + return /\\|\/{2,}|\.{2,}/iu.test(url.pathname); +}); - (readableStream as unknown as $type.Keyable)[$symbol.objReadableLength] = gzipContentLength; - cfg.body = readableStream; // Readable stream includes a length property. - } else { - const readableStream = new Blob([gzipBody]).stream(); - (readableStream as unknown as $type.Keyable)[$symbol.objReadableLength] = gzipContentLength; - cfg.body = readableStream; // Readable stream includes a length property. - } - } - /** - * A few points to remember: - * - * We want header preparation to consider a potentially encoded (e.g., gzipped) body content length. Therefore, it’s - * important that we prepare response headers after we have already prepared & potentially encoded body above. - * - * The inclusion of a body {@see responseNeedsContentBody()} should not be considered by header preparation. For - * example, a HEAD request should return all the same headers that a GET request does, which would include, for - * example, the `content-length` of a body, or the `content-length` of an encoded body. - */ - const headers = await prepareResponseHeaders(request, url, cfg), - needsContentBody = responseNeedsContentBody(request, cfg.status, cfg.body); +/** + * Request has an invalid app base URL origin? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request has an invalid app base URL origin. + */ +export const requestPathHasInvalidAppBaseURLOrigin = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + const url = _url || $url.parse(request.url); + const appBaseURL = $app.baseURL({ parsed: true }); - return new Response((needsContentBody ? cfg.body : null) as BodyInit | null, { - status: cfg.status, - statusText: responseStatusText(cfg.status), - headers, // Prepared above; {@see prepareResponseHeaders()}. - // Tells Cloudflare when we encoded manually; {@see https://o5p.me/fHo2ON}. - ...(cfg.encodeBody && $env.isCFW() && !$is.nul(cfg.body) ? { encodeBody: 'manual' } : {}), - }); -}; + if (url.host !== appBaseURL.host) return true; + return ( + url.protocol !== appBaseURL.protocol && // + // This Miniflare behavior; i.e., `http:`, began in Wrangler 3.19.0. + // In Miniflare, we don’t consider a mismatched protocol to be an issue. + // We assume the original request URL was `https:` and Miniflare is acting as a proxy. + // It’s worth noting that all our local test configurations make `https:` requests only. + (!$env.isCFWViaMiniflare() || 'http:' !== url.protocol) + ); +}); /** - * Prepares HTTP response headers. + * Request path has an invalid trailing slash? * * @param request HTTP request. - * @param cfg Required; {@see ResponseConfig}. - * - * @returns HTTP response headers promise. + * @param url Optional pre-parsed URL. Default is taken from `request`. * - * @note Private function. Intentionally not exporting. + * @returns True if request path has an invalid trailing slash. */ -const prepareResponseHeaders = async (request: $type.Request, url: $type.URL, cfg: Required): Promise<$type.Headers> => { - // Initializes grouped header objects. - - const alwaysOnHeaders: { [x: string]: string } = {}; - const contentHeaders: { [x: string]: string } = {}; - const cacheHeaders: { [x: string]: string } = {}; - const securityHeaders: { [x: string]: string } = {}; - const corsHeaders: { [x: string]: string } = {}; +export const requestPathHasInvalidTrailingSlash = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + const url = _url || $url.parse(request.url); + if ('/' === url.pathname) return false; - // Enforces `Headers` object type on headers given by config. + return url.pathname.endsWith('/'); +}); - cfg.headers = // Headers. - cfg.headers instanceof Headers - ? cfg.headers // Use existing instance. - : new Headers((cfg.headers || {}) as HeadersInit); +/** + * Request path is forbidden? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request path is forbidden. + */ +export const requestPathIsForbidden = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + const url = _url || $url.parse(request.url); + if ('/' === url.pathname) return false; - cfg.appendHeaders = // Appends. + if (/\/\./iu.test(url.pathname) && !/^\/\.well-known(?:$|\/)/iu.test(url.pathname)) { + return true; // No dotfile paths, except `/.well-known` at root of a domain. + } + if (/(?:~|[^/.]\.(?:bak|backup|copy|log|old|te?mp))(?:$|\/)/iu.test(url.pathname)) { + return true; // No backups, copies, logs, or temp paths. + } + if (/\/(?:[^/]*[._-])?(?:private|cache|logs?|te?mp)(?:$|\/)/iu.test(url.pathname)) { + return true; // No private, cache, log, or temp paths. + } + if (/\/(?:yarn|vendor|node[_-]modules|jspm[_-]packages|bower[_-]components)(?:$|\/)/iu.test(url.pathname)) { + return true; // No package management dependencies paths. + } + return false; +}); + +/** + * Request path is dynamic? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request is dynamic. + * + * @note This is determining whether it *might* be; i.e., probably is dynamic, not that is in fact dynamic. + * The best practice is to attempt to resolve dynamically first, then fall back on static handlers. + */ +export const requestPathIsDynamic = $fnꓺmemo(2, (request: $type.Request, url?: $type.URL): boolean => { + return requestPathHasDynamicBase(request, url) || requestPathIsPotentiallyDynamic(request, url) || !requestPathHasStaticExtension(request, url); +}); + +/** + * Request path has a dynamic base? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request path has a dynamic base. + */ +export const requestPathHasDynamicBase = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + let url = _url || $url.parse(request.url); + url = $fn.try(() => $url.removeAppBasePath(url), url)(); + if ('/' === url.pathname) return false; + + return /^\/(?:api)(?:$|\/)/iu.test(url.pathname); +}); + +/** + * Request path is potentially dynamic? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request path is potentially dynamic. + */ +export const requestPathIsPotentiallyDynamic = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + const url = _url || $url.parse(request.url); + if ('/' === url.pathname) return false; + + return requestPathIsSEORelatedFile(request, url) && !/\/favicon\.ico$/iu.test(url.pathname); +}); + +/** + * Request path is an SEO file? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request path is an SEO file. + */ +export const requestPathIsSEORelatedFile = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + const url = _url || $url.parse(request.url); + if ('/' === url.pathname) return false; + + return /\/(?:\.well[-_]known\/|sitemaps\/.*\.xml|(?:[^/]+[-_])?sitemap(?:[-_][^/]+)?\.xml|manifest\.json|(?:ads|humans|robots)\.txt|favicon\.ico)$/iu.test(url.pathname); +}); + +/** + * Request path is in an admin area? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request path is in an admin area. + */ +export const requestPathIsInAdmin = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + const url = _url || $url.parse(request.url); + if ('/' === url.pathname) return false; + + return /\/(?:[^/]+[-_])?admin(?:[-_][^/]+)?(?:$|\/)/iu.test(url.pathname); +}); + +/** + * Request path is static? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request is static. + */ +export const requestPathIsStatic = $fnꓺmemo(2, (request: $type.Request, url?: $type.URL): boolean => { + return !requestPathIsDynamic(request, url); +}); + +/** + * Request path has a static file extension? + * + * @param request HTTP request. + * @param url Optional pre-parsed URL. Default is taken from `request`. + * + * @returns True if request path has a static file extension. + */ +export const requestPathHasStaticExtension = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { + const url = _url || $url.parse(request.url); + if ('/' === url.pathname) return false; + + return $path.hasStaticExt(url.pathname); +}); + +/** + * Supported HTTP request methods. + * + * @returns An array of supported HTTP request methods (uppercase). + */ +export const supportedRequestMethods = (): string[] => ['OPTIONS', 'HEAD', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + +// --- +// Response utilities. + +/** + * HTTP response config. + * + * @param config Optional config options. + * + * @returns HTTP response config promise. + */ +export const responseConfig = async (config?: ResponseConfig): Promise> => { + return $obj.defaults({}, config || {}, { + status: 405, + + enableCORs: false, + varyOn: [], + cacheVersion: '', + + maxAge: null, + sMaxAge: null, + staleAge: null, + + headers: {}, + appendHeaders: {}, + + body: null, + encodeBody: null, + }) as Required; +}; + +/** + * Prepares an HTTP response. + * + * @param request HTTP request. + * @param config Optional; {@see ResponseConfig}. + * + * @returns Mutatable HTTP response promise. + */ +export const prepareResponse = async (request: $type.Request, config?: ResponseConfig): Promise<$type.Response> => { + const cfg = await responseConfig(config), + url = $url.tryParse(request.url); + + /** + * This will also be the case when preparing a request, which triggers a response error whenever the URL is + * unparseable. In such a case, we return immediately with a 400 error status: Bad Request. + */ + if (!url /* Catches unparseable URLs. */) { + const url400 = new URL('https://0.0.0.0/'), + cfg400 = await responseConfig({ status: 400, body: responseStatusText(400) }), + needsContentBody = responseNeedsContentBody(request, cfg400.status, cfg400.body); + + return new Response((needsContentBody ? cfg400.body : null) as BodyInit | null, { + status: cfg400.status, + statusText: responseStatusText(cfg400.status), + headers: await prepareResponseHeaders(request, url400, cfg400), + }); + } + + /** + * This CORs approach makes implementation simpler, because we consolidate the handling of `OPTIONS` into the + * `enableCORs` flag, such that an explicit `OPTIONS` case handler does not need to be added by an implementation. + * The case of `405` (method now allowed; i.e., default status) is in conflict with CORs being enabled. Thus, the + * `OPTIONS` method is actually ok, because `enableCORs` was defined by the response config. + */ + if (cfg.enableCORs && 'OPTIONS' === request.method && (!cfg.status || 405 === cfg.status)) { + cfg.status = 204; // No content for CORs preflight requests. + } + if (!cfg.status /* Catches empty/invalid status. */) { + const cfg500 = await responseConfig({ status: 500, body: responseStatusText(500) }), + needsContentBody = responseNeedsContentBody(request, cfg500.status, cfg500.body); + + return new Response((needsContentBody ? cfg500.body : null) as BodyInit | null, { + status: cfg500.status, + statusText: responseStatusText(cfg500.status), + headers: await prepareResponseHeaders(request, url, cfg500), + }); + } + + /** + * We only encode string body types at this time, and only gzip encoding is supported at this time. In a future + * version of this library we may decide to support other body types and/or other encoding formats. + * + * In general, we do not allow the body of HTML documents to be encoded, because doing so would hinder our ability + * to easily transform cacheable markup. The best approach for HTML is to utilize Cloudflare for on-the-fly + * encoding, because we generally don’t want an HTML response to be encoded at this layer. + */ + if (cfg.encodeBody && !$is.nul(cfg.body)) { + if (!$is.string(cfg.body)) throw Error('SHfNesyN'); + if ('gzip' !== cfg.encodeBody) throw Error('rU2H3CfG'); + if (contentIsHTML(cfg.headers)) throw Error('DVsRapCc'); + + const gzipBody = await $gzip.encode(cfg.body), // Compressed gzip byte array. + gzipContentLength = gzipBody.length; // Compressed content length in bytes. + + if ($env.isCFW() /* {@see https://o5p.me/uXMnF4} */) { + const _ = globalThis as $type.Keyable, + FixedLengthStream = _.FixedLengthStream as typeof $type.cfw.FixedLengthStream, + { writable: writableStream, readable: readableStream } = new FixedLengthStream(gzipContentLength); + + const writer = writableStream.getWriter(); + void writer.write(gzipBody), void writer.close(); + + (readableStream as unknown as $type.Keyable)[$symbol.objReadableLength] = gzipContentLength; + cfg.body = readableStream; // Readable stream includes a length property. + } else { + const readableStream = new Blob([gzipBody]).stream(); + (readableStream as unknown as $type.Keyable)[$symbol.objReadableLength] = gzipContentLength; + cfg.body = readableStream; // Readable stream includes a length property. + } + } + /** + * A few points to remember: + * + * We want header preparation to consider a potentially encoded (e.g., gzipped) body content length. Therefore, it’s + * important that we prepare response headers after we have already prepared & potentially encoded body above. + * + * The inclusion of a body {@see responseNeedsContentBody()} should not be considered by header preparation. For + * example, a HEAD request should return all the same headers that a GET request does, which would include, for + * example, the `content-length` of a body, or the `content-length` of an encoded body. + */ + const headers = await prepareResponseHeaders(request, url, cfg), + needsContentBody = responseNeedsContentBody(request, cfg.status, cfg.body); + + return new Response((needsContentBody ? cfg.body : null) as BodyInit | null, { + status: cfg.status, + statusText: responseStatusText(cfg.status), + headers, // Prepared above; {@see prepareResponseHeaders()}. + // Tells Cloudflare when we encoded manually; {@see https://o5p.me/fHo2ON}. + ...(cfg.encodeBody && $env.isCFW() && !$is.nul(cfg.body) ? { encodeBody: 'manual' } : {}), + }); +}; + +/** + * Prepares HTTP response headers. + * + * @param request HTTP request. + * @param cfg Required; {@see ResponseConfig}. + * + * @returns HTTP response headers promise. + * + * @note Private function. Intentionally not exporting. + */ +const prepareResponseHeaders = async (request: $type.Request, url: $type.URL, cfg: Required): Promise<$type.Headers> => { + // Initializes grouped header objects. + + const alwaysOnHeaders: { [x: string]: string } = {}; + const contentHeaders: { [x: string]: string } = {}; + const cacheHeaders: { [x: string]: string } = {}; + const securityHeaders: { [x: string]: string } = {}; + const corsHeaders: { [x: string]: string } = {}; + + // Enforces `Headers` object type on headers given by config. + + cfg.headers = // Headers. + cfg.headers instanceof Headers + ? cfg.headers // Use existing instance. + : new Headers((cfg.headers || {}) as HeadersInit); + + cfg.appendHeaders = // Appends. cfg.appendHeaders instanceof Headers ? cfg.appendHeaders // Use existing instance. : new Headers((cfg.appendHeaders || {}) as HeadersInit); @@ -449,222 +731,10 @@ const prepareResponseHeaders = async (request: $type.Request, url: $type.URL, cf } // Merges and returns all headers. - const headers = new Headers({ ...alwaysOnHeaders, ...contentHeaders, ...cacheHeaders, ...securityHeaders, ...corsHeaders }); - - cfg.headers.forEach((value, name) => headers.set(name, value)); - cfg.appendHeaders.forEach((value, name) => headers.append(name, value)); - - return headers; -}; - -/** - * Prepares a cached HTTP response. - * - * For performance reasons, this utility reserves the right to read the `.body` of the input response. The assumption is - * that it’s OK to disturb `.body` when preparing a response that was pulled from a cache. By doing so, we avoid needing - * to clone the input response before reading `.body`, which conserves memory. - * - * In the case of HTML we don’t transform a response that is encoded; e.g., gzipped, as it would be more resource - * intensive than we’d like. In general, we do not allow the body of HTML documents to be encoded by our `$http` - * utilities, because doing so would hinder our ability to easily transform markup. The best approach for HTML is to - * utilize Cloudflare for on-the-fly encoding, because we don’t want an HTML response to be encoded at this layer. - * - * @param request HTTP request. - * @param response HTTP response from a cache. - * - * @returns Mutatable HTTP response copy promise. - * - * @note Important to keep in mind that we are potentially dealing with a response to a byte range request here. - * In such a case, the response body could potentially be an HTML fragment based on the byte range - * requested by the caller and not necessarily a full and complete HTML document. - * - * @review An unfortunate edge case where it is possible for a byte range request to chop HTML markup into slices; - * e.g., slicing a CSP replacement code, which would cause the repopulation of CSP nonce replacement codes - * to break. Please work on ways to avoid the potential for this to occur. - * - * - Currently unsure of how to fix this other than to try and prevent range requests on HTML files? - * - No, that’s really going in the wrong direction, because once it works, it works beautifully, so why destroy? - * - * - Is it possible to partially replace nonce replacement codes in such a way that we adhere to range requests when doing so? - * - It may not be, but it might be partially possible and as a result would further reduce the potential for this to occur. - * Is the added dev time and complexity of overhead and maintenance going to be worth the effort? - * --- - * @review Uncached content served by our `$http` utilities will not return or support byte range requests. - * Thus, if a user happens to be the first one to request a given URL, that request will not support a - * range request. We should work to avoid this scenario such that all requests can support range requests. - * - * - Is `cache.put()` followed by `cache.match()` consistent or eventually consistent? - * - Testing shows no, eventually consistent, so not possible. - * - * - We could consider this a value-added feature brought about by Cloudflare’s cache, but not part of critical services. - * - In cases when byte range requests are critical to support we should really be thinking about static file hosting, yes? - * Or we should be implementing support for range requests within whatever is formulating responses, yes? - */ -export const prepareCachedResponse = async (request: $type.Request, response: $type.Response): Promise<$type.Response> => { - if ( - contentIsHTML(response.headers) && // - !contentIsEncoded(response.headers) && - request.headers.has('x-csp-nonce') && - response.headers.has('content-security-policy') - ) { - const cspNonceReplCode = $crypto.cspNonceReplacementCode(), - cspNonce = request.headers.get('x-csp-nonce') || '', - csp = response.headers.get('content-security-policy') || ''; - - if (responseNeedsContentBody(request, response.status, response.body)) { - // Content length does not and must not change. Otherwise, we would have to alter our `content-length` header, - // which would get a little messy because it is possible we are actually dealing with a byte range request/response. - // The length does not change here because CSP nonce replacement codes exactly match the length of CSP nonce values. - - const htmlMarkup = (await response.text()).replaceAll(cspNonceReplCode, cspNonce); - response = new Response(htmlMarkup, response); - } else { - response = new Response(null, response); // Mutatable copy. - } - response.headers.set('content-security-policy', csp.replaceAll(cspNonceReplCode, cspNonce)); - // - } else if (!responseNeedsContentBody(request, response.status, response.body)) { - response = new Response(null, response); // Mutatable copy. - } else { - response = new Response(response.body as BodyInit, response); // Mutatable copy. - } - if ($env.isCFW() && response.headers.has('content-length') && !response.headers.has('accept-ranges')) { - // The Cloudflare cache API supports byte range requests; {@see https://o5p.me/omV7Jx}. - // When using a Cloudflare worker and caching, this signals our support for range requests. - // Cloudflare only supports byte range requests whenever a `content-length` header is available. - // However, Cloudflare strips away this header regardless, unless and until a `range:` request is made. - // Therefore, to test support for byte range requests send a HEAD|GET request with a `range:` header. - response.headers.set('accept-ranges', 'bytes'); - } - response.headers.set('x-cache-status', 'hit; prepared'); - - return response; // Mutatable copy. -}; - -/** - * Prepares an HTTP response for a cache. - * - * This utility always clones the input response instead of disturbing `.body` of a response being served up. Even if we - * don’t read `.body` here, we still need a clone, because what is returned by this utility will be read into a cache. - * Cloning a response requires added memory. Therefore, this should only be used when the overhead is acceptable. - * - * In the case of HTML we don’t transform a response that is encoded; e.g., gzipped, as it would be more resource - * intensive than we’d like. In general, we do not allow the body of HTML documents to be encoded by our `$http` - * utilities, because doing so would hinder our ability to easily transform markup. The best approach for HTML is to - * utilize Cloudflare for on-the-fly encoding, because we don’t want an HTML response to be encoded at this layer. - * - * @param request HTTP request. - * @param response HTTP response being served up. - * - * @returns Mutatable HTTP response clone promise. - */ -export const prepareResponseForCache = async (request: $type.Request, response: $type.Response): Promise<$type.Response> => { - response = response.clone(); // Must clone response, which requires added memory. - response.headers.delete('x-cache-status'); // Serves no purpose in a cache. - - if ( - contentIsHTML(response.headers) && // - !contentIsEncoded(response.headers) && - request.headers.has('x-csp-nonce') && - response.headers.has('content-security-policy') - ) { - const cspNonceReplCode = $crypto.cspNonceReplacementCode(), - csp = response.headers.get('content-security-policy') || ''; - - if (response.body) { - // Content length does not and must not change. Otherwise, we would have to alter our `content-length` header, - // which would get a little messy because it is possible we are later dealing with a byte range request/response. - // The length does not change here because CSP nonce replacement codes exactly match the length of CSP nonce values. - const htmlMarkup = (await response.text()) - // {@see https://regex101.com/r/oTjEIq/7} {@see https://regex101.com/r/1MioJI/9}. - .replace(/(]*?)?\s+nonce\s*=\s*)(['"])[^'"<>]*\2/giu, '$1$2' + cspNonceReplCode + '$2') - .replace( - /(]*?)?\s+id\s*=\s*(['"])global-data\2[^<>]*>(?:[\s\S](?!<\/script>))*)((['"]{0,1})cspNonce\4\s*:\s*)(['"])[^'"<>]*\5/iu, - '$1$3$5' + cspNonceReplCode + '$5', - ); - response = new Response(htmlMarkup, response); - } - response.headers.set('content-security-policy', csp.replace(/'nonce-[^']+'/giu, `'nonce-${cspNonceReplCode}'`)); - } - return response; // Mutatable clone. -}; - -/** - * Prepares `referer` based on from » to URLs & referrer policy. - * - * This is most useful when following redirect responses. - * - * @param parseable Parseable headers, which may include a `referrer-policy` header. - * @param fromParseable Parseable URL or string; e.g., a URL that is issuing a redirection. - * @param toParseable Parseable URL or string; e.g., a URL that is set as the redirect location. - * - * @returns Parsed {@see $type.Headers} instance with an appropriate `referer` header value. - */ -export const prepareRefererHeader = (parseable: $type.RawHeadersInit, fromParseable: $type.URL | string, toParseable: $type.URL | string): $type.Headers => { - const headers = parseHeaders(parseable), - fromURL = $url.tryParse(fromParseable), - toURL = $url.tryParse(toParseable); - - if (!fromURL || !toURL) { - headers.delete('referer'); - return headers; // Not possible. - } - let referer = ''; // Initializes `referer` header value. - - const referrerPolicy = ((headers.get('referrer-policy') || '') - .split(/\s*,\s*/u).slice(-1)[0] || '').toLowerCase(); // prettier-ignore - - switch (referrerPolicy) { - case 'no-referrer': { - break; // No referrer. - } - case 'origin': { - referer = fromURL.origin; - break; - } - case 'unsafe-url': { - referer = fromURL.toString(); - break; - } - case 'same-origin': { - if (fromURL.origin === toURL.origin) { - referer = fromURL.toString(); - } - break; - } - case 'origin-when-cross-origin': { - if (fromURL.origin === toURL.origin) { - referer = fromURL.toString(); - } else { - referer = fromURL.origin; - } - break; - } - case 'strict-origin': { - if (!$url.isPotentiallyTrustworthy(fromURL) || $url.isPotentiallyTrustworthy(toURL)) { - referer = fromURL.origin; - } - break; - } - case 'no-referrer-when-downgrade': { - if (!$url.isPotentiallyTrustworthy(fromURL) || $url.isPotentiallyTrustworthy(toURL)) { - referer = fromURL.toString(); - } - break; - } - case 'strict-origin-when-cross-origin': - default: { - if (fromURL.origin === toURL.origin) { - referer = fromURL.toString(); - // - } else if (!$url.isPotentiallyTrustworthy(fromURL) || $url.isPotentiallyTrustworthy(toURL)) { - referer = fromURL.origin; - } - } - } - if ('' !== referer) { - headers.set('referer', referer); - } else headers.delete('referer'); + const headers = new Headers({ ...alwaysOnHeaders, ...contentHeaders, ...cacheHeaders, ...securityHeaders, ...corsHeaders }); + + cfg.headers.forEach((value, name) => headers.set(name, value)); + cfg.appendHeaders.forEach((value, name) => headers.append(name, value)); return headers; }; @@ -680,28 +750,6 @@ export const responseStatusText = (status: string | number): string => { return responseStatusCodes()[String(status)] || ''; }; -/** - * Request has a supported method? - * - * @param request HTTP request. - * - * @returns True if request has a supported method. - */ -export const requestHasSupportedMethod = (request: $type.Request): boolean => { - return supportedRequestMethods().includes(request.method); -}; - -/** - * Request has a cacheable request method? - * - * @param request HTTP request. - * - * @returns True if request has a cacheable request method. - */ -export const requestHasCacheableMethod = $fnꓺmemo(2, (request: $type.Request): boolean => { - return requestHasSupportedMethod(request) && ['HEAD', 'GET'].includes(request.method); -}); - /** * Response needs content headers? * @@ -732,249 +780,292 @@ export const responseNeedsContentBody = $fnꓺmemo(2, (request: $type.Request, r return requestHasSupportedMethod(request) && !['OPTIONS', 'HEAD'].includes(request.method) && ![101, 103, 204, 205, 304].includes(responseStatus) && !$is.nul(responseBody); }); -/** - * Request is coming from an identified user? - * - * @param request HTTP request. - * - * @returns True if request is coming from an identified user. - */ -export const requestIsFromUser = $fnꓺmemo(2, (request: $type.Request): boolean => { - if (request.headers.has('authorization')) { - return true; // Authorization header. - } - if (!request.headers.has('cookie')) { - return false; // No cookies. - } - const cookie = request.headers.get('cookie') || ''; // Contains encoded cookies. - return /(?:^\s*|;\s*)(?:ut[mx]_)?(?:author|user|customer)(?:[_-][^=;]+)?=\s*"?[^";]/iu.test(cookie); -}); +// --- +// Response cache utilities. /** - * Request is coming from a specific via token? + * Prepares a cached HTTP response. * - * WARNING: We do not, by default, vary caches based on `x-via`. Therefore, _only_ use this when processing an - * uncacheable request type; e.g., `POST` request, or by explicitly declaring that a cache should vary on `x-via`. + * For performance reasons, this utility reserves the right to read the `.body` of the input response. The assumption is + * that it’s OK to disturb `.body` when preparing a response that was pulled from a cache. By doing so, we avoid needing + * to clone the input response before reading `.body`, which conserves memory. * - * @param request HTTP request. - * @param via `x-via` token to consider. + * In the case of HTML we don’t transform a response that is encoded; e.g., gzipped, as it would be more resource + * intensive than we’d like. In general, we do not allow the body of HTML documents to be encoded by our `$http` + * utilities, because doing so would hinder our ability to easily transform markup. The best approach for HTML is to + * utilize Cloudflare for on-the-fly encoding, because we don’t want an HTML response to be encoded at this layer. * - * @returns True if request is coming from `x-via` token. - */ -export const requestIsVia = $fnꓺmemo(2, (request: $type.Request, via: string): boolean => { - if (!request.headers.has('x-via')) { - return false; // No `x-via` header. - } - const header = request.headers.get('x-via') || ''; // Contains via tokens. - return $is.notEmpty(header) && new RegExp('(?:^|[,;])\\s*(?:' + $str.escRegExp(via) + ')\\s*(?:$|[,;])', 'ui').test(header); -}); - -/** - * Request expects a JSON response? + * @param request HTTP request. + * @param response HTTP response from a cache. * - * WARNING: We do not vary caches based on `accept`. Therefore, you should _only_ use this when processing an - * uncacheable request type; e.g., `POST` request, or another that is uncacheable; {@see requestHasCacheableMethod()}. + * @returns Mutatable HTTP response copy promise. * - * WARNING: This isn’t foolproof because it assumes anything of our own served from `/api` will expect JSON in the - * absense of an `accept` header. Therefore, only use in contexts where false-positives are not detrimental. + * @note Important to keep in mind that we are potentially dealing with a response to a byte range request here. + * In such a case, the response body could potentially be an HTML fragment based on the byte range + * requested by the caller and not necessarily a full and complete HTML document. * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. + * @review An unfortunate edge case where it is possible for a byte range request to chop HTML markup into slices; + * e.g., slicing a CSP replacement code, which would cause the repopulation of CSP nonce replacement codes + * to break. Please work on ways to avoid the potential for this to occur. * - * @returns True if request expects a JSON response. - */ -export const requestExpectsJSON = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - let url = _url || $url.parse(request.url); - url = $fn.try(() => $url.removeAppBasePath(url), url)(); - const acceptHeader = request.headers.get('accept') || ''; - return ( - (acceptHeader && /\b(?:application\/json)\b/iu.test(acceptHeader)) || // - (!acceptHeader && $env.isC10n() && '/' !== url.pathname && /^\/(?:api)(?:$|\/)/iu.test(url.pathname)) - ); -}); - -/** - * Request path is invalid? + * - Currently unsure of how to fix this other than to try and prevent range requests on HTML files? + * - No, that’s really going in the wrong direction, because once it works, it works beautifully, so why destroy? * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. + * - Is it possible to partially replace nonce replacement codes in such a way that we adhere to range requests when doing so? + * - It may not be, but it might be partially possible and as a result would further reduce the potential for this to occur. + * Is the added dev time and complexity of overhead and maintenance going to be worth the effort? + * --- + * @review Uncached content served by our `$http` utilities will not return or support byte range requests. + * Thus, if a user happens to be the first one to request a given URL, that request will not support a + * range request. We should work to avoid this scenario such that all requests can support range requests. * - * @returns True if request path is invalid. + * - Is `cache.put()` followed by `cache.match()` consistent or eventually consistent? + * - Testing shows no, eventually consistent, so not possible. + * + * - We could consider this a value-added feature brought about by Cloudflare’s cache, but not part of critical services. + * - In cases when byte range requests are critical to support we should really be thinking about static file hosting, yes? + * Or we should be implementing support for range requests within whatever is formulating responses, yes? */ -export const requestPathIsInvalid = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - const url = _url || $url.parse(request.url); - if ('/' === url.pathname) return false; +export const prepareCachedResponse = async (request: $type.Request, response: $type.Response): Promise<$type.Response> => { + if ( + contentIsHTML(response.headers) && // + !contentIsEncoded(response.headers) && + request.headers.has('x-csp-nonce') && + response.headers.has('content-security-policy') + ) { + const cspNonceReplCode = $crypto.cspNonceReplacementCode(), + cspNonce = request.headers.get('x-csp-nonce') || '', + csp = response.headers.get('content-security-policy') || ''; - return /\\|\/{2,}|\.{2,}/iu.test(url.pathname); -}); + if (responseNeedsContentBody(request, response.status, response.body)) { + // Content length does not and must not change. Otherwise, we would have to alter our `content-length` header, + // which would get a little messy because it is possible we are actually dealing with a byte range request/response. + // The length does not change here because CSP nonce replacement codes exactly match the length of CSP nonce values. -/** - * Request has an invalid app base URL origin? - * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. - * - * @returns True if request has an invalid app base URL origin. - */ -export const requestPathHasInvalidAppBaseURLOrigin = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - const url = _url || $url.parse(request.url); - const appBaseURL = $app.baseURL({ parsed: true }); + const htmlMarkup = (await response.text()).replaceAll(cspNonceReplCode, cspNonce); + response = new Response(htmlMarkup, response); + } else { + response = new Response(null, response); // Mutatable copy. + } + response.headers.set('content-security-policy', csp.replaceAll(cspNonceReplCode, cspNonce)); + // + } else if (!responseNeedsContentBody(request, response.status, response.body)) { + response = new Response(null, response); // Mutatable copy. + } else { + response = new Response(response.body as BodyInit, response); // Mutatable copy. + } + if ($env.isCFW() && response.headers.has('content-length') && !response.headers.has('accept-ranges')) { + // The Cloudflare cache API supports byte range requests; {@see https://o5p.me/omV7Jx}. + // When using a Cloudflare worker and caching, this signals our support for range requests. + // Cloudflare only supports byte range requests whenever a `content-length` header is available. + // However, Cloudflare strips away this header regardless, unless and until a `range:` request is made. + // Therefore, to test support for byte range requests send a HEAD|GET request with a `range:` header. + response.headers.set('accept-ranges', 'bytes'); + } + response.headers.set('x-cache-status', 'hit; prepared'); - if (url.host !== appBaseURL.host) return true; - return ( - url.protocol !== appBaseURL.protocol && // - // This Miniflare behavior; i.e., `http:`, began in Wrangler 3.19.0. - // In Miniflare, we don’t consider a mismatched protocol to be an issue. - // We assume the original request URL was `https:` and Miniflare is acting as a proxy. - // It’s worth noting that all our local test configurations make `https:` requests only. - (!$env.isCFWViaMiniflare() || 'http:' !== url.protocol) - ); -}); + return response; // Mutatable copy. +}; /** - * Request path has an invalid trailing slash? + * Prepares an HTTP response for a cache. * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. + * This utility always clones the input response instead of disturbing `.body` of a response being served up. Even if we + * don’t read `.body` here, we still need a clone, because what is returned by this utility will be read into a cache. + * Cloning a response requires added memory. Therefore, this should only be used when the overhead is acceptable. * - * @returns True if request path has an invalid trailing slash. - */ -export const requestPathHasInvalidTrailingSlash = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - const url = _url || $url.parse(request.url); - if ('/' === url.pathname) return false; - - return url.pathname.endsWith('/'); -}); - -/** - * Request path is forbidden? + * In the case of HTML we don’t transform a response that is encoded; e.g., gzipped, as it would be more resource + * intensive than we’d like. In general, we do not allow the body of HTML documents to be encoded by our `$http` + * utilities, because doing so would hinder our ability to easily transform markup. The best approach for HTML is to + * utilize Cloudflare for on-the-fly encoding, because we don’t want an HTML response to be encoded at this layer. * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. + * @param request HTTP request. + * @param response HTTP response being served up. * - * @returns True if request path is forbidden. + * @returns Mutatable HTTP response clone promise. */ -export const requestPathIsForbidden = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - const url = _url || $url.parse(request.url); - if ('/' === url.pathname) return false; +export const prepareResponseForCache = async (request: $type.Request, response: $type.Response): Promise<$type.Response> => { + response = response.clone(); // Must clone response, which requires added memory. + response.headers.delete('x-cache-status'); // Serves no purpose in a cache. - if (/\/\./iu.test(url.pathname) && !/^\/\.well-known(?:$|\/)/iu.test(url.pathname)) { - return true; // No dotfile paths, except `/.well-known` at root of a domain. - } - if (/(?:~|[^/.]\.(?:bak|backup|copy|log|old|te?mp))(?:$|\/)/iu.test(url.pathname)) { - return true; // No backups, copies, logs, or temp paths. - } - if (/\/(?:[^/]*[._-])?(?:private|cache|logs?|te?mp)(?:$|\/)/iu.test(url.pathname)) { - return true; // No private, cache, log, or temp paths. - } - if (/\/(?:yarn|vendor|node[_-]modules|jspm[_-]packages|bower[_-]components)(?:$|\/)/iu.test(url.pathname)) { - return true; // No package management dependencies paths. - } - return false; -}); + if ( + contentIsHTML(response.headers) && // + !contentIsEncoded(response.headers) && + request.headers.has('x-csp-nonce') && + response.headers.has('content-security-policy') + ) { + const cspNonceReplCode = $crypto.cspNonceReplacementCode(), + csp = response.headers.get('content-security-policy') || ''; -/** - * Request path is dynamic? - * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. - * - * @returns True if request is dynamic. - * - * @note This is determining whether it *might* be; i.e., probably is dynamic, not that is in fact dynamic. - * The best practice is to attempt to resolve dynamically first, then fall back on static handlers. - */ -export const requestPathIsDynamic = $fnꓺmemo(2, (request: $type.Request, url?: $type.URL): boolean => { - return requestPathHasDynamicBase(request, url) || requestPathIsPotentiallyDynamic(request, url) || !requestPathHasStaticExtension(request, url); -}); + if (response.body) { + // Content length does not and must not change. Otherwise, we would have to alter our `content-length` header, + // which would get a little messy because it is possible we are later dealing with a byte range request/response. + // The length does not change here because CSP nonce replacement codes exactly match the length of CSP nonce values. + const htmlMarkup = (await response.text()) + // {@see https://regex101.com/r/oTjEIq/7} {@see https://regex101.com/r/1MioJI/9}. + .replace(/(]*?)?\s+nonce\s*=\s*)(['"])[^'"<>]*\2/giu, '$1$2' + cspNonceReplCode + '$2') + .replace( + /(]*?)?\s+id\s*=\s*(['"])global-data\2[^<>]*>(?:[\s\S](?!<\/script>))*)((['"]{0,1})cspNonce\4\s*:\s*)(['"])[^'"<>]*\5/iu, + '$1$3$5' + cspNonceReplCode + '$5', + ); + response = new Response(htmlMarkup, response); + } + response.headers.set('content-security-policy', csp.replace(/'nonce-[^']+'/giu, `'nonce-${cspNonceReplCode}'`)); + } + return response; // Mutatable clone. +}; + +// --- +// Heartbeat utilities. /** - * Request path has a dynamic base? - * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. + * Logs a heartbeat for monitoring purposes. * - * @returns True if request path has a dynamic base. + * @param id Heartbeat ID; e.g., `JGndBRX5LXN79q5q1GkpsmaQ`. + * @param options All optional; {@see HeartbeatOptions}. */ -export const requestPathHasDynamicBase = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - let url = _url || $url.parse(request.url); - url = $fn.try(() => $url.removeAppBasePath(url), url)(); - if ('/' === url.pathname) return false; +export const heartbeat = async (id: string, options?: HeartbeatOptions): Promise => { + const opts = $obj.defaults({}, options || {}) as HeartbeatOptions, + fetch = (opts.cfw ? opts.cfw.fetch : globalThis.fetch) as typeof globalThis.fetch; - return /^\/(?:api)(?:$|\/)/iu.test(url.pathname); -}); + await fetch('https://uptime.betterstack.com/api/v1/heartbeat/' + $url.encode(id), { + signal: AbortSignal.timeout($time.secondInMilliseconds), + }).catch(() => undefined); +}; + +// --- +// Header utilities. /** - * Request path is potentially dynamic? + * Parses headers into a {@see $type.Headers} instance. * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. + * @param parseable Headers; {@see $type.RawHeadersInit}. * - * @returns True if request path is potentially dynamic. + * @returns Parsed headers into a {@see $type.Headers} instance. */ -export const requestPathIsPotentiallyDynamic = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - const url = _url || $url.parse(request.url); - if ('/' === url.pathname) return false; +export const parseHeaders = (parseable: $type.RawHeadersInit): $type.Headers => { + if (parseable instanceof Headers || $is.array(parseable)) { + return new Headers(parseable as HeadersInit); + } + const headers = new Headers(); // Initialize headers instance. - return requestPathIsSEORelatedFile(request, url) && !/\/favicon\.ico$/iu.test(url.pathname); -}); + if ($is.object(parseable)) { + for (let [name, value] of Object.entries(parseable)) { + headers.set(name, value); + } + } else if ($is.string(parseable)) { + const lines = parseable.split(/[\r\n]+/u); -/** - * Request path is an SEO file? - * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. - * - * @returns True if request path is an SEO file. - */ -export const requestPathIsSEORelatedFile = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - const url = _url || $url.parse(request.url); - if ('/' === url.pathname) return false; + for (let i = 0, name = ''; i < lines.length; i++) { + const line = lines[i]; // Current line. - return /\/(?:\.well[-_]known\/|sitemaps\/.*\.xml|(?:[^/]+[-_])?sitemap(?:[-_][^/]+)?\.xml|manifest\.json|(?:ads|humans|robots)\.txt|favicon\.ico)$/iu.test(url.pathname); -}); + if (name && [' ', '\t'].includes(line[0])) { + headers.set(name, ((headers.get(name) || '') + ' ' + line.trim()).trim()); + continue; // Multiline header concatenation. + } + if (!line.includes(':')) continue; // Invalid line. -/** - * Request path is in an admin area? - * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. - * - * @returns True if request path is in an admin area. - */ -export const requestPathIsInAdmin = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - const url = _url || $url.parse(request.url); - if ('/' === url.pathname) return false; + name = line.slice(0, line.indexOf(':')).toLowerCase().trim(); + const value = line.slice(line.indexOf(':') + 1).trim(); - return /\/(?:[^/]+[-_])?admin(?:[-_][^/]+)?(?:$|\/)/iu.test(url.pathname); -}); + if (!name) continue; // Invalid line. -/** - * Request path is static? - * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. - * - * @returns True if request is static. - */ -export const requestPathIsStatic = $fnꓺmemo(2, (request: $type.Request, url?: $type.URL): boolean => { - return !requestPathIsDynamic(request, url); -}); + if (headers.has(name)) { + headers.append(name, value); + } else headers.set(name, value); + } + } + return headers; +}; /** - * Request path has a static file extension? + * Prepares `referer` based on from » to URLs & referrer policy. * - * @param request HTTP request. - * @param url Optional pre-parsed URL. Default is taken from `request`. + * This is most useful when following redirect responses. * - * @returns True if request path has a static file extension. + * @param parseable Parseable headers, which may include a `referrer-policy` header. + * @param fromParseable Parseable URL or string; e.g., a URL that is issuing a redirection. + * @param toParseable Parseable URL or string; e.g., a URL that is set as the redirect location. + * + * @returns Parsed {@see $type.Headers} instance with an appropriate `referer` header value. */ -export const requestPathHasStaticExtension = $fnꓺmemo(2, (request: $type.Request, _url?: $type.URL): boolean => { - const url = _url || $url.parse(request.url); - if ('/' === url.pathname) return false; +export const prepareRefererHeader = (parseable: $type.RawHeadersInit, fromParseable: $type.URL | string, toParseable: $type.URL | string): $type.Headers => { + const headers = parseHeaders(parseable), + referrerPolicy = ((headers.get('referrer-policy') || '').split(/\s*,\s*/u).slice(-1)[0] || '').toLowerCase(), + // + fromURL = $url.tryParse(fromParseable), + toURL = $url.tryParse(toParseable); - return $path.hasStaticExt(url.pathname); -}); + if (!fromURL || !toURL) { + headers.delete('referer'); + return headers; // Not possible. + } + let referer = ''; // Initializes header value. + + switch (referrerPolicy) { + case 'no-referrer': { + break; // No referrer. + } + case 'origin': { + referer = fromURL.origin; + break; + } + case 'unsafe-url': { + referer = fromURL.toString(); + break; + } + case 'same-origin': { + if (fromURL.origin === toURL.origin) { + referer = fromURL.toString(); + } + break; + } + case 'origin-when-cross-origin': { + if (fromURL.origin === toURL.origin) { + referer = fromURL.toString(); + } else { + referer = fromURL.origin; + } + break; + } + case 'strict-origin': { + if (!$url.isPotentiallyTrustworthy(fromURL)) { + referer = fromURL.origin; + // + } else if ($url.isPotentiallyTrustworthy(toURL)) { + referer = fromURL.origin; + } + break; + } + case 'no-referrer-when-downgrade': { + if (!$url.isPotentiallyTrustworthy(fromURL)) { + referer = fromURL.toString(); + // + } else if ($url.isPotentiallyTrustworthy(toURL)) { + referer = fromURL.toString(); + } + break; + } + case 'strict-origin-when-cross-origin': + default: { + if (fromURL.origin === toURL.origin) { + referer = fromURL.toString(); + // + } else if (!$url.isPotentiallyTrustworthy(fromURL)) { + referer = fromURL.origin; + // + } else if ($url.isPotentiallyTrustworthy(toURL)) { + referer = fromURL.origin; + } + } + } + if (referer) { + headers.set('referer', referer); + } else { + headers.delete('referer'); + } + return headers; +}; /** * Gets clean content type from headers. @@ -1050,70 +1141,6 @@ export const contentIsEncoded = $fnꓺmemo(2, (headers: $type.HeadersInit): bool return !['', 'none'].includes((parseHeaders(headers).get('content-encoding') || '').toLowerCase()); }); -/** - * Parses headers into a {@see $type.Headers} instance. - * - * @param parseable Headers; {@see $type.RawHeadersInit}. - * - * @returns Parsed headers into a {@see $type.Headers} instance. - */ -export const parseHeaders = (parseable: $type.RawHeadersInit): $type.Headers => { - if (parseable instanceof Headers || $is.array(parseable)) { - return new Headers(parseable as HeadersInit); - } - const headers = new Headers(); // Initialize headers instance. - - if ($is.object(parseable)) { - for (let [name, value] of Object.entries(parseable)) { - headers.set(name, value); - } - } else if ($is.string(parseable)) { - const lines = parseable.split(/[\r\n]+/u); - - for (let i = 0, name = ''; i < lines.length; i++) { - const line = lines[i]; // Current line. - - if (name && [' ', '\t'].includes(line[0])) { - headers.set(name, ((headers.get(name) || '') + ' ' + line.trim()).trim()); - continue; // Multiline header concatenation. - } - if (!line.includes(':')) continue; // Invalid line. - - name = line.slice(0, line.indexOf(':')).toLowerCase().trim(); - const value = line.slice(line.indexOf(':') + 1).trim(); - - if (!name) continue; // Invalid line. - - if (headers.has(name)) { - headers.append(name, value); - } else headers.set(name, value); - } - } - return headers; -}; - -/** - * Logs a heartbeat for monitoring purposes. - * - * @param id Heartbeat ID; e.g., `JGndBRX5LXN79q5q1GkpsmaQ`. - * @param options All optional; {@see HeartbeatOptions}. - */ -export const heartbeat = async (id: string, options?: HeartbeatOptions): Promise => { - const opts = $obj.defaults({}, options || {}) as HeartbeatOptions, - fetch = (opts.cfw ? opts.cfw.fetch : globalThis.fetch) as typeof globalThis.fetch; - - await fetch('https://uptime.betterstack.com/api/v1/heartbeat/' + $url.encode(id), { - signal: AbortSignal.timeout($time.secondInMilliseconds), - }).catch(() => undefined); -}; - -/** - * Supported HTTP request methods. - * - * @returns An array of supported HTTP request methods (uppercase). - */ -export const supportedRequestMethods = (): string[] => ['OPTIONS', 'HEAD', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE']; - /** * URL-containing header names. * diff --git a/src/url.ts b/src/url.ts index 5bc0519a..96ad1f00 100644 --- a/src/url.ts +++ b/src/url.ts @@ -658,7 +658,7 @@ export const isRelative = $fnꓺmemo(12, (parseable: $type.URL | string): boolea * @see https://o5p.me/9talJI for details on spec compliance. */ export const isPotentiallyTrustworthy = $fnꓺmemo(12, (parseable: $type.URL | string): boolean => { - if ($is.string(parseable) && ['about:blank', 'about:srcdoc'].includes(parseable.toLowerCase())) { + if ($is.string(parseable) && ['about:blank', 'about:srcdoc', 'about:client'].includes(parseable.toLowerCase())) { return true; // Special trustworthy cases. } const url = tryParse(parseable); diff --git a/tsconfig.json b/tsconfig.json index ea3927ab..8680ace6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ * @note This entire file will be updated automatically. * @note Instead of editing here, please review `./tsconfig.mjs`. * - * Last generated using `./tsconfig.mjs` Mar 13, 2024 5:25 AM UTC. + * Last generated using `./tsconfig.mjs` Mar 13, 2024 6:30 AM UTC. */ { "include": ["./src/**/*", "./dev-types.d.ts"], diff --git a/wrangler.toml b/wrangler.toml index ebd7a1d6..abf35683 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -7,7 +7,7 @@ # @note This entire file will be updated automatically. # @note Instead of editing here, please review `./wrangler.mjs`. # -# Last generated using `./wrangler.mjs` Mar 13, 2024 5:25 AM UTC. +# Last generated using `./wrangler.mjs` Mar 13, 2024 6:30 AM UTC. ## send_metrics = false