UNPKG

@cloudflare/kv-asset-handler

Version:
261 lines (260 loc) 13.9 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (_) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; Object.defineProperty(exports, "__esModule", { value: true }); exports.InternalError = exports.NotFoundError = exports.MethodNotAllowedError = exports.serveSinglePageApp = exports.mapRequestToAsset = exports.getAssetFromKV = void 0; var mime = require("mime"); var types_1 = require("./types"); Object.defineProperty(exports, "MethodNotAllowedError", { enumerable: true, get: function () { return types_1.MethodNotAllowedError; } }); Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: function () { return types_1.NotFoundError; } }); Object.defineProperty(exports, "InternalError", { enumerable: true, get: function () { return types_1.InternalError; } }); /** * maps the path of incoming request to the request pathKey to look up * in bucket and in cache * e.g. for a path '/' returns '/index.html' which serves * the content of bucket/index.html * @param {Request} request incoming request */ var mapRequestToAsset = function (request) { var parsedUrl = new URL(request.url); var pathname = parsedUrl.pathname; if (pathname.endsWith('/')) { // If path looks like a directory append index.html // e.g. If path is /about/ -> /about/index.html pathname = pathname.concat('index.html'); } else if (!mime.getType(pathname)) { // If path doesn't look like valid content // e.g. /about.me -> /about.me/index.html pathname = pathname.concat('/index.html'); } parsedUrl.pathname = pathname; return new Request(parsedUrl.toString(), request); }; exports.mapRequestToAsset = mapRequestToAsset; /** * maps the path of incoming request to /index.html if it evaluates to * any HTML file. * @param {Request} request incoming request */ function serveSinglePageApp(request) { // First apply the default handler, which already has logic to detect // paths that should map to HTML files. request = mapRequestToAsset(request); var parsedUrl = new URL(request.url); // Detect if the default handler decided to map to // a HTML file in some specific directory. if (parsedUrl.pathname.endsWith('.html')) { // If expected HTML file was missing, just return the root index.html return new Request(parsedUrl.origin + "/index.html", request); } else { // The default handler decided this is not an HTML page. It's probably // an image, CSS, or JS file. Leave it as-is. return request; } } exports.serveSinglePageApp = serveSinglePageApp; var defaultCacheControl = { browserTTL: null, edgeTTL: 2 * 60 * 60 * 24, bypassCache: false, }; /** * takes the path of the incoming request, gathers the appropriate content from KV, and returns * the response * * @param {FetchEvent} event the fetch event of the triggered request * @param {{mapRequestToAsset: (string: Request) => Request, cacheControl: {bypassCache:boolean, edgeTTL: number, browserTTL:number}, ASSET_NAMESPACE: any, ASSET_MANIFEST:any}} [options] configurable options * @param {CacheControl} [options.cacheControl] determine how to cache on Cloudflare and the browser * @param {typeof(options.mapRequestToAsset)} [options.mapRequestToAsset] maps the path of incoming request to the request pathKey to look up * @param {Object | string} [options.ASSET_NAMESPACE] the binding to the namespace that script references * @param {any} [options.ASSET_MANIFEST] the map of the key to cache and store in KV * */ var getAssetFromKV = function (event, options) { return __awaiter(void 0, void 0, void 0, function () { var request, ASSET_NAMESPACE, ASSET_MANIFEST, SUPPORTED_METHODS, rawPathKey, pathIsEncoded, requestKey, parsedUrl, pathname, pathKey, cache, mimeType, shouldEdgeCache, cacheKey, evalCacheOpts, shouldSetBrowserCache, response, headers, shouldRevalidate, body; return __generator(this, function (_a) { switch (_a.label) { case 0: // Assign any missing options passed in to the default options = Object.assign({ ASSET_NAMESPACE: __STATIC_CONTENT, ASSET_MANIFEST: __STATIC_CONTENT_MANIFEST, mapRequestToAsset: mapRequestToAsset, cacheControl: defaultCacheControl, defaultMimeType: 'text/plain', }, options); request = event.request; ASSET_NAMESPACE = options.ASSET_NAMESPACE; ASSET_MANIFEST = typeof (options.ASSET_MANIFEST) === 'string' ? JSON.parse(options.ASSET_MANIFEST) : options.ASSET_MANIFEST; if (typeof ASSET_NAMESPACE === 'undefined') { throw new types_1.InternalError("there is no KV namespace bound to the script"); } SUPPORTED_METHODS = ['GET', 'HEAD']; if (!SUPPORTED_METHODS.includes(request.method)) { throw new types_1.MethodNotAllowedError(request.method + " is not a valid request method"); } rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s ; pathIsEncoded = false; if (ASSET_MANIFEST[rawPathKey]) { requestKey = request; } else if (ASSET_MANIFEST[decodeURIComponent(rawPathKey)]) { pathIsEncoded = true; requestKey = request; } else { requestKey = options.mapRequestToAsset(request); } parsedUrl = new URL(requestKey.url); pathname = pathIsEncoded ? decodeURIComponent(parsedUrl.pathname) : parsedUrl.pathname // decode percentage encoded path only when necessary ; pathKey = pathname.replace(/^\/+/, '') // remove prepended / ; cache = caches.default; mimeType = mime.getType(pathKey) || options.defaultMimeType; if (mimeType.startsWith('text')) { mimeType += '; charset=utf-8'; } shouldEdgeCache = false // false if storing in KV by raw file path i.e. no hash ; // check manifest for map from file path to hash if (typeof ASSET_MANIFEST !== 'undefined') { if (ASSET_MANIFEST[pathKey]) { pathKey = ASSET_MANIFEST[pathKey]; // if path key is in asset manifest, we can assume it contains a content hash and can be cached shouldEdgeCache = true; } } cacheKey = new Request(parsedUrl.origin + "/" + pathKey, request); evalCacheOpts = (function () { switch (typeof options.cacheControl) { case 'function': return options.cacheControl(request); case 'object': return options.cacheControl; default: return defaultCacheControl; } })(); options.cacheControl = Object.assign({}, defaultCacheControl, evalCacheOpts); // override shouldEdgeCache if options say to bypassCache if (options.cacheControl.bypassCache || options.cacheControl.edgeTTL === null || request.method == 'HEAD') { shouldEdgeCache = false; } shouldSetBrowserCache = typeof options.cacheControl.browserTTL === 'number'; response = null; if (!shouldEdgeCache) return [3 /*break*/, 2]; return [4 /*yield*/, cache.match(cacheKey)]; case 1: response = _a.sent(); _a.label = 2; case 2: if (!response) return [3 /*break*/, 3]; headers = new Headers(response.headers); shouldRevalidate = false; // Four preconditions must be met for a 304 Not Modified: // - the request cannot be a range request // - client sends if-none-match // - resource has etag // - test if-none-match against the pathKey so that we test against KV, rather than against // CF cache, which may modify the etag with a weak validator (e.g. W/"...") shouldRevalidate = [ request.headers.has('range') !== true, request.headers.has('if-none-match'), response.headers.has('etag'), request.headers.get('if-none-match') === "" + pathKey, ].every(Boolean); if (shouldRevalidate) { // fixes issue #118 if (response.body && 'cancel' in Object.getPrototypeOf(response.body)) { response.body.cancel(); console.log('Body exists and environment supports readable streams. Body cancelled'); } else { console.log('Environment doesnt support readable streams'); } headers.set('cf-cache-status', 'REVALIDATED'); response = new Response(null, { status: 304, headers: headers, statusText: 'Not Modified', }); } else { headers.set('CF-Cache-Status', 'HIT'); response = new Response(response.body, { headers: headers }); } return [3 /*break*/, 5]; case 3: return [4 /*yield*/, ASSET_NAMESPACE.get(pathKey, 'arrayBuffer')]; case 4: body = _a.sent(); if (body === null) { throw new types_1.NotFoundError("could not find " + pathKey + " in your content namespace"); } response = new Response(body); if (shouldEdgeCache) { response.headers.set('Accept-Ranges', 'bytes'); response.headers.set('Content-Length', body.length); // set etag before cache insertion if (!response.headers.has('etag')) { response.headers.set('etag', "" + pathKey); } // determine Cloudflare cache behavior response.headers.set('Cache-Control', "max-age=" + options.cacheControl.edgeTTL); event.waitUntil(cache.put(cacheKey, response.clone())); response.headers.set('CF-Cache-Status', 'MISS'); } _a.label = 5; case 5: response.headers.set('Content-Type', mimeType); if (shouldSetBrowserCache) { response.headers.set('Cache-Control', "max-age=" + options.cacheControl.browserTTL); } else { response.headers.delete('Cache-Control'); } return [2 /*return*/, response]; } }); }); }; exports.getAssetFromKV = getAssetFromKV;