File size: 11,622 Bytes
5641073
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InternalError = exports.NotFoundError = exports.MethodNotAllowedError = exports.serveSinglePageApp = exports.mapRequestToAsset = exports.getAssetFromKV = void 0;
const mime = require("mime");
const 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; } });
const defaultCacheControl = {
    browserTTL: null,
    edgeTTL: 2 * 60 * 60 * 24,
    bypassCache: false, // do not bypass Cloudflare's cache
};
const parseStringAsObject = (maybeString) => typeof maybeString === 'string' ? JSON.parse(maybeString) : maybeString;
const getAssetFromKVDefaultOptions = {
    ASSET_NAMESPACE: typeof __STATIC_CONTENT !== 'undefined' ? __STATIC_CONTENT : undefined,
    ASSET_MANIFEST: typeof __STATIC_CONTENT_MANIFEST !== 'undefined'
        ? parseStringAsObject(__STATIC_CONTENT_MANIFEST)
        : {},
    cacheControl: defaultCacheControl,
    defaultMimeType: 'text/plain',
    defaultDocument: 'index.html',
    pathIsEncoded: false,
};
function assignOptions(options) {
    // Assign any missing options passed in to the default
    // options.mapRequestToAsset is handled manually later
    return Object.assign({}, getAssetFromKVDefaultOptions, options);
}
/**
 * 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
 */
