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( 383 reader, 384 (bytes) => bodyReadPromise.resolve(bytes), 385 (error) => bodyReadPromise.reject(error) 386 ) 387 } else { 388 bodyReadPromise.resolve(undefined) 389 } 390 391 // 12. 392 /** @type {CacheBatchOperation[]} */ 393 const operations = [] 394 395 // 13. 396 /** @type {CacheBatchOperation} */ 397 const operation = { 398 type: 'put', // 14. 399 request: innerRequest, // 15. 400 response: clonedResponse // 16. 401 } 402 403 // 17. 404 operations.push(operation) 405 406 // 19. 407 const bytes = await bodyReadPromise.promise 408 409 if (clonedResponse.body != null) { 410 clonedResponse.body.source = bytes 411 } 412 413 // 19.1 414 const cacheJobPromise = createDeferredPromise() 415 416 // 19.2.1 417 let errorData = null 418 419 // 19.2.2 420 try { 421 this.#batchCacheOperations(operations) 422 } catch (e) { 423 errorData = e 424 } 425 426 // 19.2.3 427 queueMicrotask(() => { 428 // 19.2.3.1 429 if (errorData === null) { 430 cacheJobPromise.resolve() 431 } else { // 19.2.3.2 432 cacheJobPromise.reject(errorData) 433 } 434 }) 435 436 return cacheJobPromise.promise 437 } 438 439 async delete (request, options = {}) { 440 webidl.brandCheck(this, Cache) 441 webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.delete' }) 442 443 request = webidl.converters.RequestInfo(request) 444 options = webidl.converters.CacheQueryOptions(options) 445 446 /** 447 * @type {Request} 448 */ 449 let r = null 450 451 if (request instanceof Request) { 452 r = request[kState] 453 454 if (r.method !== 'GET' && !options.ignoreMethod) { 455 return false 456 } 457 } else { 458 assert(typeof request === 'string') 459 460 r = new Request(request)[kState] 461 } 462 463 /** @type {CacheBatchOperation[]} */ 464 const operations = [] 465 466 /** @type {CacheBatchOperation} */ 467 const operation = { 468 type: 'delete', 469 request: r, 470 options 471 } 472 473 operations.push(operation) 474 475 const cacheJobPromise = createDeferredPromise() 476 477 let errorData = null 478 let requestResponses 479 480 try { 481 requestResponses = this.#batchCacheOperations(operations) 482 } catch (e) { 483 errorData = e 484 } 485 486 queueMicrotask(() => { 487 if (errorData === null) { 488 cacheJobPromise.resolve(!!requestResponses?.length) 489 } else { 490 cacheJobPromise.reject(errorData) 491 } 492 }) 493 494 return cacheJobPromise.promise 495 } 496 497 /** 498 * @see https://w3c.github.io/ServiceWorker/#dom-cache-keys 499 * @param {any} request 500 * @param {import('../../types/cache').CacheQueryOptions} options 501 * @returns {readonly Request[]} 502 */ 503 async keys (request = undefined, options = {}) { 504 webidl.brandCheck(this, Cache) 505 506 if (request !== undefined) request = webidl.converters.RequestInfo(request) 507 options = webidl.converters.CacheQueryOptions(options) 508 509 // 1. 510 let r = null 511 512 // 2. 513 if (request !== undefined) { 514 // 2.1 515 if (request instanceof Request) { 516 // 2.1.1 517 r = request[kState] 518 519 // 2.1.2 520 if (r.method !== 'GET' && !options.ignoreMethod) { 521 return [] 522 } 523 } else if (typeof request === 'string') { // 2.2 524 r = new Request(request)[kState] 525 } 526 } 527 528 // 4. 529 const promise = createDeferredPromise() 530 531 // 5. 532 // 5.1 533 const requests = [] 534 535 // 5.2 536 if (request === undefined) { 537 // 5.2.1 538 for (const requestResponse of this.#relevantRequestResponseList) { 539 // 5.2.1.1 540 requests.push(requestResponse[0]) 541 } 542 } else { // 5.3 543 // 5.3.1 544 const requestResponses = this.#queryCache(r, options) 545 546 // 5.3.2 547 for (const requestResponse of requestResponses) { 548 // 5.3.2.1 549 requests.push(requestResponse[0]) 550 } 551 } 552 553 // 5.4 554 queueMicrotask(() => { 555 // 5.4.1 556 const requestList = [] 557 558 // 5.4.2 559 for (const request of requests) { 560 const requestObject = new Request('https://a') 561 requestObject[kState] = request 562 requestObject[kHeaders][kHeadersList] = request.headersList 563 requestObject[kHeaders][kGuard] = 'immutable' 564 requestObject[kRealm] = request.client 565 566 // 5.4.2.1 567 requestList.push(requestObject) 568 } 569 570 // 5.4.3 571 promise.resolve(Object.freeze(requestList)) 572 }) 573 574 return promise.promise 575 } 576 577 /** 578 * @see https://w3c.github.io/ServiceWorker/#batch-cache-operations-algorithm 579 * @param {CacheBatchOperation[]} operations 580 * @returns {requestResponseList} 581 */ 582 #batchCacheOperations (operations) { 583 // 1. 584 const cache = this.#relevantRequestResponseList 585 586 // 2. 587 const backupCache = [...cache] 588 589 // 3. 590 const addedItems = [] 591 592 // 4.1 593 const resultList = [] 594 595 try { 596 // 4.2 597 for (const operation of operations) { 598 // 4.2.1 599 if (operation.type !== 'delete' && operation.type !== 'put') { 600 throw webidl.errors.exception({ 601 header: 'Cache.#batchCacheOperations', 602 message: 'operation type does not match "delete" or "put"' 603 }) 604 } 605 606 // 4.2.2 607 if (operation.type === 'delete' && operation.response != null) { 608 throw webidl.errors.exception({ 609 header: 'Cache.#batchCacheOperations', 610 message: 'delete operation should not have an associated response' 611 }) 612 } 613 614 // 4.2.3 615 if (this.#queryCache(operation.request, operation.options, addedItems).length) { 616 throw new DOMException('???', 'InvalidStateError') 617 } 618 619 // 4.2.4 620 let requestResponses 621 622 // 4.2.5 623 if (operation.type === 'delete') { 624 // 4.2.5.1 625 requestResponses = this.#queryCache(operation.request, operation.options) 626 627 // TODO: the spec is wrong, this is needed to pass WPTs 628 if (requestResponses.length === 0) { 629 return [] 630 } 631 632 // 4.2.5.2 633 for (const requestResponse of requestResponses) { 634 const idx = cache.indexOf(requestResponse) 635 assert(idx !== -1) 636 637 // 4.2.5.2.1 638 cache.splice(idx, 1) 639 } 640 } else if (operation.type === 'put') { // 4.2.6 641 // 4.2.6.1 642 if (operation.response == null) { 643 throw webidl.errors.exception({ 644 header: 'Cache.#batchCacheOperations', 645 message: 'put operation should have an associated response' 646 }) 647 } 648 649 // 4.2.6.2 650 const r = operation.request 651 652 // 4.2.6.3 653 if (!urlIsHttpHttpsScheme(r.url)) { 654 throw webidl.errors.exception({ 655 header: 'Cache.#batchCacheOperations', 656 message: 'expected http or https scheme' 657 }) 658 } 659 660 // 4.2.6.4 661 if (r.method !== 'GET') { 662 throw webidl.errors.exception({ 663 header: 'Cache.#batchCacheOperations', 664 message: 'not get method' 665 }) 666 } 667 668 // 4.2.6.5 669 if (operation.options != null) { 670 throw webidl.errors.exception({ 671 header: 'Cache.#batchCacheOperations', 672 message: 'options must not be defined' 673 }) 674 } 675 676 // 4.2.6.6 677 requestResponses = this.#queryCache(operation.request) 678 679 // 4.2.6.7 680 for (const requestResponse of requestResponses) { 681 const idx = cache.indexOf(requestResponse) 682 assert(idx !== -1) 683 684 // 4.2.6.7.1 685 cache.splice(idx, 1) 686 } 687 688 // 4.2.6.8 689 cache.push([operation.request, operation.response]) 690 691 // 4.2.6.10 692 addedItems.push([operation.request, operation.response]) 693 } 694 695 // 4.2.7 696 resultList.push([operation.request, operation.response]) 697 } 698 699 // 4.3 700 return resultList 701 } catch (e) { // 5. 702 // 5.1 703 this.#relevantRequestResponseList.length = 0 704 705 // 5.2 706 this.#relevantRequestResponseList = backupCache 707 708 // 5.3 709 throw e 710 } 711 } 712 713 /** 714 * @see https://w3c.github.io/ServiceWorker/#query-cache 715 * @param {any} requestQuery 716 * @param {import('../../types/cache').CacheQueryOptions} options 717 * @param {requestResponseList} targetStorage 718 * @returns {requestResponseList} 719 */ 720 #queryCache (requestQuery, options, targetStorage) { 721 /** @type {requestResponseList} */ 722 const resultList = [] 723 724 const storage = targetStorage ?? this.#relevantRequestResponseList 725 726 for (const requestResponse of storage) { 727 const [cachedRequest, cachedResponse] = requestResponse 728 if (this.#requestMatchesCachedItem(requestQuery, cachedRequest, cachedResponse, options)) { 729 resultList.push(requestResponse) 730 } 731 } 732 733 return resultList 734 } 735 736 /** 737 * @see https://w3c.github.io/ServiceWorker/#request-matches-cached-item-algorithm 738 * @param {any} requestQuery 739 * @param {any} request 740 * @param {any | null} response 741 * @param {import('../../types/cache').CacheQueryOptions | undefined} options 742 * @returns {boolean} 743 */ 744 #requestMatchesCachedItem (requestQuery, request, response = null, options) { 745 // if (options?.ignoreMethod === false && request.method === 'GET') { 746 // return false 747 // } 748 749 const queryURL = new URL(requestQuery.url) 750 751 const cachedURL = new URL(request.url) 752 753 if (options?.ignoreSearch) { 754 cachedURL.search = '' 755 756 queryURL.search = '' 757 } 758 759 if (!urlEquals(queryURL, cachedURL, true)) { 760 return false 761 } 762 763 if ( 764 response == null || 765 options?.ignoreVary || 766 !response.headersList.contains('vary') 767 ) { 768 return true 769 } 770 771 const fieldValues = getFieldValues(response.headersList.get('vary')) 772 773 for (const fieldValue of fieldValues) { 774 if (fieldValue === '*') { 775 return false 776 } 777 778 const requestValue = request.headersList.get(fieldValue) 779 const queryValue = requestQuery.headersList.get(fieldValue) 780 781 // If one has the header and the other doesn't, or one has 782 // a different value than the other, return false 783 if (requestValue !== queryValue) { 784 return false 785 } 786 } 787 788 return true 789 } 790} 791 792Object.defineProperties(Cache.prototype, { 793 [Symbol.toStringTag]: { 794 value: 'Cache', 795 configurable: true 796 }, 797 match: kEnumerableProperty, 798 matchAll: kEnumerableProperty, 799 add: kEnumerableProperty, 800 addAll: kEnumerableProperty, 801 put: kEnumerableProperty, 802 delete: kEnumerableProperty, 803 keys: kEnumerableProperty 804}) 805 806const cacheQueryOptionConverters = [ 807 { 808 key: 'ignoreSearch', 809 converter: webidl.converters.boolean, 810 defaultValue: false 811 }, 812 { 813 key: 'ignoreMethod', 814 converter: webidl.converters.boolean, 815 defaultValue: false 816 }, 817 { 818 key: 'ignoreVary', 819 converter: webidl.converters.boolean, 820 defaultValue: false 821 } 822] 823 824webidl.converters.CacheQueryOptions = webidl.dictionaryConverter(cacheQueryOptionConverters) 825 826webidl.converters.MultiCacheQueryOptions = webidl.dictionaryConverter([ 827 ...cacheQueryOptionConverters, 828 { 829 key: 'cacheName', 830 converter: webidl.converters.DOMString 831 } 832]) 833 834webidl.converters.Response = webidl.interfaceConverter(Response) 835 836webidl.converters['sequence<RequestInfo>'] = webidl.sequenceConverter( 837 webidl.converters.RequestInfo 838) 839 840module.exports = { 841 Cache 842} 843