1 # ezhttp/src/cache: Cache-Control and a small freshness check (RFC 9111). This is 2 # not a shared cache. Expires is recorded; max-age overrides it for freshness. 3 # IMF-fixdate arithmetic is not evaluated. 4 import Base 5 import ./http.bend as Http 6 7 # the directives this module records 8 type Cc is Data: 9 Cc{ 10 no_store: Bool 11 no_cache: Bool 12 public: Bool 13 private: Bool 14 max_age: String 15 expires: String 16 } 17 18 # no directives 19 def cache.empty() -> Cc: 20 Cc{False{}, False{}, False{}, False{}, "", ""} 21 22 # set no-store 23 def cache.store(c: Cc) -> Cc: 24 match c: 25 case Cc{no_store, no_cache, public, private, max_age, expires}: 26 Cc{True{}, no_cache, public, private, max_age, expires} 27 28 # set no-cache 29 def cache.revalidate(c: Cc) -> Cc: 30 match c: 31 case Cc{no_store, no_cache, public, private, max_age, expires}: 32 Cc{no_store, True{}, public, private, max_age, expires} 33 34 # set public 35 def cache.pub(c: Cc) -> Cc: 36 match c: 37 case Cc{no_store, no_cache, public, private, max_age, expires}: 38 Cc{no_store, no_cache, True{}, private, max_age, expires} 39 40 # set private 41 def cache.priv(c: Cc) -> Cc: 42 match c: 43 case Cc{no_store, no_cache, public, private, max_age, expires}: 44 Cc{no_store, no_cache, public, True{}, max_age, expires} 45 46 # set max-age text 47 def cache.age(c: Cc, age: String) -> Cc: 48 match c: 49 case Cc{no_store, no_cache, public, private, max_age, expires}: 50 Cc{no_store, no_cache, public, private, age, expires} 51 52 # set the Expires header text 53 def cache.expires(c: Cc, when: String) -> Cc: 54 match c: 55 case Cc{no_store, no_cache, public, private, max_age, expires}: 56 Cc{no_store, no_cache, public, private, max_age, when} 57 58 # private, or leave the directive unrecognized 59 def cache.one.private(c: Cc, _name: String, _value: String, hit: Bool) -> Cc: 60 match hit: 61 case True{}: 62 cache.priv(c) 63 case False{}: 64 c 65 66 # public 67 def cache.one.public(c: Cc, +name: String, value: String, hit: Bool) -> Cc: 68 match hit: 69 case True{}: 70 cache.pub(c) 71 case False{}: 72 cache.one.private(c, name, value, String.eq(name, "private")) 73 74 # max-age, kept only when the argument is digits (delta-seconds) 75 def cache.one.age_ok(c: Cc, +raw: String, m: Maybe<&2, Nat>) -> Cc: 76 match m: 77 case None{}: 78 c 79 case Some{n}: 80 cache.age(c, raw) 81 82 # max-age 83 def cache.one.age(c: Cc, +name: String, +value: String, hit: Bool) -> Cc: 84 match hit: 85 case True{}: 86 cache.one.age_ok(c, value, Http.digits.read(value)) 87 case False{}: 88 cache.one.public(c, name, value, String.eq(name, "public")) 89 90 # no-cache 91 def cache.one.cache(c: Cc, +name: String, +value: String, hit: Bool) -> Cc: 92 match hit: 93 case True{}: 94 cache.revalidate(c) 95 case False{}: 96 cache.one.age(c, name, value, String.eq(name, "max-age")) 97 98 # no-store 99 def cache.one.store(c: Cc, +name: String, +value: String, hit: Bool) -> Cc: 100 match hit: 101 case True{}: 102 cache.store(c) 103 case False{}: 104 cache.one.cache(c, name, value, String.eq(name, "no-cache")) 105 106 # a directive with no `=` argument 107 def cache.one.flag(c: Cc, +name: String) -> Cc: 108 cache.one.store(c, name, "", String.eq(name, "no-store")) 109 110 # flag or name=value 111 def cache.one.dispatch(c: Cc, +piece: String, cut: Http.Cut) -> Cc: 112 match cut: 113 case Http.NoCut{}: 114 cache.one.flag(c, String.to_lower(String.trim(piece))) 115 case Http.Cut{+before, after}: 116 +name = String.to_lower(String.trim(before)) 117 cache.one.store(c, name, String.trim(after), String.eq(name, "no-store")) 118 119 # one directive text 120 def cache.one(c: Cc, +piece: String) -> Cc: 121 cache.one.dispatch(c, piece, Http.divide(piece, "=")) 122 123 # walk directives. The list shrinks. 124 def cache.fold(ps: List<&2, String>, c: Cc) -> Cc: 125 match ps: 126 case []: 127 c 128 case h <> t: 129 cache.fold(t, cache.one(c, String.trim(h))) 130 131 # Cache-Control field value (RFC 9111 §5.2) 132 def cache.parse(value: String) -> Cc: 133 cache.fold(String.split(value, ','), cache.empty()) 134 135 # record an Expires header beside Cache-Control (RFC 9111 §5.3) 136 def cache.note(value: String, expires: String) -> Cc: 137 cache.expires(cache.parse(value), expires) 138 139 # true when max-age is present, so it overrides Expires (RFC 9111 §4.2.1) 140 def cache.max_age(c: Cc) -> Bool: 141 match c: 142 case Cc{no_store, no_cache, public, private, max_age, expires}: 143 Bool.not(String.eq(max_age, "")) 144 145 # freshness from max-age. no-store and no-cache are not fresh. 146 def cache.fresh.go(age: Nat, m: Maybe<&2, Nat>) -> Bool: 147 match m: 148 case None{}: 149 False{} 150 case Some{n}: 151 Nat.is_lt(age, n) 152 153 # no-cache must revalidate 154 def cache.fresh.cache(age: Nat, max_age: String, no_cache: Bool) -> Bool: 155 match no_cache: 156 case True{}: 157 False{} 158 case False{}: 159 cache.fresh.go(age, Http.digits.read(max_age)) 160 161 # no-store is not reused 162 def cache.fresh.store(age: Nat, no_cache: Bool, max_age: String, no_store: Bool) 163 -> Bool: 164 match no_store: 165 case True{}: 166 False{} 167 case False{}: 168 cache.fresh.cache(age, max_age, no_cache) 169 170 # age seconds against the directives. max-age=0 is stale (RFC 9111 §4.2.1). 171 def cache.fresh(c: Cc, age: Nat) -> Bool: 172 match c: 173 case Cc{no_store, no_cache, public, private, max_age, expires}: 174 cache.fresh.store(age, no_cache, max_age, no_store)