1'use strict' 2 3const { kConstruct } = require('./symbols') 4const { urlEquals, fieldValues: getFieldValues } = require('./util') 5const { kEnumerableProperty, isDisturbed } = require('../core/util') 6const { kHeadersList } = require('../core/symbols') 7const { webidl } = require('../fetch/webidl') 8const { Response, cloneResponse } = require('../fetch/response') 9const { Request } = require('../fetch/request') 10const { kState, kHeaders, kGuard, kRealm } = require('../fetch/symbols') 11const { fetching } = require('../fetch/index') 12const { urlIsHttpHttpsScheme, createDeferredPromise, readAllBytes } = require('../fetch/util') 13const assert = require('assert') 14const { getGlobalDispatcher } = require('../global') 15 16/** 17 * @see https://w3c.github.io/ServiceWorker/#dfn-cache-batch-operation 18 * @typedef {Object} CacheBatchOperation 19 * @property {'delete' | 'put'} type 20 * @property {any} request 21 * @property {any} response 22 * @property {import('../../types/cache').CacheQueryOptions} options 23 */ 24 25/** 26 * @see https://w3c.github.io/ServiceWorker/#dfn-request-response-list 27 * @typedef {[any, any][]} requestResponseList 28 */ 29 30class Cache { 31 /** 32 * @see https://w3c.github.io/ServiceWorker/#dfn-relevant-request-response-list 33 * @type {requestResponseList} 34 */ 35 #relevantRequestResponseList 36 37 constructor () { 38 if (arguments[0] !== kConstruct) { 39 webidl.illegalConstructor() 40 } 41 42 this.#relevantRequestResponseList = arguments[1] 43 } 44 45 async match (request, options = {}) { 46 webidl.brandCheck(this, Cache) 47 webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.match' }) 48 49 request = webidl.converters.RequestInfo(request) 50 options = webidl.converters.CacheQueryOptions(options) 51 52 const p = await this.matchAll(request, options) 53 54 if (p.length === 0) { 55 return 56 } 57 58 return p[0] 59 } 60 61 async matchAll (request = undefined, options = {}) { 62 webidl.brandCheck(this, Cache) 63 64 if (request !== undefined) request = webidl.converters.RequestInfo(request) 65 options = webidl.converters.CacheQueryOptions(options) 66 67 // 1. 68 let r = null 69 70 // 2. 71 if (request !== undefined) { 72 if (request instanceof Request) { 73 // 2.1.1 74 r = request[kState] 75 76 // 2.1.2 77 if (r.method !== 'GET' && !options.ignoreMethod) { 78 return [] 79 } 80 } else if (typeof request === 'string') { 81 // 2.2.1 82 r = new Request(request)[kState] 83 } 84 } 85 86 // 5. 87 // 5.1 88 const responses = [] 89 90 // 5.2 91 if (request === undefined) { 92 // 5.2.1 93 for (const requestResponse of this.#relevantRequestResponseList) { 94 responses.push(requestResponse[1]) 95 } 96 } else { // 5.3 97 // 5.3.1 98 const requestResponses = this.#queryCache(r, options) 99 100 // 5.3.2 101 for (const requestResponse of requestResponses) { 102 responses.push(requestResponse[1]) 103 } 104 } 105 106 // 5.4 107 // We don't implement CORs so we don't need to loop over the responses, yay! 108 109 // 5.5.1 110 const responseList = [] 111 112 // 5.5.2 113 for (const response of responses) { 114 // 5.5.2.1 115 const responseObject = new Response(response.body?.source ?? null) 116 const body = responseObject[kState].body 117 responseObject[kState] = response 118 responseObject[kState].body = body 119 responseObject[kHeaders][kHeadersList] = response.headersList 120 responseObject[kHeaders][kGuard] = 'immutable' 121 122 responseList.push(responseObject) 123 } 124 125 // 6. 126 return Object.freeze(responseList) 127 } 128 129 async add (request) { 130 webidl.brandCheck(this, Cache) 131 webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.add' }) 132 133 request = webidl.converters.RequestInfo(request) 134 135 // 1. 136 const requests = [request] 137 138 // 2. 139 const responseArrayPromise = this.addAll(requests) 140 141 // 3. 142 return await responseArrayPromise 143 } 144 145 async addAll (requests) { 146 webidl.brandCheck(this, Cache) 147 webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.addAll' }) 148 149 requests = webidl.converters['sequence<RequestInfo>'](requests) 150 151 // 1. 152 const responsePromises = [] 153 154 // 2. 155 const requestList = [] 156 157 // 3. 158 for (const request of requests) { 159 if (typeof request === 'string') { 160 continue 161 } 162 163 // 3.1 164 const r = request[kState] 165 166 // 3.2 167 if (!urlIsHttpHttpsScheme(r.url) || r.method !== 'GET') { 168 throw webidl.errors.exception({ 169 header: 'Cache.addAll', 170 message: 'Expected http/s scheme when method is not GET.' 171 }) 172 } 173 } 174 175 // 4. 176 /** @type {ReturnType<typeof fetching>[]} */ 177 const fetchControllers = [] 178 179 // 5. 180 for (const request of requests) { 181 // 5.1 182 const r = new Request(request)[kState] 183 184 // 5.2 185 if (!urlIsHttpHttpsScheme(r.url)) { 186 throw webidl.errors.exception({ 187 header: 'Cache.addAll', 188 message: 'Expected http/s scheme.' 189 }) 190 } 191 192 // 5.4 193 r.initiator = 'fetch' 194 r.destination = 'subresource' 195 196 // 5.5 197 requestList.push(r) 198 199 // 5.6 200 const responsePromise = createDeferredPromise() 201 202 // 5.7 203 fetchControllers.push(fetching({ 204 request: r, 205 dispatcher: getGlobalDispatcher(), 206 processResponse (response) { 207 // 1. 208 if (response.type === 'error' || response.status === 206 || response.status < 200 || response.status > 299) { 209 responsePromise.reject(webidl.errors.exception({ 210 header: 'Cache.addAll', 211 message: 'Received an invalid status code or the request failed.' 212 })) 213 } else if (response.headersList.contains('vary')) { // 2. 214 // 2.1 215 const fieldValues = getFieldValues(response.headersList.get('vary')) 216 217 // 2.2 218 for (const fieldValue of fieldValues) { 219 // 2.2.1 220 if (fieldValue === '*') { 221 responsePromise.reject(webidl.errors.exception({ 222 header: 'Cache.addAll', 223 message: 'invalid vary field value' 224 })) 225 226 for (const controller of fetchControllers) { 227 controller.abort() 228 } 229 230 return 231 } 232 } 233 } 234 }, 235 processResponseEndOfBody (response) { 236 // 1. 237 if (response.aborted) { 238 responsePromise.reject(new DOMException('aborted', 'AbortError')) 239 return 240 } 241 242 // 2. 243 responsePromise.resolve(response) 244 } 245 })) 246 247 // 5.8 248 responsePromises.push(responsePromise.promise) 249 } 250 251 // 6. 252 const p = Promise.all(responsePromises) 253 254 // 7. 255 const responses = await p 256 257 // 7.1 258 const operations = [] 259 260 // 7.2 261 let index = 0 262 263 // 7.3 264 for (const response of responses) { 265 // 7.3.1 266 /** @type {CacheBatchOperation} */ 267 const operation = { 268 type: 'put', // 7.3.2 269 request: requestList[index], // 7.3.3 270 response // 7.3.4 271 } 272 273 operations.push(operation) // 7.3.5 274 275 index++ // 7.3.6 276 } 277 278 // 7.5 279 const cacheJobPromise = createDeferredPromise() 280 281 // 7.6.1 282 let errorData = null 283 284 // 7.6.2 285 try { 286 this.#batchCacheOperations(operations) 287 } catch (e) { 288 errorData = e 289 } 290 291 // 7.6.3 292 queueMicrotask(() => { 293 // 7.6.3.1 294 if (errorData === null) { 295 cacheJobPromise.resolve(undefined) 296 } else { 297 // 7.6.3.2 298 cacheJobPromise.reject(errorData) 299 } 300 }) 301 302 // 7.7 303 return cacheJobPromise.promise 304 } 305 306 async put (request, response) { 307 webidl.brandCheck(this, Cache) 308 webidl.argumentLengthCheck(arguments, 2, { header: 'Cache.put' }) 309 310 request = webidl.converters.RequestInfo(request) 311 response = webidl.converters.Response(response) 312 313 // 1. 314 let innerRequest = null 315 316 // 2. 317 if (request instanceof Request) { 318 innerRequest = request[kState] 319 } else { // 3. 320 innerRequest = new Request(request)[kState] 321 } 322 323 // 4. 324 if (!urlIsHttpHttpsScheme(innerRequest.url) || innerRequest.method !== 'GET') { 325 throw webidl.errors.exception({ 326 header: 'Cache.put', 327 message: 'Expected an http/s scheme when method is not GET' 328 }) 329 } 330 331 // 5. 332 const innerResponse = response[kState] 333 334 // 6. 335 if (innerResponse.status === 206) { 336 throw webidl.errors.exception({ 337 header: 'Cache.put', 338 message: 'Got 206 status' 339 }) 340 } 341 342 // 7. 343 if (innerResponse.headersList.contains('vary')) { 344 // 7.1. 345 const fieldValues = getFieldValues(innerResponse.headersList.get('vary')) 346 347 // 7.2. 348 for (const fieldValue of fieldValues) { 349 // 7.2.1 350 if (fieldValue === '*') { 351 throw webidl.errors.exception({ 352 header: 'Cache.put', 353 message: 'Got * vary field value' 354 }) 355 } 356 } 357 } 358 359 // 8. 360 if (innerResponse.body && (isDisturbed(innerResponse.body.stream) || innerResponse.body.stream.locked)) { 361 throw webidl.errors.exception({ 362 header: 'Cache.put', 363 message: 'Response body is locked or disturbed' 364 }) 365 } 366 367 // 9. 368 const clonedResponse = cloneResponse(innerResponse) 369 370 // 10. 371 const bodyReadPromise = createDeferredPromise() 372 373 // 11. 374 if (innerResponse.body != null) { 375 // 11.1 376 const stream = innerResponse.body.stream 377 378 // 11.2 379 const reader = stream.getReader() 380 381 // 11.3 382 readAllBytes(reader).then(bodyReadPromise.resolve, bodyReadPromise.reject) 383 } else { 384 bodyReadPromise.resolve(undefined) 385 } 386 387 // 12. 388 /** @type {CacheBatchOperation[]} */ 389 const operations = [] 390 391 // 13. 392 /** @type {CacheBatchOperation} */ 393 const operation = { 394 type: 'put', // 14. 395 request: innerRequest, // 15. 396 response: clonedResponse // 16. 397 } 398 399 // 17. 400 operations.push(operation) 401 402 // 19. 403 const bytes = await bodyReadPromise.promise 404 405 if (clonedResponse.body != null) { 406 clonedResponse.body.source = bytes 407 } 408 409 // 19.1 410 const cacheJobPromise = createDeferredPromise() 411 412 // 19.2.1 413 let errorData = null 414 415 // 19.2.2 416 try { 417 this.#batchCacheOperations(operations) 418 } catch (e) { 419 errorData = e 420 } 421 422 // 19.2.3 423 queueMicrotask(() => { 424 // 19.2.3.1 425 if (errorData === null) { 426 cacheJobPromise.resolve() 427 } else { // 19.2.3.2 428 cacheJobPromise.reject(errorData) 429 } 430 }) 431 432 return cacheJobPromise.promise 433 } 434 435 async delete (request, options = {}) { 436 webidl.brandCheck(this, Cache) 437 webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.delete' }) 438 439 request = webidl.converters.RequestInfo(request) 440 options = webidl.converters.CacheQueryOptions(options) 441 442 /** 443 * @type {Request} 444 */ 445 let r = null 446 447 if (request instanceof Request) { 448 r = request[kState] 449 450 if (r.method !== 'GET' && !options.ignoreMethod) { 451 return false 452 } 453 } else { 454 assert(typeof request === 'string') 455 456 r = new Request(request)[kState] 457 } 458 459 /** @type {CacheBatchOperation[]} */ 460 const operations = [] 461 462 /** @type {CacheBatchOperation} */ 463 const operation = { 464 type: 'delete', 465 request: r, 466 options 467 } 468 469 operations.push(operation) 470 471 const cacheJobPromise = createDeferredPromise() 472 473 let errorData = null 474 let requestResponses 475 476 try { 477 requestResponses = this.#batchCacheOperations(operations) 478 } catch (e) { 479 errorData = e 480 } 481 482 queueMicrotask(() => { 483 if (errorData === null) { 484 cacheJobPromise.resolve(!!requestResponses?.length) 485 } else { 486 cacheJobPromise.reject(errorData) 487 } 488 }) 489 490 return cacheJobPromise.promise 491 } 492 493 /** 494 * @see https://w3c.github.io/ServiceWorker/#dom-cache-keys 495 * @param {any} request 496 * @param {import('../../types/cache').CacheQueryOptions} options 497 * @returns {readonly Request[]} 498 */ 499 async keys (request = undefined, options = {}) { 500 webidl.brandCheck(this, Cache) 501 502 if (request !== undefined) request = webidl.converters.RequestInfo(request) 503 options = webidl.converters.CacheQueryOptions(options) 504 505 // 1. 506 let r = null 507 508 // 2. 509 if (request !== undefined) { 510 // 2.1 511 if (request instanceof Request) { 512 // 2.1.1 513 r = request[kState] 514 515 // 2.1.2 516 if (r.method !== 'GET' && !options.ignoreMethod) { 517 return [] 518 } 519 } else if (typeof request === 'string') { // 2.2 520 r = new Request(request)[kState] 521 } 522 } 523 524 // 4. 525 const promise = createDeferredPromise() 526 527 // 5. 528 // 5.1 529 const requests = [] 530 531 // 5.2 532 if (request === undefined) { 533 // 5.2.1 534 for (const requestResponse of this.#relevantRequestResponseList) { 535 // 5.2.1.1 536 requests.push(requestResponse[0]) 537 } 538 } else { // 5.3 539 // 5.3.1 540 const requestResponses = this.#queryCache(r, options) 541 542 // 5.3.2 543 for (const requestResponse of requestResponses) { 544 // 5.3.2.1 545 requests.push(requestResponse[0]) 546 } 547 } 548 549 // 5.4 550 queueMicrotask(() => { 551 // 5.4.1 552 const requestList = [] 553 554 // 5.4.2 555 for (const request of requests) { 556 const requestObject = new Request('https://a') 557 requestObject[kState] = request 558 requestObject[kHeaders][kHeadersList] = request.headersList 559 requestObject[kHeaders][kGuard] = 'immutable' 560 requestObject[kRealm] = request.client 561 562 // 5.4.2.1 563 requestList.push(requestObject) 564 } 565 566 // 5.4.3 567 promise.resolve(Object.freeze(requestList)) 568 }) 569 570 return promise.promise 571 } 572 573 /** 574 * @see https://w3c.github.io/ServiceWorker/#batch-cache-operations-algorithm 575 * @param {CacheBatchOperation[]} operations 576 * @returns {requestResponseList} 577 */ 578 #batchCacheOperations (operations) { 579 // 1. 580 const cache = this.#relevantRequestResponseList 581 582 // 2. 583 const backupCache = [...cache] 584 585 // 3. 586 const addedItems = [] 587 588 // 4.1 589 const resultList = [] 590 591 try { 592 // 4.2 593 for (const operation of operations) { 594 // 4.2.1 595 if (operation.type !== 'delete' && operation.type !== 'put') { 596 throw webidl.errors.exception({ 597 header: 'Cache.#batchCacheOperations', 598 message: 'operation type does not match "delete" or "put"' 599 }) 600 } 601 602 // 4.2.2 603 if (operation.type === 'delete' && operation.response != null) { 604 throw webidl.errors.exception({ 605 header: 'Cache.#batchCacheOperations', 606 message: 'delete operation should not have an associated response' 607 }) 608 } 609 610 // 4.2.3 611 if (this.#queryCache(operation.request, operation.options, addedItems).length) { 612 throw new DOMException('???', 'InvalidStateError') 613 } 614 615 // 4.2.4 616 let requestResponses 617 618 // 4.2.5 619 if (operation.type === 'delete') { 620 // 4.2.5.1 621 requestResponses = this.#queryCache(operation.request, operation.options) 622 623 // TODO: the spec is wrong, this is needed to pass WPTs 624 if (requestResponses.length === 0) { 625 return [] 626 } 627 628 // 4.2.5.2 629 for (const requestResponse of requestResponses) { 630 const idx = cache.indexOf(requestResponse) 631 assert(idx !== -1) 632 633 // 4.2.5.2.1 634 cache.splice(idx, 1) 635 } 636 } else if (operation.type === 'put') { // 4.2.6 637 // 4.2.6.1 638 if (operation.response == null) { 639 throw webidl.errors.exception({ 640 header: 'Cache.#batchCacheOperations', 641 message: 'put operation should have an associated response' 642 }) 643 } 644 645 // 4.2.6.2 646 const r = operation.request 647 648 // 4.2.6.3 649 if (!urlIsHttpHttpsScheme(r.url)) { 650 throw webidl.errors.exception({ 651 header: 'Cache.#batchCacheOperations', 652 message: 'expected http or https scheme' 653 }) 654 } 655 656 // 4.2.6.4 657 if (r.method !== 'GET') { 658 throw webidl.errors.exception({ 659 header: 'Cache.#batchCacheOperations', 660 message: 'not get method' 661 }) 662 } 663 664 // 4.2.6.5 665 if (operation.options != null) { 666 throw webidl.errors.exception({ 667 header: 'Cache.#batchCacheOperations', 668 message: 'options must not be defined' 669 }) 670 } 671 672 // 4.2.6.6 673 requestResponses = this.#queryCache(operation.request) 674 675 // 4.2.6.7 676 for (const requestResponse of requestResponses) { 677 const idx = cache.indexOf(requestResponse) 678 assert(idx !== -1) 679 680 // 4.2.6.7.1 681 cache.splice(idx, 1) 682 } 683 684 // 4.2.6.8 685 cache.push([operation.request, operation.response]) 686 687 // 4.2.6.10 688 addedItems.push([operation.request, operation.response]) 689 } 690 691 // 4.2.7 692 resultList.push([operation.request, operation.response]) 693 } 694 695 // 4.3 696 return resultList 697 } catch (e) { // 5. 698 // 5.1 699 this.#relevantRequestResponseList.length = 0 700 701 // 5.2 702 this.#relevantRequestResponseList = backupCache 703 704 // 5.3 705 throw e 706 } 707 } 708 709 /** 710 * @see https://w3c.github.io/ServiceWorker/#query-cache 711 * @param {any} requestQuery 712 * @param {import('../../types/cache').CacheQueryOptions} options 713 * @param {requestResponseList} targetStorage 714 * @returns {requestResponseList} 715 */ 716 #queryCache (requestQuery, options, targetStorage) { 717 /** @type {requestResponseList} */ 718 const resultList = [] 719 720 const storage = targetStorage ?? this.#relevantRequestResponseList 721 722 for (const requestResponse of storage) { 723 const [cachedRequest, cachedResponse] = requestResponse 724 if (this.#requestMatchesCachedItem(requestQuery, cachedRequest, cachedResponse, options)) { 725 resultList.push(requestResponse) 726 } 727 } 728 729 return resultList 730 } 731 732 /** 733 * @see https://w3c.github.io/ServiceWorker/#request-matches-cached-item-algorithm 734 * @param {any} requestQuery 735 * @param {any} request 736 * @param {any | null} response 737 * @param {import('../../types/cache').CacheQueryOptions | undefined} options 738 * @returns {boolean} 739 */ 740 #requestMatchesCachedItem (requestQuery, request, response = null, options) { 741 // if (options?.ignoreMethod === false && request.method === 'GET') { 742 // return false 743 // } 744 745 const queryURL = new URL(requestQuery.url) 746 747 const cachedURL = new URL(request.url) 748 749 if (options?.ignoreSearch) { 750 cachedURL.search = '' 751 752 queryURL.search = '' 753 } 754 755 if (!urlEquals(queryURL, cachedURL, true)) { 756 return false 757 } 758 759 if ( 760 response == null || 761 options?.ignoreVary || 762 !response.headersList.contains('vary') 763 ) { 764 return true 765 } 766 767 const fieldValues = getFieldValues(response.headersList.get('vary')) 768 769 for (const fieldValue of fieldValues) { 770 if (fieldValue === '*') { 771 return false 772 } 773 774 const requestValue = request.headersList.get(fieldValue) 775 const queryValue = requestQuery.headersList.get(fieldValue) 776 777 // If one has the header and the other doesn't, or one has 778 // a different value than the other, return false 779 if (requestValue !== queryValue) { 780 return false 781 } 782 } 783 784 return true 785 } 786} 787 788Object.defineProperties(Cache.prototype, { 789 [Symbol.toStringTag]: { 790 value: 'Cache', 791 configurable: true 792 }, 793 match: kEnumerableProperty, 794 matchAll: kEnumerableProperty, 795 add: kEnumerableProperty, 796 addAll: kEnumerableProperty, 797 put: kEnumerableProperty, 798 delete: kEnumerableProperty, 799 keys: kEnumerableProperty 800}) 801 802const cacheQueryOptionConverters = [ 803 { 804 key: 'ignoreSearch', 805 converter: webidl.converters.boolean, 806 defaultValue: false 807 }, 808 { 809 key: 'ignoreMethod', 810 converter: webidl.converters.boolean, 811 defaultValue: false 812 }, 813 { 814 key: 'ignoreVary', 815 converter: webidl.converters.boolean, 816 defaultValue: false 817 } 818] 819 820webidl.converters.CacheQueryOptions = webidl.dictionaryConverter(cacheQueryOptionConverters) 821 822webidl.converters.MultiCacheQueryOptions = webidl.dictionaryConverter([ 823 ...cacheQueryOptionConverters, 824 { 825 key: 'cacheName', 826 converter: webidl.converters.DOMString 827 } 828]) 829 830webidl.converters.Response = webidl.interfaceConverter(Response) 831 832webidl.converters['sequence<RequestInfo>'] = webidl.sequenceConverter( 833 webidl.converters.RequestInfo 834) 835 836module.exports = { 837 Cache 838} 839