const mapRequestToAsset = (request, options) => {
    options = assignOptions(options);
    const parsedUrl = new URL(request.url);
    let pathname = parsedUrl.pathname;
    if (pathname.endsWith('/')) {
        // If path looks like a directory append options.defaultDocument
        // e.g. If path is /about/ -> /about/index.html
        pathname = pathname.concat(options.defaultDocument);
    }
    else if (!mime.getType(pathname)) {
        // If path doesn't look like valid content
        //  e.g. /about.me ->  /about.me/index.html
        pathname = pathname.concat('/' + options.defaultDocument);
    }
    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, options) {
    options = assignOptions(options);
    // First apply the default handler, which already has logic to detect
    // paths that should map to HTML files.
    request = mapRequestToAsset(request, options);
    const 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 (or options.defaultDocument)
        return new Request(`${parsedUrl.origin}/${options.defaultDocument}`, 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;
const getAssetFromKV = async (event, options) => {
    options = assignOptions(options);
    const request = event.request;
    const ASSET_NAMESPACE = options.ASSET_NAMESPACE;
    const ASSET_MANIFEST = parseStringAsObject(options.ASSET_MANIFEST);
    if (typeof ASSET_NAMESPACE === 'undefined') {
        throw new types_1.InternalError(`there is no KV namespace bound to the script`);
    }
    const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, ''); // strip any preceding /'s
    let pathIsEncoded = options.pathIsEncoded;
    let requestKey;
    // if options.mapRequestToAsset is explicitly passed in, always use it and assume user has own intentions
    // otherwise handle request as normal, with default mapRequestToAsset below
    if (options.mapRequestToAsset) {
        requestKey = options.mapRequestToAsset(request);
    }
    else if (ASSET_MANIFEST[rawPathKey]) {
        requestKey = request;
    }
    else if (ASSET_MANIFEST[decodeURIComponent(rawPathKey)]) {
        pathIsEncoded = true;
        requestKey = request;
    }
    else {
        const mappedRequest = mapRequestToAsset(request);
        const mappedRawPathKey = new URL(mappedRequest.url).pathname.replace(/^\/+/, '');
        if (ASSET_MANIFEST[decodeURIComponent(mappedRawPathKey)]) {
            pathIsEncoded = true;
            requestKey = mappedRequest;
        }
        else {
            // use default mapRequestToAsset
            requestKey = mapRequestToAsset(request, options);
        }
    }
    const SUPPORTED_METHODS = ['GET', 'HEAD'];
    if (!SUPPORTED_METHODS.includes(requestKey.method)) {
        throw new types_1.MethodNotAllowedError(`${requestKey.method} is not a valid request method`);
    }
    const parsedUrl = new URL(requestKey.url);
    const pathname = pathIsEncoded ? decodeURIComponent(parsedUrl.pathname) : parsedUrl.pathname; // decode percentage encoded path only when necessary
    // pathKey is the file path to look up in the manifest
    let pathKey = pathname.replace(/^\/+/, ''); // remove prepended /
    // @ts-ignore
    const cache = caches.default;
    let mimeType = mime.getType(pathKey) || options.defaultMimeType;
    if (mimeType.startsWith('text') || mimeType === 'application/javascript') {
        mimeType += '; charset=utf-8';
    }
    let 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;
        }
    }
    // TODO this excludes search params from cache, investigate ideal behavior
    let cacheKey = new Request(`${parsedUrl.origin}/${pathKey}`, request);
    // if argument passed in for cacheControl is a function then
    // evaluate that function. otherwise return the Object passed in
    // or default Object
    const evalCacheOpts = (() => {
        switch (typeof options.cacheControl) {
            case 'function':
                return options.cacheControl(request);
            case 'object':
                return options.cacheControl;
            default:
                return defaultCacheControl;
        }
    })();
    // formats the etag depending on the response context. if the entityId
    // is invalid, returns an empty string (instead of null) to prevent the
    // the potentially disastrous scenario where the value of the Etag resp
    // header is "null". Could be modified in future to base64 encode etc
    const formatETag = (entityId = pathKey, validatorType = 'strong') => {
        if (!entityId) {
            return '';
        }
        switch (validatorType) {
            case 'weak':
                if (!entityId.startsWith('W/')) {
                    return `W/${entityId}`;
                }
                return entityId;
            case 'strong':
                if (entityId.startsWith(`W/"`)) {
                    entityId = entityId.replace('W/', '');
                }
                if (!entityId.endsWith(`"`)) {
                    entityId = `"${entityId}"`;
                }
                return entityId;
            default:
                return '';
        }
    };
    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;
    }
    // only set max-age if explicitly passed in a number as an arg
    const shouldSetBrowserCache = typeof options.cacheControl.browserTTL === 'number';
    let response = null;
    if (shouldEdgeCache) {
        response = await cache.match(cacheKey);
    }
    if (response) {
        if (response.status > 300 && response.status < 400) {
            if (response.body && 'cancel' in Object.getPrototypeOf(response.body)) {
                // Body exists and environment supports readable streams
                response.body.cancel();
            }
            else {
                // Environment doesnt support readable streams, or null repsonse body. Nothing to do
            }
            response = new Response(null, response);
        }
        else {
            // fixes #165
            let opts = {
                headers: new Headers(response.headers),
                status: 0,
                statusText: '',
            };
            opts.headers.set('cf-cache-status', 'HIT');
            if (response.status) {
                opts.status = response.status;
                opts.statusText = response.statusText;
            }
            else if (opts.headers.has('Content-Range')) {
                opts.status = 206;
                opts.statusText = 'Partial Content';
            }
            else {
                opts.status = 200;
                opts.statusText = 'OK';
            }
            response = new Response(response.body, opts);
        }
    }
    else {
        const body = await ASSET_NAMESPACE.get(pathKey, 'arrayBuffer');
        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', formatETag(pathKey, 'strong'));
            }
            // 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');
        }
    }
    response.headers.set('Content-Type', mimeType);
    if (response.status === 304) {
        let etag = formatETag(response.headers.get('etag'), 'strong');
        let ifNoneMatch = cacheKey.headers.get('if-none-match');
        let proxyCacheStatus = response.headers.get('CF-Cache-Status');
        if (etag) {
            if (ifNoneMatch && ifNoneMatch === etag && proxyCacheStatus === 'MISS') {
                response.headers.set('CF-Cache-Status', 'EXPIRED');
            }
            else {
                response.headers.set('CF-Cache-Status', 'REVALIDATED');
            }
            response.headers.set('etag', formatETag(etag, 'weak'));
        }
    }
    if (shouldSetBrowserCache) {
        response.headers.set('Cache-Control', `max-age=${options.cacheControl.browserTTL}`);
    }
    else {
        response.headers.delete('Cache-Control');
    }
    return response;
};
exports.getAssetFromKV = getAssetFromKV;