-
Notifications
You must be signed in to change notification settings - Fork 19
/
Copy pathrange-requests.mjs
380 lines (370 loc) · 13.6 KB
/
range-requests.mjs
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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
/*
* This method throws if the supplied value is not an array.
* The destructed values are required to produce a meaningful error for users.
* The destructed and restructured object is so it's clear what is
* needed.
*/
const isArray = (value, details) => {
if (!Array.isArray(value)) {
throw (({ moduleName, className, funcName, paramName }) => {
if (!moduleName || !className || !funcName || !paramName) {
throw new Error(`Unexpected input to 'not-an-array' error.`);
}
return (`The parameter '${paramName}' passed into ` +
`'${moduleName}.${className}.${funcName}()' must be an array.`);
})(details);
}
};
const hasMethod = (object, expectedMethod, details) => {
const type = typeof object[expectedMethod];
if (type !== 'function') {
details['expectedMethod'] = expectedMethod;
throw (({ expectedMethod, paramName, moduleName, className, funcName, }) => {
if (!expectedMethod ||
!paramName ||
!moduleName ||
!className ||
!funcName) {
throw new Error(`Unexpected input to 'missing-a-method' error.`);
}
return (`${moduleName}.${className}.${funcName}() expected the ` +
`'${paramName}' parameter to expose a '${expectedMethod}' method.`);
})(details);
}
};
const isType = (object, expectedType, details) => {
if (typeof object !== expectedType) {
details['expectedType'] = expectedType;
throw (({ paramName, moduleName, className, funcName, }) => {
if (!expectedType || !paramName || !moduleName || !funcName) {
throw new Error(`Unexpected input to 'incorrect-type' error.`);
}
const classNameStr = className ? `${className}.` : '';
return (`The parameter '${paramName}' passed into ` +
`'${moduleName}.${classNameStr}` +
`${funcName}()' must be of type ${expectedType}.`);
})(details);
}
};
const isInstance = (object,
// Need the general type to do the check later.
// eslint-disable-next-line @typescript-eslint/ban-types
expectedClass, details) => {
if (!(object instanceof expectedClass)) {
details['expectedClassName'] = expectedClass.name;
throw (({ expectedClassName, paramName, moduleName, className, funcName, isReturnValueProblem, }) => {
if (!expectedClassName || !moduleName || !funcName) {
throw new Error(`Unexpected input to 'incorrect-class' error.`);
}
const classNameStr = className ? `${className}.` : '';
if (isReturnValueProblem) {
return (`The return value from ` +
`'${moduleName}.${classNameStr}${funcName}()' ` +
`must be an instance of class ${expectedClassName}.`);
}
return (`The parameter '${paramName}' passed into ` +
`'${moduleName}.${classNameStr}${funcName}()' ` +
`must be an instance of class ${expectedClassName}.`);
})(details);
}
};
const isOneOf = (value, validValues, details) => {
if (!validValues.includes(value)) {
details['validValueDescription'] = `Valid values are ${JSON.stringify(validValues)}.`;
throw (({ paramName, validValueDescription, value }) => {
if (!paramName || !validValueDescription) {
throw new Error(`Unexpected input to 'invalid-value' error.`);
}
return (`The '${paramName}' parameter was given a value with an ` +
`unexpected value. ${validValueDescription} Received a value of ` +
`${JSON.stringify(value)}.`);
})(details);
}
};
const isArrayOfClass = (value,
// Need general type to do check later.
expectedClass, // eslint-disable-line
details) => {
const error = (({ value, moduleName, className, funcName, paramName, }) => {
return (`The supplied '${paramName}' parameter must be an array of ` +
`'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` +
`Please check the call to ${moduleName}.${className}.${funcName}() ` +
`to fix the issue.`);
})(details);
if (!Array.isArray(value)) {
throw error;
}
for (const item of value) {
if (!(item instanceof expectedClass)) {
throw error;
}
}
};
const assert = process.env.NODE_ENV === 'production'
? null
: {
hasMethod,
isArray,
isInstance,
isOneOf,
isType,
isArrayOfClass,
};
const logger = (process.env.NODE_ENV === 'production'
? null
: (() => {
// Don't overwrite this value if it's already set.
// See https://github.com/GoogleChrome/workbox/pull/2284#issuecomment-560470923
if (!('__WB_DISABLE_DEV_LOGS' in globalThis)) {
// @ts-ignore
globalThis.__WB_DISABLE_DEV_LOGS = false;
}
let inGroup = false;
const methodToColorMap = {
debug: `#7f8c8d`,
log: `#2ecc71`,
warn: `#f39c12`,
error: `#c0392b`,
groupCollapsed: `#3498db`,
groupEnd: null, // No colored prefix on groupEnd
};
const print = function (method, args) {
// @ts-ignore
if (globalThis.__WB_DISABLE_DEV_LOGS) {
return;
}
if (method === 'groupCollapsed') {
// Safari doesn't print all console.groupCollapsed() arguments:
// https://bugs.webkit.org/show_bug.cgi?id=182754
if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {
// @ts-ignore
console[method](...args);
return;
}
}
const styles = [
`background: ${methodToColorMap[method]}`,
`border-radius: 0.5em`,
`color: white`,
`font-weight: bold`,
`padding: 2px 0.5em`,
];
// When in a group, the workbox prefix is not displayed.
const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')];
// @ts-ignore
console[method](...logPrefix, ...args);
if (method === 'groupCollapsed') {
inGroup = true;
}
if (method === 'groupEnd') {
inGroup = false;
}
};
// eslint-disable-next-line @typescript-eslint/ban-types
const api = {};
const loggerMethods = Object.keys(methodToColorMap);
for (const key of loggerMethods) {
const method = key;
api[method] = (...args) => {
print(method, args);
};
}
return api;
})());
/**
* @param {string} rangeHeader A Range: header value.
* @return {Object} An object with `start` and `end` properties, reflecting
* the parsed value of the Range: header. If either the `start` or `end` are
* omitted, then `null` will be returned.
*
* @private
*/
export function parseRangeHeader(rangeHeader) {
const normalizedRangeHeader = rangeHeader.trim().toLowerCase();
if (!normalizedRangeHeader.startsWith('bytes=')) {
throw (({ normalizedRangeHeader }) => {
if (!normalizedRangeHeader) {
throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`);
}
return (`The 'unit' portion of the Range header must be set to 'bytes'. ` +
`The Range header provided was "${normalizedRangeHeader}"`)
})({ normalizedRangeHeader });
}
// Specifying multiple ranges separate by commas is valid syntax, but this
// library only attempts to handle a single, contiguous sequence of bytes.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range#Syntax
if (normalizedRangeHeader.includes(',')) {
throw (({ normalizedRangeHeader }) => {
if (!normalizedRangeHeader) {
throw new Error(`Unexpected input to 'single-range-only' error.`);
}
return (`Multiple ranges are not supported. Please use a single start ` +
`value, and optional end value. The Range header provided was ` +
`"${normalizedRangeHeader}"`);
})({ normalizedRangeHeader });
}
const rangeParts = /(\d*)-(\d*)/.exec(normalizedRangeHeader);
// We need either at least one of the start or end values.
if (!rangeParts || !(rangeParts[1] || rangeParts[2])) {
throw (({ normalizedRangeHeader }) => {
if (!normalizedRangeHeader) {
throw new Error(`Unexpected input to 'invalid-range-values' error.`);
}
return (`The Range header is missing both start and end values. At least ` +
`one of those values is needed. The Range header provided was ` +
`"${normalizedRangeHeader}"`);
})({ normalizedRangeHeader });
}
return {
start: rangeParts[1] === '' ? undefined : Number(rangeParts[1]),
end: rangeParts[2] === '' ? undefined : Number(rangeParts[2]),
};
}
/**
* @param {Blob} blob A source blob.
* @param {number} [start] The offset to use as the start of the
* slice.
* @param {number} [end] The offset to use as the end of the slice.
* @return {Object} An object with `start` and `end` properties, reflecting
* the effective boundaries to use given the size of the blob.
*
* @private
*/
export function calculateEffectiveBoundaries(blob, start, end) {
if (process.env.NODE_ENV !== 'production') {
assert.isInstance(blob, Blob, {
moduleName: 'workbox-range-requests',
funcName: 'calculateEffectiveBoundaries',
paramName: 'blob',
});
}
const blobSize = blob.size;
if ((end && end > blobSize) || (start && start < 0)) {
throw ((start, end, size ) => {
return (`The start (${start}) and end (${end}) values in the Range are ` +
`not satisfiable by the cached response, which is ${size} bytes.`);
})({ start, end, size: blobSize });
}
let effectiveStart;
let effectiveEnd;
if (start !== undefined && end !== undefined) {
effectiveStart = start;
// Range values are inclusive, so add 1 to the value.
effectiveEnd = end + 1;
}
else if (start !== undefined && end === undefined) {
effectiveStart = start;
effectiveEnd = blobSize;
}
else if (end !== undefined && start === undefined) {
effectiveStart = blobSize - end;
effectiveEnd = blobSize;
}
return {
start: effectiveStart,
end: effectiveEnd,
};
}
/**
* Given a `Request` and `Response` objects as input, this will return a
* promise for a new `Response`.
*
* If the original `Response` already contains partial content (i.e. it has
* a status of 206), then this assumes it already fulfills the `Range:`
* requirements, and will return it as-is.
*
* @param {Request} request A request, which should contain a Range:
* header.
* @param {Response} originalResponse A response.
* @return {Promise<Response>} Either a `206 Partial Content` response, with
* the response body set to the slice of content specified by the request's
* `Range:` header, or a `416 Range Not Satisfiable` response if the
* conditions of the `Range:` header can't be met.
*
* @memberof workbox-range-requests
*/
export async function createPartialResponse(request, originalResponse) {
try {
if (originalResponse.status === 206) {
// If we already have a 206, then just pass it through as-is;
// see https://github.com/GoogleChrome/workbox/issues/1720
return originalResponse;
}
const rangeHeader = request.headers.get('range');
if (!rangeHeader) {
throw (() => {
return `No Range header was found in the Request provided.`;
})();
}
const boundaries = parseRangeHeader(rangeHeader);
const originalBlob = await originalResponse.blob();
const effectiveBoundaries = calculateEffectiveBoundaries(originalBlob, boundaries.start, boundaries.end);
const slicedBlob = originalBlob.slice(effectiveBoundaries.start, effectiveBoundaries.end);
const slicedBlobSize = slicedBlob.size;
const slicedResponse = new Response(slicedBlob, {
// Status code 206 is for a Partial Content response.
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206
status: 206,
statusText: 'Partial Content',
headers: originalResponse.headers,
});
slicedResponse.headers.set('Content-Length', String(slicedBlobSize));
slicedResponse.headers.set('Content-Range', `bytes ${effectiveBoundaries.start}-${effectiveBoundaries.end - 1}/` +
`${originalBlob.size}`);
return slicedResponse;
}
catch (error) {
if (process.env.NODE_ENV !== 'production') {
logger.warn(`Unable to construct a partial response; returning a ` +
`416 Range Not Satisfiable response instead.`);
logger.groupCollapsed(`View details here.`);
logger.log(error);
logger.log(request);
logger.log(originalResponse);
logger.groupEnd();
}
return new Response('', {
status: 416,
statusText: 'Range Not Satisfiable',
});
}
}
/**
* The range request plugin makes it easy for a request with a 'Range' header to
* be fulfilled by a cached response.
*
* It does this by intercepting the `cachedResponseWillBeUsed` plugin callback
* and returning the appropriate subset of the cached response body.
*
* @memberof workbox-range-requests
*/
export default class RangeRequestsPlugin {
/**
* @param {Object} options
* @param {Request} options.request The original request, which may or may not
* contain a Range: header.
* @param {Response} options.cachedResponse The complete cached response.
* @return {Promise<Response>} If request contains a 'Range' header, then a
* new response with status 206 whose body is a subset of `cachedResponse` is
* returned. Otherwise, `cachedResponse` is returned as-is.
*
* @private
*/
cachedResponseWillBeUsed = async ({
request,
cachedResponse,
}) => {
// Only return a sliced response if there's something valid in the cache,
// and there's a Range: header in the request.
if (cachedResponse && request.headers.has('range')) {
return await createPartialResponse(request, cachedResponse);
}
// If there was no Range: header, or if cachedResponse wasn't valid, just
// pass it through as-is.
return cachedResponse;
};
}
export function urlPattern({ request }) {
const { destination } = request;
return destination === 'video' || destination === 'audio';
}