import { URL } from "url" ; import { Request , RequestInfo , RequestInitCfProperties , Response , withStringFormDataFiles , } from "@miniflare/core" ; import { Awaitable , Clock , Storage , assertInRequest , defaultClock , getRequestContext , millisToSeconds , waitForOpenInputGate , waitForOpenOutputGate , } from "@miniflare/shared" ; import CachePolicy from "http-cache-semantics" ; import { Request as BaseRequest , Response as BaseResponse , Headers , } from "undici" ; import { CacheError } from "./error" ; import { CacheInterface , CacheMatchOptions , CachedMeta } from "./helpers" ; function normaliseRequest ( req ) { return req instanceof Request || req instanceof BaseRequest ? req : new Request ( req ) ; } function normaliseHeaders ( headers ) { const result = { } ; for ( const [ key , value ] of headers ) result [ key . toLowerCase ( ) ] = value ; return result ; } function getKey ( req ) { if ( req . cf ?. cacheKey ) return req . cf . cacheKey ; try { const url = new URL ( req . url ) ; return url . toString ( ) ; } catch ( e ) { throw new TypeError ( "Invalid URL. Cache API keys must be fully-qualified, valid URLs." ) ; } } const cacheTtlByStatusRangeRegexp = /^(?<from>\d+)(-(?<to>\d+))?$/ ; function getExpirationTtl ( clock , req , res ) { const cf = req . cf ; if ( cf ?. cacheTtl ) return cf . cacheTtl * 1000 ; if ( cf ?. cacheTtlByStatus ) { for ( const [ range , ttl ] of Object . entries ( cf . cacheTtlByStatus ) ) { const match = cacheTtlByStatusRangeRegexp . exec ( range ) ; const fromString = match ?. groups ?. from ; if ( ! fromString ) continue ; const from = parseInt ( fromString ) ; const toString = match ?. groups ?. to ; if ( toString ) { const to = parseInt ( toString ) ; if ( from <= res . status && res . status <= to ) return ttl * 1000 ; } else if ( res . status === from ) { return ttl * 1000 ; } } } const reqHeaders = normaliseHeaders ( req . headers ) ; delete reqHeaders [ "cache-control" ] ; const resHeaders = normaliseHeaders ( res . headers ) ; if ( resHeaders [ "cache-control" ] ?. toLowerCase ( ) . includes ( "private=set-cookie" ) ) { resHeaders [ "cache-control" ] = resHeaders [ "cache-control" ] . replace ( /private=set-cookie/i , "" ) ; delete resHeaders [ "set-cookie" ] ; } const cacheReq = { url : req . url , method : req . method , headers : reqHeaders , } ; const cacheRes = { status : res . status , headers : resHeaders , } ; const originalNow = CachePolicy . prototype . now ; CachePolicy . prototype . now = clock ; try { const policy = new CachePolicy ( cacheReq , cacheRes , { shared : true } ) ; if ( "set-cookie" in resHeaders || ! policy . storable ( ) ) { return ; } return policy . timeToLive ( ) ; } finally { CachePolicy . prototype . now = originalNow ; } } export interface InternalCacheOptions { formDataFiles ? : boolean ; clock ? : Clock ; blockGlobalAsyncIO ? : boolean ; } export class Cache implements CacheInterface { readonly #storage : Awaitable < Storage > ; readonly #formDataFiles : boolean ; readonly #clock : Clock ; readonly #blockGlobalAsyncIO : boolean ; constructor ( storage , { formDataFiles = true , clock = defaultClock , blockGlobalAsyncIO = false , } = { } ) { this . #storage = storage ; this . #formDataFiles = formDataFiles ; this . #clock = clock ; this . #blockGlobalAsyncIO = blockGlobalAsyncIO ; } async put ( req , res ) { if ( this . #blockGlobalAsyncIO ) assertInRequest ( ) ; getRequestContext ( ) ?. incrementSubrequests ( ) ; req = normaliseRequest ( req ) ; if ( res instanceof Response && res . webSocket ) { throw new TypeError ( "Cannot cache WebSocket upgrade response." ) ; } if ( req . method !== "GET" ) { throw new TypeError ( "Cannot cache response to non-GET request." ) ; } if ( res . status === 206 ) { throw new TypeError ( "Cannot cache response to a range request (206 Partial Content)." ) ; } if ( res . headers . get ( "vary" ) ?. includes ( "*" ) ) { throw new TypeError ( "Cannot cache response with 'Vary: *' header." ) ; } const expirationTtl = getExpirationTtl ( this . #clock , req , res ) ; if ( expirationTtl === undefined ) return ; const key = getKey ( req ) ; const metadata = { status : res . status , headers : [ ... res . headers ] , } ; const storage = await this . #storage ; await waitForOpenOutputGate ( ) ; await storage . put ( key , { value : new Uint8Array ( await res . arrayBuffer ( ) ) , expiration : millisToSeconds ( this . #clock ( ) + expirationTtl ) , metadata , } ) ; await waitForOpenInputGate ( ) ; } async match ( req , options ? ) { if ( this . #blockGlobalAsyncIO ) assertInRequest ( ) ; getRequestContext ( ) ?. incrementSubrequests ( ) ; req = normaliseRequest ( req ) ; if ( req . method !== "GET" && ! options ?. ignoreMethod ) return ; const key = getKey ( req ) ; const storage = await this . #storage ; const cached = await storage . get < CachedMeta > ( key ) ; await waitForOpenInputGate ( ) ; if ( ! cached ) return ; if ( ! cached . metadata ) { throw new CacheError ( "ERR_DESERIALIZATION" , "Unable to deserialize stored cached data due to missing " + "metadata.\nThe cached data storage format changed in Miniflare 2. " + "You cannot load cached data created with Miniflare 1 and must " + "delete it." ) ; } const headers = new Headers ( cached . metadata . headers ) ; headers . set ( "CF-Cache-Status" , "HIT" ) ; let res = new Response ( cached . value , { status : cached . metadata . status , headers , } ) ; if ( ! this . #formDataFiles ) res = withStringFormDataFiles ( res ) ; return res ; } async delete ( req , options ? ) { if ( this . #blockGlobalAsyncIO ) assertInRequest ( ) ; getRequestContext ( ) ?. incrementSubrequests ( ) ; req = normaliseRequest ( req ) ; if ( req . method !== "GET" && ! options ?. ignoreMethod ) return false ; const key = getKey ( req ) ; const storage = await this . #storage ; await waitForOpenOutputGate ( ) ; const result = storage . delete ( key ) ; await waitForOpenInputGate ( ) ; return result ; } }