what-isr 0.11.7 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.min.js CHANGED
@@ -1 +1 @@
1
- import{createHash as _}from"node:crypto";var z=new Set(["fbclid","gclid","_","mc_cid","mc_eid"]),R=["utm_"];function J(t){if(z.has(t))return!0;for(let e of R)if(t.startsWith(e))return!0;return!1}function b(t){if(!t)return"/";let e=String(t).split("?")[0].split("#")[0];return e.length>1&&e.endsWith("/")&&(e=e.slice(0,-1)),e||"/"}function H(t){if(!t)return[];if(typeof t=="string"){let a=t.startsWith("?")?t.slice(1):t;return a?[...new URLSearchParams(a).entries()]:[]}let e=[];for(let[a,n]of Object.entries(t))if(Array.isArray(n))for(let l of n)e.push([a,String(l)]);else e.push([a,String(n)]);return e}function E(t){return H(t).filter(([a])=>!J(a)).sort((a,n)=>a[0]===n[0]?a[1]<n[1]?-1:1:a[0]<n[0]?-1:1).map(([a,n])=>`${a}=${n}`).join("&")}function k(t){return t?Object.entries(t).sort((e,a)=>e[0]<a[0]?-1:1).map(([e,a])=>`${e}=${a}`).join("&"):""}function P({path:t,query:e,vary:a}={}){return[b(t),E(e),k(a)].join("\0")}function F(t){let e=_("sha256").update(t).digest("hex");return`${e.slice(0,2)}/${e.slice(2,4)}/${e.slice(4)}`}function C(t,e={},a=Date.now()){let n=Number(e.revalidate)||0,l=e.swr!=null?Number(e.swr):n;return{html:t.html||"",head:t.head||"",state:t.state??null,tags:t.tags||e.tags||[],path:t.path||e.path,status:t.status||200,partial:!!t.partial,renderedAt:a,maxAge:n,swrWindow:l,expiresAt:n>0?a+n*1e3:1/0}}function A(t,e=Date.now()){return t.expiresAt===1/0||e<t.expiresAt}function D(t,e=Date.now()){return A(t,e)?!0:t.swrWindow==null?!1:e<t.expiresAt+t.swrWindow*1e3}function N(t={},e={},a="MISS"){let n={"X-What-Cache":a};if(!((t.status==null||t.status===200)&&(t.maxAge>0||e.mode==="static"||e.mode==="hybrid")&&e.mode!=="server"))return n["Cache-Control"]="private, no-store",n;let m=t.partial?0:t.maxAge||0,s=t.swrWindow!=null?t.swrWindow:m;return n["Cache-Control"]=`public, s-maxage=${m}, stale-while-revalidate=${s}`,t.tags&&t.tags.length&&(n["Cache-Tag"]=t.tags.join(","),n["Surrogate-Key"]=t.tags.join(" ")),n}function U({store:t,render:e,cdn:a,now:n=Date.now,logger:l=console}={}){let p=new Map;function m(i){return P({path:i.path,query:i.query,vary:i.vary})}function s(i,r,f){let h=p.get(i);if(h)return h;let o=f||e,w=(async()=>{let x=await o(r,{}),y=C({...x,path:r.path},r.config||{},n());return y.status===200&&await t.set(i,y),y})().finally(()=>p.delete(i));return p.set(i,w),w}function u(i,r,f){return{html:i.html,head:i.head,state:i.state,status:i.status||200,cacheStatus:r,headers:N(i,f||{},r)}}async function g(i,r){let f=i.config||{};if(f.mode==="server"){let y=await(r||e)(i,{}),v=C({...y,path:i.path},f,n());return u(v,"BYPASS",f)}let h=m(i),o=await t.get(h),w=n();if(o&&A(o,w))return u(o,"HIT",f);if(o&&D(o,w))return s(h,i,r).catch(y=>l.error?.("[what-isr] background regenerate failed:",y)),u(o,"STALE",f);if(o&&f.onMiss==="stale-if-error")try{let y=await s(h,i,r);return u(y,"MISS",f)}catch(y){return l.error?.("[what-isr] regenerate failed, serving stale:",y),u(o,"STALE",f)}let x=await s(h,i,r);return u(x,"MISS",f)}async function c(i,{regenerate:r=!1,routeResolver:f}={}){let h=b(i),o=await t.deleteByPath(h);if(a&&a.purge&&await a.purge([i]),r){let w=f?f(h):{path:h,query:{},config:{}};await s(m(w),w).catch(x=>l.error?.("[what-isr] regen after revalidatePath failed:",x))}return o}async function d(i,{regenerate:r=!1,routeResolver:f}={}){let h=await t.deleteByTag(i);if(a&&a.purgeTags&&await a.purgeTags([i]),r&&f)for(let o of h){let w=f(o);w&&await s(m(w),w).catch(()=>{})}return h}return{handle:g,regenerate:i=>s(m(i),i),revalidatePath:c,revalidateTag:d,keyFor:m,store:t,_inFlight:p,_now:n,_cdn:a}}function M({maxEntries:t=1e3}={}){let e=new Map,a=new Map,n=new Map;function l(c,d,i){if(d==null)return;let r=c.get(d);r||c.set(d,r=new Set),r.add(i)}function p(c,d,i){if(d==null)return;let r=c.get(d);r&&(r.delete(i),r.size===0&&c.delete(d))}function m(c,d){if(d.tags)for(let i of d.tags)l(a,i,c);l(n,d.path,c)}function s(c,d){if(d.tags)for(let i of d.tags)p(a,i,c);p(n,d.path,c)}function u(c){let d=e.get(c);return d?(e.delete(c),s(c,d),!0):!1}function g(c,d){let i=c.get(d);if(!i)return[];let r=[...i];for(let f of r)u(f);return r}return{async get(c){let d=e.get(c);return d?(e.delete(c),e.set(c,d),d):null},async set(c,d){for(e.has(c)&&s(c,e.get(c)),e.set(c,d),m(c,d);e.size>t;){let i=e.keys().next().value;u(i)}},async delete(c){return u(c)},async deleteByTag(c){return g(a,c)},async deleteByPath(c){return g(n,c)},async clear(){e.clear(),a.clear(),n.clear()},async keys(){return[...e.keys()]}}}import{mkdir as V,writeFile as X,readFile as L,rename as Q,rm as $,readdir as j,stat as I}from"node:fs/promises";import{join as S,dirname as q}from"node:path";import{createHash as G}from"node:crypto";function B(t){return G("sha256").update(String(t)).digest("hex")}function Y({dir:t}){let e=S(t,"entries"),a=S(t,"tags"),n=S(t,"paths"),l=r=>S(e,F(r)+".json"),p=(r,f)=>S(r,B(f)+".json");async function m(r,f){await V(q(r),{recursive:!0});let h=`${r}.${process.pid}.${B(r).slice(0,8)}.tmp`;await X(h,f),await Q(h,r)}async function s(r){try{return JSON.parse(await L(r,"utf8"))}catch{return null}}async function u(r,f,h){let o=p(r,f),w=await s(o)||[];w.includes(h)||(w.push(h),await m(o,JSON.stringify(w)))}async function g(r,f,h){let o=p(r,f),w=await s(o);if(!w)return;let x=w.filter(y=>y!==h);x.length?await m(o,JSON.stringify(x)):await $(o,{force:!0})}async function c(r,f){let h=p(r,f),o=await s(h);if(!o)return[];for(let w of o)await d(w);return await $(h,{force:!0}),o}async function d(r){let f=await s(l(r));if(!f)return!1;await $(l(r),{force:!0});let h=f.entry||{};for(let o of h.tags||[])await g(a,o,r);return h.path&&await g(n,h.path,r),!0}async function i(){let r=[],f;try{f=await j(e)}catch{return r}for(let h of f){let o=S(e,h),w;try{w=await I(o)}catch{continue}if(w.isDirectory())for(let x of await j(o)){let y=S(o,x);try{if(!(await I(y)).isDirectory())continue}catch{continue}for(let v of await j(y)){if(!v.endsWith(".json"))continue;let T=await s(S(y,v));T&&T.key!=null&&r.push(T.key)}}}return r}return{async get(r){let f=await s(l(r));return f?f.entry:null},async set(r,f){let h=await s(l(r));if(h&&h.entry){for(let o of h.entry.tags||[])await g(a,o,r);h.entry.path&&await g(n,h.entry.path,r)}await m(l(r),JSON.stringify({key:r,entry:f}));for(let o of f.tags||[])await u(a,o,r);f.path&&await u(n,f.path,r)},async delete(r){return d(r)},async deleteByTag(r){return c(a,r)},async deleteByPath(r){return c(n,r)},async clear(){await $(t,{recursive:!0,force:!0})},async keys(){return i()}}}function Z({client:t,namespace:e="what"}={}){if(!t)throw new Error("[what-isr] createRedisStore requires { client }");let a=s=>`${e}:cache:${s}`,n=s=>`${e}:tag:${s}`,l=s=>`${e}:path:${s}`;async function p(s,u){if(u){for(let g of u.tags||[])await t.srem(n(g),s);u.path&&await t.srem(l(u.path),s)}}async function m(s){let u=await t.smembers(s)||[];for(let g of u)await t.del(a(g));return await t.del(s),u}return{async get(s){let u=await t.get(a(s));return u?JSON.parse(u):null},async set(s,u){let g=await this.get(s);g&&await p(s,g),await t.set(a(s),JSON.stringify(u));for(let c of u.tags||[])await t.sadd(n(c),s);u.path&&await t.sadd(l(u.path),s)},async delete(s){let u=await this.get(s);return await t.del(a(s)),await p(s,u),!!u},async deleteByTag(s){return m(n(s))},async deleteByPath(s){return m(l(s))},async clear(){if(typeof t.keys=="function"){let s=await t.keys(`${e}:*`);for(let u of s)await t.del(u)}},async keys(){if(typeof t.keys!="function")return[];let s=`${e}:cache:`;return(await t.keys(`${s}*`)).map(g=>g.slice(s.length))}}}async function tt(t,e={}){if(typeof t!="function")return{paths:[],fallback:!1};let a=await t(e);return{paths:a&&a.paths||[],fallback:a&&"fallback"in a?a.fallback:!1}}function et(t,e={}){return t.replace(/[:*]([A-Za-z0-9_]+)/g,(a,n)=>{let l=e[n];return l==null?"":String(l)})}function rt(t,e){return t.some(a=>{let n=a.params||{},l=new Set([...Object.keys(n),...Object.keys(e)]);for(let p of l)if(String(n[p])!==String(e[p]))return!1;return!0})}function at(t,e){return e?"serve":t==="blocking"?"render":t===!0?"skeleton":"notfound"}function nt(t,e){if(typeof t!="string"||typeof e!="string"||t.length!==e.length)return!1;let a=0;for(let n=0;n<t.length;n++)a|=t.charCodeAt(n)^e.charCodeAt(n);return a===0}function st(t,e={}){let{secret:a,header:n="x-what-revalidate-secret",regenerate:l=!1}=e;return async function(m){let s=(m.headers||{})[n]||(m.headers||{})[n.toLowerCase()];if(!a||!nt(s||"",a))return{status:401,body:{message:"Unauthorized"}};let u=m.body;if(!u||typeof u!="object")return{status:400,body:{message:"Invalid body"}};let{paths:g,tags:c,regenerate:d=l}=u;if(!Array.isArray(g)&&!Array.isArray(c))return{status:400,body:{message:"Provide `paths` and/or `tags` arrays"}};let i={paths:[],tags:[]};if(Array.isArray(g))for(let r of g)await t.revalidatePath(r,{regenerate:d}),i.paths.push(r);if(Array.isArray(c))for(let r of c)await t.revalidateTag(r,{regenerate:d}),i.tags.push(r);return{status:200,body:{revalidated:!0,...i}}}}function ot(t,e={}){let{maxConcurrent:a=4,random:n=Math.random,setTimer:l=setTimeout,clearTimer:p=clearTimeout,logger:m=console}=e,s=[],u=[],g=!1,c=0;function d(o){return Math.round(o*(1+n()*.1))}function i(o){o.timer=l(()=>r(o),d(o.intervalMs)),o.timer&&typeof o.timer.unref=="function"&&o.timer.unref()}function r(o){g&&(c<a?f(o):u.push(o))}async function f(o){c++;try{await t.regenerate(o.route)}catch(w){m.error?.("[what-isr] scheduled regenerate failed:",w)}finally{c--,g&&i(o),h()}}function h(){for(;g&&c<a&&u.length;)f(u.shift())}return{register(o,{intervalMs:w}){return s.push({route:o,intervalMs:w,timer:null}),this},start(){g=!0;for(let o of s)i(o);return this},stop(){g=!1;for(let o of s)o.timer!=null&&p(o.timer);return u.length=0,this},_tasks:s}}function O({zoneId:t,apiToken:e}={}){let a=`https://api.cloudflare.com/client/v4/zones/${t}/purge_cache`,n={Authorization:`Bearer ${e}`,"Content-Type":"application/json"};async function l(p){!t||!e||await fetch(a,{method:"POST",headers:n,body:JSON.stringify(p)})}return{purge:p=>l({files:p}),purgeTags:p=>l({tags:p})}}function K({serviceId:t,apiToken:e}={}){let a={"Fastly-Key":e,Accept:"application/json"};async function n(l){!t||!e||await fetch(`https://api.fastly.com/service/${t}/purge/${encodeURIComponent(l)}`,{method:"POST",headers:a})}return{async purge(l){for(let p of l){if(!e)return;await fetch(p,{method:"PURGE",headers:a})}},async purgeTags(l){for(let p of l)await n(p)}}}function W({token:t,projectId:e,teamId:a}={}){let n={Authorization:`Bearer ${t}`,"Content-Type":"application/json"},l=a?`?teamId=${a}`:"";async function p(m){!t||!e||await fetch(`https://api.vercel.com/v1/projects/${e}/cache/purge${l}`,{method:"POST",headers:n,body:JSON.stringify({tags:m})})}return{purge:()=>Promise.resolve(),purgeTags:p}}export{N as buildCacheHeaders,et as buildPath,P as cacheKey,U as createCacheEngine,O as createCloudflareCDN,K as createFastlyCDN,Y as createFilesystemStore,M as createMemoryStore,Z as createRedisStore,st as createRevalidateWebhook,ot as createScheduler,W as createVercelCDN,at as decideFallback,F as hashKey,A as isFresh,rt as isKnownParams,D as isServableStale,C as makeEntry,b as normalizePath,E as normalizeQuery,tt as resolveStaticPaths};
1
+ import{createHash as H}from"node:crypto";var L="\0",G=new Set(["fbclid","gclid","_","mc_cid","mc_eid"]),Q=["utm_"];function Z(t){if(G.has(t))return!0;for(let e of Q)if(t.startsWith(e))return!0;return!1}function P(t){if(!t)return"/";let e=String(t).split("?")[0].split("#")[0];return e.length>1&&e.endsWith("/")&&(e=e.slice(0,-1)),e||"/"}function M(t){if(!t)return[];if(typeof t=="string"){let r=t.startsWith("?")?t.slice(1):t;return r?[...new URLSearchParams(r).entries()]:[]}let e=[];for(let[r,s]of Object.entries(t))if(Array.isArray(s))for(let c of s)e.push([r,String(c)]);else e.push([r,String(s)]);return e}var T=t=>encodeURIComponent(String(t));function q(t){return M(t).filter(([r])=>!Z(r)).sort((r,s)=>r[0]===s[0]?r[1]<s[1]?-1:1:r[0]<s[0]?-1:1).map(([r,s])=>`${T(r)}=${T(s)}`).join("&")}function tt(t){let e={};if(!t)return e;for(let r of String(t).split(";")){let s=r.indexOf("=");s<0||(e[r.slice(0,s).trim()]=r.slice(s+1).trim())}return e}function _(t){let e=t||{};return e.vary!=null?e.vary:(e.config||{}).vary}function D(t){if(t==null)return[];if(typeof t=="string")return t?[t]:[];if(!Array.isArray(t))return null;let e=[];for(let r of t){if(typeof r!="string"||!r)return null;e.push(r)}return e}function j(t,e){let r=D(t);if(r===null)return null;if(!r.length)return{};if(!e||typeof e!="object")return null;let s={};for(let[h,l]of Object.entries(e))s[h.toLowerCase()]=Array.isArray(l)?l.join(","):l;let c=tt(s.cookie),d={};for(let h of r){let l=String(h),g=l.indexOf(":"),m=g<0?"header":l.slice(0,g).toLowerCase(),n=g<0?l:l.slice(g+1);if(m==="cookie")d[l]=c[n]??"";else if(m==="header")d[l]=s[n.toLowerCase()]??"";else return null}return d}function et(t){return t?Object.entries(t).sort((e,r)=>e[0]<r[0]?-1:1).map(([e,r])=>`${T(e)}=${T(r)}`).join("&"):""}function E({path:t,query:e,vary:r,headers:s}={}){let c=typeof r=="string"||Array.isArray(r),d=r==null||typeof r=="object"&&!Array.isArray(r),h=c?j(r,s):d?r:null;if(h===null)throw new Error("[what-isr] cannot build a cache key: `vary` is declared but could not be resolved against the request (unsupported shape, or no request headers were supplied)");return[P(t),q(e),et(h)].join(L)}function C(t){let e=String(t),r=e.lastIndexOf(L);if(r<0)return e;let s=e.slice(r+1);return s.includes("=")?e.slice(0,r+1)+H("sha256").update(s).digest("hex"):e}function R(t){let e=H("sha256").update(t).digest("hex");return`${e.slice(0,2)}/${e.slice(2,4)}/${e.slice(4)}`}function F(t,e={},r=Date.now()){let s=e.mode==="hybrid"?60:0,c=e.revalidate!=null,d=Number(e.revalidate??s),h=Number.isFinite(d)?d:s,l=e.swr!=null?Number(e.swr):h,g=h>0?r+h*1e3:c?r:1/0;return{html:t.html||"",head:t.head||"",state:t.state??null,tags:t.tags||e.tags||[],path:t.path||e.path,status:t.status||200,partial:!!t.partial,private:!!(t.private||t.usedRequestHeaders),renderedAt:r,maxAge:h,swrWindow:l,expiresAt:g}}function N(t,e=Date.now()){return t.expiresAt===1/0||e<t.expiresAt}function W(t,e=Date.now()){return N(t,e)?!0:t.swrWindow==null?!1:e<t.expiresAt+t.swrWindow*1e3}function rt(t){let e=new Set;for(let r of t){let s=String(r),c=s.indexOf(":"),d=c<0?"header":s.slice(0,c).toLowerCase();e.add(d==="cookie"?"Cookie":c<0?s:s.slice(c+1))}return[...e].join(", ")}function K(t={},e={},r="MISS",s){let c={"X-What-Cache":r},d=s!==void 0?s:e.vary,h=D(d),l=h===null||h.length>0;if(h&&h.length>0&&(c.Vary=rt(h)),!((t.status==null||t.status===200)&&!l&&!t.private&&(t.maxAge>0||e.mode==="static"||e.mode==="hybrid")&&e.mode!=="server"))return c["Cache-Control"]="private, no-store",c;let n=t.partial?0:t.maxAge||0,o=t.swrWindow!=null?t.swrWindow:n;return c["Cache-Control"]=`public, s-maxage=${n}, stale-while-revalidate=${o}`,t.tags&&t.tags.length&&(c["Cache-Tag"]=t.tags.join(","),c["Surrogate-Key"]=t.tags.join(" ")),c}function I(t){if(typeof t!="string"||!t.startsWith("/")||/^[/\\]{2}/.test(t)||t.includes("\\"))return null;try{let e=new URL(t,"http://localhost");return e.origin!=="http://localhost"?null:e.pathname+e.search}catch{return null}}function nt({store:t,render:e,cdn:r,now:s=Date.now,logger:c=console}={}){let d=new Map;function h(a){return j(_(a),a.varyHeaders||a.headers)}function l(a){let w=h(a);if(w===null)throw new Error("[what-isr] route declares `vary` but the adapter supplied no request headers; refusing to cache");return E({path:a.path,query:a.query,vary:w})}function g(a,w,S){let y=d.get(a);if(y)return y;let i=S||e,u=(async()=>{let p=await i(w,{}),x=F({...p,path:w.path},w.config||{},s());return x.status===200&&!x.private&&await t.set(a,x),x})().finally(()=>d.delete(a));return d.set(a,u),u}function m(a,w,S){return{html:a.html,head:a.head,state:a.state,status:a.status||200,cacheStatus:w,headers:K(a,S.config||{},w,_(S))}}async function n(a,w){let S=a.config||{},y=h(a);if(S.mode==="server"||y===null){y===null&&c.warn?.(`[what-isr] ${a.path} declares \`vary\` but no request headers were supplied; bypassing the cache`);let v=await(w||e)(a,{}),b=F({...v,path:a.path,private:y===null||v.private},S,s());return m(b,"BYPASS",a)}let i=E({path:a.path,query:a.query,vary:y}),u=await t.get(i),p=s();if(u&&N(u,p))return m(u,"HIT",a);if(u&&W(u,p))return g(i,a,w).catch(v=>c.error?.("[what-isr] background regenerate failed:",v)),m(u,"STALE",a);if(u&&S.onMiss==="stale-if-error")try{let v=await g(i,a,w);return m(v,"MISS",a)}catch(v){return c.error?.("[what-isr] regenerate failed, serving stale:",v),m(u,"STALE",a)}let x=await g(i,a,w);return m(x,"MISS",a)}async function o(a,{regenerate:w=!1,routeResolver:S}={}){let y=I(typeof a=="string"?a:"");if(y===null)return c.warn?.(`[what-isr] refusing to revalidate a non-local path: ${a}`),[];let i=P(y),u=await t.deleteByPath(i);if(r&&r.purge&&await r.purge([y]),w){let p=S?S(i):{path:i,query:{},config:{}};try{await g(l(p),p)}catch(x){c.error?.("[what-isr] regen after revalidatePath failed:",x)}}return u}async function f(a,{regenerate:w=!1,routeResolver:S}={}){let y=await t.deleteByTag(a);if(r&&r.purgeTags&&await r.purgeTags([a]),w&&S)for(let i of y){let u=S(i);if(u)try{await g(l(u),u)}catch{}}return y}return{handle:n,regenerate:a=>g(l(a),a),revalidatePath:o,revalidateTag:f,keyFor:l,store:t,_inFlight:d,_now:s,_cdn:r}}function at({maxEntries:t=1e3}={}){let e=new Map,r=new Map,s=new Map;function c(n,o,f){if(o==null)return;let a=n.get(o);a||n.set(o,a=new Set),a.add(f)}function d(n,o,f){if(o==null)return;let a=n.get(o);a&&(a.delete(f),a.size===0&&n.delete(o))}function h(n,o){if(o.tags)for(let f of o.tags)c(r,f,n);c(s,o.path,n)}function l(n,o){if(o.tags)for(let f of o.tags)d(r,f,n);d(s,o.path,n)}function g(n){let o=e.get(n);return o?(e.delete(n),l(n,o),!0):!1}function m(n,o){let f=n.get(o);if(!f)return[];let a=[...f];for(let w of a)g(w);return a}return{async get(n){let o=e.get(n);return o?(e.delete(n),e.set(n,o),o):null},async set(n,o){for(e.has(n)&&l(n,e.get(n)),e.set(n,o),h(n,o);e.size>t;){let f=e.keys().next().value;g(f)}},async delete(n){return g(n)},async deleteByTag(n){return m(r,n)},async deleteByPath(n){return m(s,n)},async clear(){e.clear(),r.clear(),s.clear()},async keys(){return[...e.keys()]}}}import{mkdir as st,writeFile as ot,readFile as it,rename as ct,rm as $,readdir as O,stat as J}from"node:fs/promises";import{join as A,dirname as ut}from"node:path";import{createHash as ft,randomBytes as lt}from"node:crypto";function V(t){return ft("sha256").update(String(t)).digest("hex")}function dt({dir:t}){let e=A(t,"entries"),r=A(t,"tags"),s=A(t,"paths"),c=i=>A(e,R(i)+".json"),d=(i,u)=>A(i,V(u)),h=(i,u,p)=>A(d(i,u),V(p)+".json"),l=(i,u)=>A(i,V(u)+".json");async function g(i,u){await st(ut(i),{recursive:!0});let p=`${i}.${process.pid}.${lt(6).toString("hex")}.tmp`;await ot(p,u),await ct(p,i)}async function m(i){try{return JSON.parse(await it(i,"utf8"))}catch{return null}}async function n(i,u,p){await g(h(i,u,p),JSON.stringify(p))}async function o(i,u,p){await $(h(i,u,p),{force:!0});let x=await f(i,u);if(!x)return;let v=x.filter(b=>b!==p);v.length!==x.length&&(v.length?await g(l(i,u),JSON.stringify(v)):await $(l(i,u),{force:!0}))}async function f(i,u){let p=await m(l(i,u));return Array.isArray(p)?p.filter(x=>typeof x=="string"):null}async function a(i,u){let p=new Set(await f(i,u)||[]),x=d(i,u),v;try{v=await O(x)}catch{return[...p]}for(let b of v){if(!b.endsWith(".json"))continue;let k=await m(A(x,b));typeof k=="string"&&p.add(k)}return[...p]}async function w(i,u){let p=await a(i,u);for(let x of p)await S(x);return await $(d(i,u),{recursive:!0,force:!0}),await $(l(i,u),{force:!0}),p}async function S(i){let u=await m(c(i));if(!u)return!1;await $(c(i),{force:!0});let p=u.entry||{};for(let x of p.tags||[])await o(r,x,i);return p.path&&await o(s,p.path,i),!0}async function y(){let i=[],u;try{u=await O(e)}catch{return i}for(let p of u){let x=A(e,p),v;try{v=await J(x)}catch{continue}if(v.isDirectory())for(let b of await O(x)){let k=A(x,b);try{if(!(await J(k)).isDirectory())continue}catch{continue}for(let z of await O(k)){if(!z.endsWith(".json"))continue;let B=await m(A(k,z));B&&B.key!=null&&i.push(B.key)}}}return i}return{async get(i){let u=await m(c(C(i)));return u?u.entry:null},async set(i,u){let p=C(i),x=await m(c(p));if(x&&x.entry){for(let v of x.entry.tags||[])await o(r,v,p);x.entry.path&&await o(s,x.entry.path,p)}await g(c(p),JSON.stringify({key:p,entry:u}));for(let v of u.tags||[])await n(r,v,p);u.path&&await n(s,u.path,p)},async delete(i){return S(C(i))},async deleteByTag(i){return w(r,i)},async deleteByPath(i){return w(s,i)},async clear(){await $(t,{recursive:!0,force:!0})},async keys(){return y()}}}function pt({client:t,namespace:e="what"}={}){if(!t)throw new Error("[what-isr] createRedisStore requires { client }");let r=n=>`${e}:cache:${n}`,s=n=>`${e}:tag:${n}`,c=n=>`${e}:path:${n}`;function d(n,o=Date.now()){let f=n&&n.expiresAt;if(f==null||f===1/0||!Number.isFinite(f))return 0;let a=Number(n.swrWindow)||0;return Math.max(1,Math.ceil((f-o)/1e3)+a)}async function h(n){if(typeof t.scan=="function"){let o=new Set,f="0";do{let a=await t.scan(f,"MATCH",n,"COUNT",500),[w,S]=Array.isArray(a)?a:[a.cursor,a.keys];f=String(w);for(let y of S||[])o.add(y)}while(f!=="0");return[...o]}return typeof t.keys=="function"?await t.keys(n)||[]:[]}async function l(n){let o=await t.get(r(n));return o?JSON.parse(o):null}async function g(n,o){if(o){for(let f of o.tags||[])await t.srem(s(f),n);o.path&&await t.srem(c(o.path),n)}}async function m(n){let o=await t.smembers(n)||[];for(let f of o)await t.del(r(f));return await t.del(n),o}return{async get(n){return l(C(n))},async set(n,o){let f=C(n),a=await l(f);a&&await g(f,a),await t.set(r(f),JSON.stringify(o));let w=d(o);w>0&&typeof t.expire=="function"&&await t.expire(r(f),w);for(let S of o.tags||[])await t.sadd(s(S),f);o.path&&await t.sadd(c(o.path),f)},async delete(n){let o=C(n),f=await l(o);return await t.del(r(o)),await g(o,f),!!f},async deleteByTag(n){return m(s(n))},async deleteByPath(n){return m(c(n))},async clear(){for(let n of await h(`${e}:*`))await t.del(n)},async keys(){let n=`${e}:cache:`;return(await h(`${n}*`)).map(f=>f.slice(n.length))}}}async function ht(t,e={}){if(typeof t!="function")return{paths:[],fallback:!1};let r=await t(e);return{paths:r&&r.paths||[],fallback:r&&"fallback"in r?r.fallback:!1}}function yt(t,e={}){return t.replace(/[:*]([A-Za-z0-9_]+)/g,(r,s)=>{let c=e[s];return c==null?"":String(c)})}function wt(t,e){return t.some(r=>{let s=r.params||{},c=new Set([...Object.keys(s),...Object.keys(e)]);for(let d of c)if(String(s[d])!==String(e[d]))return!1;return!0})}function gt(t,e){return e?"serve":t==="blocking"?"render":t===!0?"skeleton":"notfound"}import{createHash as mt,timingSafeEqual as xt}from"node:crypto";function St(t,e){if(typeof t!="string"||typeof e!="string")return!1;let r=s=>mt("sha256").update(s).digest();return xt(r(t),r(e))}var vt=100;function At(t,e={}){let{secret:r,header:s="x-what-revalidate-secret",regenerate:c=!1,maxBatch:d=vt}=e;return async function(l){let g=(l.headers||{})[s]||(l.headers||{})[s.toLowerCase()];if(!r||!St(g||"",r))return{status:401,body:{message:"Unauthorized"}};let m=l.body;if(!m||typeof m!="object")return{status:400,body:{message:"Invalid body"}};let{paths:n,tags:o}=m;if(!Array.isArray(n)&&!Array.isArray(o))return{status:400,body:{message:"Provide `paths` and/or `tags` arrays"}};let f=(Array.isArray(n)?n.length:0)+(Array.isArray(o)?o.length:0);if(f>d)return{status:400,body:{message:`Too many entries: ${f} exceeds the ${d} per-request limit`}};let a={paths:[],tags:[]};if(Array.isArray(n))for(let w of n)await t.revalidatePath(w,{regenerate:c}),a.paths.push(w);if(Array.isArray(o))for(let w of o)await t.revalidateTag(w,{regenerate:c}),a.tags.push(w);return{status:200,body:{revalidated:!0,...a}}}}function bt(t,e={}){let{maxConcurrent:r=4,random:s=Math.random,setTimer:c=setTimeout,clearTimer:d=clearTimeout,logger:h=console}=e,l=[],g=[],m=!1,n=0;function o(y){return Math.round(y*(1+s()*.1))}function f(y){y.timer=c(()=>a(y),o(y.intervalMs)),y.timer&&typeof y.timer.unref=="function"&&y.timer.unref()}function a(y){m&&(n<r?w(y):g.push(y))}async function w(y){n++;try{await t.regenerate(y.route)}catch(i){h.error?.("[what-isr] scheduled regenerate failed:",i)}finally{n--,m&&f(y),S()}}function S(){for(;m&&n<r&&g.length;)w(g.shift())}return{register(y,{intervalMs:i}){return l.push({route:y,intervalMs:i,timer:null}),this},start(){m=!0;for(let y of l)f(y);return this},stop(){m=!1;for(let y of l)y.timer!=null&&d(y.timer);return g.length=0,this},_tasks:l}}function U({zoneId:t,apiToken:e}={}){let r=`https://api.cloudflare.com/client/v4/zones/${t}/purge_cache`,s={Authorization:`Bearer ${e}`,"Content-Type":"application/json"};async function c(d){!t||!e||await fetch(r,{method:"POST",headers:s,body:JSON.stringify(d)})}return{purge:d=>c({files:d}),purgeTags:d=>c({tags:d})}}function X({serviceId:t,apiToken:e,baseUrl:r}={}){let s={"Fastly-Key":e,Accept:"application/json"};function c(h){if(!r)return null;let l=I(h);if(l===null)return null;try{return new URL(l,r).href}catch{return null}}async function d(h){!t||!e||await fetch(`https://api.fastly.com/service/${t}/purge/${encodeURIComponent(h)}`,{method:"POST",headers:s})}return{async purge(h){if(e)for(let l of h){let g=c(l);g&&await fetch(g,{method:"PURGE",headers:s})}},async purgeTags(h){for(let l of h)await d(l)}}}function Y({token:t,projectId:e,teamId:r}={}){let s={Authorization:`Bearer ${t}`,"Content-Type":"application/json"},c=r?`?teamId=${r}`:"";async function d(h){!t||!e||await fetch(`https://api.vercel.com/v1/projects/${e}/cache/purge${c}`,{method:"POST",headers:s,body:JSON.stringify({tags:h})})}return{purge:()=>Promise.resolve(),purgeTags:d}}export{K as buildCacheHeaders,yt as buildPath,E as cacheKey,nt as createCacheEngine,U as createCloudflareCDN,X as createFastlyCDN,dt as createFilesystemStore,at as createMemoryStore,pt as createRedisStore,At as createRevalidateWebhook,bt as createScheduler,Y as createVercelCDN,gt as decideFallback,R as hashKey,N as isFresh,wt as isKnownParams,W as isServableStale,F as makeEntry,P as normalizePath,q as normalizeQuery,D as normalizeVaryDeclaration,ht as resolveStaticPaths,j as resolveVary};
package/index.d.ts CHANGED
@@ -9,8 +9,16 @@ export interface PageCacheConfig {
9
9
  swr?: number;
10
10
  /** Purge handles for revalidateTag. */
11
11
  tags?: string[];
12
- /** Split the cache by these request signals, e.g. 'cookie:theme'. */
13
- vary?: string[];
12
+ /**
13
+ * Split the cache by these request signals, e.g. 'cookie:theme' or
14
+ * 'header:accept-language' ('x' alone means the 'x' header). The adapter must
15
+ * supply the matching request headers as `RouteMatch.varyHeaders`; without
16
+ * them the route is served uncached rather than shared between users.
17
+ *
18
+ * A bare string is accepted as shorthand for a one-element list. Any other
19
+ * shape is refused and the route is served uncached.
20
+ */
21
+ vary?: string[] | string;
14
22
  fallback?: 'blocking' | boolean;
15
23
  onMiss?: 'blocking' | string;
16
24
  /** Background regeneration interval, in seconds. */
@@ -25,6 +33,8 @@ export interface CacheEntry {
25
33
  status?: number;
26
34
  tags?: string[];
27
35
  path?: string;
36
+ /** Per-user render: never stored, never served from a shared cache. */
37
+ private?: boolean;
28
38
  /** Epoch ms when the entry was created. */
29
39
  createdAt: number;
30
40
  /** Seconds-to-stale snapshot from the page config. */
@@ -51,10 +61,28 @@ export function isFresh(entry: CacheEntry, now?: number): boolean;
51
61
  export function isServableStale(entry: CacheEntry, now?: number): boolean;
52
62
 
53
63
  // --- Keys ---
54
- export function cacheKey(routeMatch: RouteMatch): string;
64
+ export function cacheKey(input: {
65
+ path: string;
66
+ query?: Record<string, string> | string;
67
+ vary?: string[] | string | Record<string, string>;
68
+ headers?: Record<string, string>;
69
+ }): string;
55
70
  export function normalizePath(path: string): string;
56
71
  export function normalizeQuery(query: Record<string, string> | string): string;
57
72
  export function hashKey(key: string): string;
73
+ /** Resolve a declared vary list against request headers; null if unresolvable. */
74
+ export function resolveVary(
75
+ vary: string[] | string | Record<string, string> | undefined,
76
+ headers?: Record<string, string>
77
+ ): Record<string, string> | null;
78
+ /**
79
+ * Coerce a `vary` declaration to canonical `string[]`, or null if the shape
80
+ * cannot be resolved. The cache key and the Cache-Control builder both read
81
+ * this, so they can never disagree about whether a route is per-user.
82
+ */
83
+ export function normalizeVaryDeclaration(
84
+ vary: string[] | string | undefined | null
85
+ ): string[] | null;
58
86
 
59
87
  // --- CDN adapters (optional) ---
60
88
  export interface CDNAdapter {
@@ -62,7 +90,8 @@ export interface CDNAdapter {
62
90
  purgeTags(tags: string[]): Promise<void>;
63
91
  }
64
92
  export function createCloudflareCDN(options: { zoneId: string; apiToken: string }): CDNAdapter;
65
- export function createFastlyCDN(options: { serviceId: string; apiToken: string }): CDNAdapter;
93
+ /** `baseUrl` is required for URL purge: only local paths resolved against it are purged. */
94
+ export function createFastlyCDN(options: { serviceId: string; apiToken: string; baseUrl?: string }): CDNAdapter;
66
95
  export function createVercelCDN(options: { projectId: string; token: string; teamId?: string }): CDNAdapter;
67
96
 
68
97
  // --- Headers ---
@@ -92,6 +121,8 @@ export interface RouteMatch {
92
121
  params?: Record<string, string>;
93
122
  route?: unknown;
94
123
  request?: Request;
124
+ /** Request headers the route's `vary` names resolve against. */
125
+ varyHeaders?: Record<string, string>;
95
126
  }
96
127
 
97
128
  export interface RenderResult {
@@ -131,7 +162,7 @@ export function createCacheEngine(options?: {
131
162
  // --- Revalidation webhook ---
132
163
  export interface WebhookRequest {
133
164
  headers?: Record<string, string>;
134
- body?: { paths?: string[]; tags?: string[]; secret?: string; regenerate?: boolean };
165
+ body?: { paths?: string[]; tags?: string[]; secret?: string };
135
166
  }
136
167
  export interface WebhookResponse {
137
168
  status: number;
@@ -139,7 +170,7 @@ export interface WebhookResponse {
139
170
  }
140
171
  export function createRevalidateWebhook(
141
172
  engine: CacheEngine,
142
- options: { secret: string }
173
+ options: { secret: string; header?: string; regenerate?: boolean; maxBatch?: number }
143
174
  ): (req: WebhookRequest) => Promise<WebhookResponse>;
144
175
 
145
176
  // --- Poll scheduler ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-isr",
3
- "version": "0.11.7",
3
+ "version": "0.12.0",
4
4
  "description": "What Framework - Origin-first ISR cache engine: stale-while-revalidate, on-demand & poll regeneration, no CDN required",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -30,7 +30,7 @@
30
30
  "author": "ZVN DEV (https://zvndev.com)",
31
31
  "license": "MIT",
32
32
  "peerDependencies": {
33
- "what-server": "^0.11.7"
33
+ "what-server": "^0.12.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "what-server": {
package/src/cdn/fastly.js CHANGED
@@ -1,8 +1,23 @@
1
1
  // Fastly CDN adapter — surrogate-key purge (tags) and URL purge.
2
2
 
3
- export function createFastlyCDN({ serviceId, apiToken } = {}) {
3
+ import { safeLocalPath } from '../local-path.js';
4
+
5
+ export function createFastlyCDN({ serviceId, apiToken, baseUrl } = {}) {
4
6
  const headers = { 'Fastly-Key': apiToken, Accept: 'application/json' };
5
7
 
8
+ // A URL purge is a request to an arbitrary host carrying the Fastly API key,
9
+ // so only local paths resolved against the configured baseUrl are ever sent.
10
+ function purgeUrl(target) {
11
+ if (!baseUrl) return null;
12
+ const local = safeLocalPath(target);
13
+ if (local === null) return null;
14
+ try {
15
+ return new URL(local, baseUrl).href;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
6
21
  async function purgeKey(key) {
7
22
  if (!serviceId || !apiToken) return;
8
23
  await fetch(`https://api.fastly.com/service/${serviceId}/purge/${encodeURIComponent(key)}`, {
@@ -12,10 +27,11 @@ export function createFastlyCDN({ serviceId, apiToken } = {}) {
12
27
  }
13
28
 
14
29
  return {
15
- async purge(urls) {
16
- for (const url of urls) {
17
- if (!apiToken) return;
18
- await fetch(url, { method: 'PURGE', headers });
30
+ async purge(paths) {
31
+ if (!apiToken) return;
32
+ for (const p of paths) {
33
+ const url = purgeUrl(p);
34
+ if (url) await fetch(url, { method: 'PURGE', headers });
19
35
  }
20
36
  },
21
37
  async purgeTags(tags) {
package/src/headers.js CHANGED
@@ -2,18 +2,53 @@
2
2
  // them lets ANY standards-compliant CDN do edge ISR for free (s-maxage +
3
3
  // stale-while-revalidate), and Cache-Tag/Surrogate-Key enable CDN tag purge.
4
4
 
5
+ import { normalizeVaryDeclaration } from './key.js';
6
+
7
+ // A declared vary source ('cookie:session') maps to the HTTP header a shared
8
+ // cache would have to key on.
9
+ function varyHeaderNames(vary) {
10
+ const names = new Set();
11
+ for (const raw of vary) {
12
+ const name = String(raw);
13
+ const i = name.indexOf(':');
14
+ const kind = i < 0 ? 'header' : name.slice(0, i).toLowerCase();
15
+ names.add(kind === 'cookie' ? 'Cookie' : (i < 0 ? name : name.slice(i + 1)));
16
+ }
17
+ return [...names].join(', ');
18
+ }
19
+
5
20
  /**
6
- * @param {object} entry { maxAge, swrWindow, tags?, partial?, status? }
7
- * @param {object} config route page config { mode, ... }
21
+ * @param {object} entry { maxAge, swrWindow, tags?, partial?, private?, status? }
22
+ * @param {object} config route page config { mode, vary?, ... }
8
23
  * @param {string} cacheStatus HIT | STALE | MISS | BYPASS
24
+ * @param {string[]} [vary] The declared vary the cache key used. Callers that
25
+ * omit it fall back to config.vary, which is only correct when the routeMatch
26
+ * carries no vary of its own.
9
27
  */
10
- export function buildCacheHeaders(entry = {}, config = {}, cacheStatus = 'MISS') {
28
+ export function buildCacheHeaders(entry = {}, config = {}, cacheStatus = 'MISS', vary) {
11
29
  const headers = { 'X-What-Cache': cacheStatus };
12
30
 
31
+ // A route that varies on cookies/auth renders per user. The origin cache keys
32
+ // those variants separately, but a shared cache must never hold them, so no
33
+ // `public` here regardless of maxAge.
34
+ //
35
+ // This reads normalizeVaryDeclaration() rather than testing the raw value,
36
+ // because the cache key reads it too. When these two disagreed, a declaration
37
+ // like `vary: 'cookie:session'` produced a constant key AND a `public` header,
38
+ // and the two failures pointed the same way so nothing caught it. An
39
+ // unresolvable declaration (null) is treated as varying: refusing to mark it
40
+ // public is the fail-closed direction.
41
+ const rawDeclared = vary !== undefined ? vary : config.vary;
42
+ const declared = normalizeVaryDeclaration(rawDeclared);
43
+ const varies = declared === null || declared.length > 0;
44
+ if (declared && declared.length > 0) headers.Vary = varyHeaderNames(declared);
45
+
13
46
  // Non-200 responses (soft-404s, error pages) are never cacheable — the
14
47
  // origin engine doesn't store them, and a CDN must not either.
15
48
  const isOk = entry.status == null || entry.status === 200;
16
49
  const cacheable = isOk
50
+ && !varies
51
+ && !entry.private
17
52
  && (entry.maxAge > 0 || config.mode === 'static' || config.mode === 'hybrid')
18
53
  && config.mode !== 'server';
19
54
 
package/src/index.js CHANGED
@@ -14,7 +14,7 @@ export { createRedisStore } from './stores/redis-store.js';
14
14
  export { makeEntry, isFresh, isServableStale } from './stores/store-interface.js';
15
15
 
16
16
  // Keying + headers
17
- export { cacheKey, normalizePath, normalizeQuery, hashKey } from './key.js';
17
+ export { cacheKey, normalizePath, normalizeQuery, hashKey, resolveVary, normalizeVaryDeclaration } from './key.js';
18
18
  export { buildCacheHeaders } from './headers.js';
19
19
 
20
20
  // Dynamic paths (getStaticPaths)
package/src/isr.js CHANGED
@@ -8,15 +8,27 @@
8
8
  // `render(routeMatch, ctx)` is INJECTED by the adapter (wraps renderPage +
9
9
  // serializeState) — the engine never imports what-server, keeping it standalone.
10
10
 
11
- import { cacheKey, normalizePath } from './key.js';
11
+ import { cacheKey, normalizePath, resolveVary, declaredVary } from './key.js';
12
12
  import { makeEntry, isFresh, isServableStale } from './stores/store-interface.js';
13
13
  import { buildCacheHeaders } from './headers.js';
14
+ import { safeLocalPath } from './local-path.js';
14
15
 
15
16
  export function createCacheEngine({ store, render, cdn, now = Date.now, logger = console } = {}) {
16
17
  const inFlight = new Map(); // key -> Promise<entry> (dedupe)
17
18
 
19
+ // A route may declare `vary` (['cookie:session']) in its page config; the
20
+ // adapter supplies the request headers those names resolve against. Null means
21
+ // the values are unavailable, so the render is per-user and must not be cached.
22
+ function varyFor(routeMatch) {
23
+ return resolveVary(declaredVary(routeMatch), routeMatch.varyHeaders || routeMatch.headers);
24
+ }
25
+
18
26
  function keyFor(routeMatch) {
19
- return cacheKey({ path: routeMatch.path, query: routeMatch.query, vary: routeMatch.vary });
27
+ const vary = varyFor(routeMatch);
28
+ if (vary === null) {
29
+ throw new Error('[what-isr] route declares `vary` but the adapter supplied no request headers; refusing to cache');
30
+ }
31
+ return cacheKey({ path: routeMatch.path, query: routeMatch.query, vary });
20
32
  }
21
33
 
22
34
  // Render + store, deduping concurrent calls for the same key. `renderOverride`
@@ -29,11 +41,12 @@ export function createCacheEngine({ store, render, cdn, now = Date.now, logger =
29
41
  const p = (async () => {
30
42
  const out = await doRender(routeMatch, {});
31
43
  const entry = makeEntry({ ...out, path: routeMatch.path }, routeMatch.config || {}, now());
32
- // Only 200 renders are cached. Storing a non-200 (soft-404, error page)
33
- // would serve it as a HIT until expiry bad for correctness and SEO.
34
- // The response is still returned to the caller with its real status;
35
- // any previously cached good entry is left in place.
36
- if (entry.status === 200) {
44
+ // Only public 200 renders are cached. Storing a non-200 (soft-404, error
45
+ // page) would serve it as a HIT until expiry (bad for correctness and
46
+ // SEO), and storing a per-user render would serve one visitor's HTML to
47
+ // everyone. The response is still returned to the caller with its real
48
+ // status; any previously cached good entry is left in place.
49
+ if (entry.status === 200 && !entry.private) {
37
50
  await store.set(key, entry);
38
51
  }
39
52
  return entry;
@@ -42,65 +55,85 @@ export function createCacheEngine({ store, render, cdn, now = Date.now, logger =
42
55
  return p;
43
56
  }
44
57
 
45
- function serve(entry, cacheStatus, config) {
58
+ function serve(entry, cacheStatus, routeMatch) {
46
59
  return {
47
60
  html: entry.html,
48
61
  head: entry.head,
49
62
  state: entry.state,
50
63
  status: entry.status || 200,
51
64
  cacheStatus,
52
- headers: buildCacheHeaders(entry, config || {}, cacheStatus),
65
+ headers: buildCacheHeaders(entry, routeMatch.config || {}, cacheStatus, declaredVary(routeMatch)),
53
66
  };
54
67
  }
55
68
 
56
69
  async function handle(routeMatch, renderOverride) {
57
70
  const config = routeMatch.config || {};
58
-
59
- // Uncacheable (server-rendered) routes: always render, never store.
60
- if (config.mode === 'server') {
71
+ const vary = varyFor(routeMatch);
72
+
73
+ // Uncacheable routes: server-rendered, or a `vary` declaration the adapter
74
+ // left unresolved (caching it would publish one user's HTML to everyone).
75
+ // Always render, never store.
76
+ if (config.mode === 'server' || vary === null) {
77
+ if (vary === null) {
78
+ logger.warn?.(`[what-isr] ${routeMatch.path} declares \`vary\` but no request headers were supplied; bypassing the cache`);
79
+ }
61
80
  const out = await (renderOverride || render)(routeMatch, {});
62
- const entry = makeEntry({ ...out, path: routeMatch.path }, config, now());
63
- return serve(entry, 'BYPASS', config);
81
+ const entry = makeEntry({ ...out, path: routeMatch.path, private: vary === null || out.private }, config, now());
82
+ return serve(entry, 'BYPASS', routeMatch);
64
83
  }
65
84
 
66
- const key = keyFor(routeMatch);
85
+ const key = cacheKey({ path: routeMatch.path, query: routeMatch.query, vary });
67
86
  const entry = await store.get(key);
68
87
  const t = now();
69
88
 
70
89
  if (entry && isFresh(entry, t)) {
71
- return serve(entry, 'HIT', config);
90
+ return serve(entry, 'HIT', routeMatch);
72
91
  }
73
92
 
74
93
  if (entry && isServableStale(entry, t)) {
75
94
  // Serve stale immediately; refresh in the background (deduped, non-blocking).
76
95
  regenerate(key, routeMatch, renderOverride).catch((e) => logger.error?.('[what-isr] background regenerate failed:', e));
77
- return serve(entry, 'STALE', config);
96
+ return serve(entry, 'STALE', routeMatch);
78
97
  }
79
98
 
80
99
  // Cold miss or expired beyond the swr window.
81
100
  if (entry && config.onMiss === 'stale-if-error') {
82
101
  try {
83
102
  const fresh = await regenerate(key, routeMatch, renderOverride);
84
- return serve(fresh, 'MISS', config);
103
+ return serve(fresh, 'MISS', routeMatch);
85
104
  } catch (e) {
86
105
  logger.error?.('[what-isr] regenerate failed, serving stale:', e);
87
- return serve(entry, 'STALE', config);
106
+ return serve(entry, 'STALE', routeMatch);
88
107
  }
89
108
  }
90
109
 
91
110
  const fresh = await regenerate(key, routeMatch, renderOverride);
92
- return serve(fresh, 'MISS', config);
111
+ return serve(fresh, 'MISS', routeMatch);
93
112
  }
94
113
 
95
114
  // --- On-demand invalidation (origin purge + optional CDN fan-out) ---
96
115
 
97
116
  async function revalidatePath(path, { regenerate: regen = false, routeResolver } = {}) {
98
- const norm = normalizePath(path);
117
+ // Never let a caller-supplied target reach the CDN adapter: a purge carries
118
+ // the CDN API token, so an absolute URL would exfiltrate it (SSRF).
119
+ const local = safeLocalPath(typeof path === 'string' ? path : '');
120
+ if (local === null) {
121
+ logger.warn?.(`[what-isr] refusing to revalidate a non-local path: ${path}`);
122
+ return [];
123
+ }
124
+ const norm = normalizePath(local);
99
125
  const deleted = await store.deleteByPath(norm);
100
- if (cdn && cdn.purge) await cdn.purge([path]);
126
+ if (cdn && cdn.purge) await cdn.purge([local]);
101
127
  if (regen) {
102
128
  const route = routeResolver ? routeResolver(norm) : { path: norm, query: {}, config: {} };
103
- await regenerate(keyFor(route), route).catch((e) => logger.error?.('[what-isr] regen after revalidatePath failed:', e));
129
+ // keyFor() can throw (a route declaring `vary` with no headers), so it has
130
+ // to run inside the try: as an argument expression the .catch() never
131
+ // attached and the rejection escaped revalidatePath.
132
+ try {
133
+ await regenerate(keyFor(route), route);
134
+ } catch (e) {
135
+ logger.error?.('[what-isr] regen after revalidatePath failed:', e);
136
+ }
104
137
  }
105
138
  return deleted;
106
139
  }
@@ -111,7 +144,10 @@ export function createCacheEngine({ store, render, cdn, now = Date.now, logger =
111
144
  if (regen && routeResolver) {
112
145
  for (const key of deleted) {
113
146
  const route = routeResolver(key);
114
- if (route) await regenerate(keyFor(route), route).catch(() => {});
147
+ if (!route) continue;
148
+ try {
149
+ await regenerate(keyFor(route), route);
150
+ } catch { /* best effort: a purge must not fail because a regen did */ }
115
151
  }
116
152
  }
117
153
  return deleted;
package/src/key.js CHANGED
Binary file
@@ -0,0 +1,23 @@
1
+ // Revalidation targets must be same-origin local paths before they reach a CDN
2
+ // adapter. A CDN purge sends the CDN API token as a request header, so an
3
+ // attacker-supplied absolute URL ("https://attacker.example/x",
4
+ // "http://169.254.169.254/") would exfiltrate that token and turn on-demand
5
+ // revalidation into an SSRF primitive.
6
+ //
7
+ // Backslashes matter: browsers and `new URL()` treat "\" like "/", so
8
+ // "/\evil.com" canonicalizes to http://evil.com. Reject anything starting with
9
+ // two slash-or-backslash chars or containing a backslash, then canonicalize via
10
+ // URL and require the localhost origin.
11
+
12
+ /** @returns {string|null} the canonical local "path?query", or null if unsafe. */
13
+ export function safeLocalPath(value) {
14
+ if (typeof value !== 'string' || !value.startsWith('/')) return null;
15
+ if (/^[/\\]{2}/.test(value) || value.includes('\\')) return null;
16
+ try {
17
+ const u = new URL(value, 'http://localhost');
18
+ if (u.origin !== 'http://localhost') return null;
19
+ return u.pathname + u.search;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
@@ -4,8 +4,8 @@
4
4
 
5
5
  import { mkdir, writeFile, readFile, rename, rm, readdir, stat } from 'node:fs/promises';
6
6
  import { join, dirname } from 'node:path';
7
- import { createHash } from 'node:crypto';
8
- import { hashKey } from '../key.js';
7
+ import { createHash, randomBytes } from 'node:crypto';
8
+ import { hashKey, redactVary } from '../key.js';
9
9
 
10
10
  function safeName(s) {
11
11
  return createHash('sha256').update(String(s)).digest('hex');
@@ -17,11 +17,20 @@ export function createFilesystemStore({ dir }) {
17
17
  const pathsDir = join(dir, 'paths');
18
18
 
19
19
  const entryFile = (key) => join(entriesDir, hashKey(key) + '.json');
20
- const indexFile = (base, name) => join(base, safeName(name) + '.json');
20
+ const indexDir = (base, name) => join(base, safeName(name));
21
+ const indexFile = (base, name, key) => join(indexDir(base, name), safeName(key) + '.json');
22
+ // v0.11.7 and earlier wrote one JSON array per tag/path instead of a
23
+ // directory. Left unread, an in-place upgrade would silently orphan every
24
+ // pre-upgrade entry: revalidatePath would return [] while the webhook still
25
+ // reported success, and the entries would keep being served from disk.
26
+ const legacyIndexFile = (base, name) => join(base, safeName(name) + '.json');
21
27
 
22
28
  async function atomicWrite(file, contents) {
23
29
  await mkdir(dirname(file), { recursive: true });
24
- const tmp = `${file}.${process.pid}.${safeName(file).slice(0, 8)}.tmp`;
30
+ // The temp name must be unique per write: two workers (or two concurrent
31
+ // writes in one process) targeting the same file would otherwise clobber
32
+ // each other's partial temp file before the rename.
33
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
25
34
  await writeFile(tmp, contents);
26
35
  await rename(tmp, file);
27
36
  }
@@ -34,30 +43,47 @@ export function createFilesystemStore({ dir }) {
34
43
  }
35
44
  }
36
45
 
46
+ // Reverse indexes are append-only directories with one file per key, not a
47
+ // shared JSON list: a read-modify-write of one list loses entries whenever two
48
+ // workers index different variants of the same path concurrently, orphaning an
49
+ // entry that revalidatePath would then never find.
37
50
  async function addToIndex(base, name, key) {
38
- const file = indexFile(base, name);
39
- const list = (await readJson(file)) || [];
40
- if (!list.includes(key)) {
41
- list.push(key);
42
- await atomicWrite(file, JSON.stringify(list));
43
- }
51
+ await atomicWrite(indexFile(base, name, key), JSON.stringify(key));
44
52
  }
45
53
 
46
54
  async function removeFromIndex(base, name, key) {
47
- const file = indexFile(base, name);
48
- const list = await readJson(file);
49
- if (!list) return;
50
- const next = list.filter((k) => k !== key);
51
- if (next.length) await atomicWrite(file, JSON.stringify(next));
52
- else await rm(file, { force: true });
55
+ await rm(indexFile(base, name, key), { force: true });
56
+ const legacy = await readLegacyIndex(base, name);
57
+ if (!legacy) return;
58
+ const next = legacy.filter((k) => k !== key);
59
+ if (next.length === legacy.length) return;
60
+ if (next.length) await atomicWrite(legacyIndexFile(base, name), JSON.stringify(next));
61
+ else await rm(legacyIndexFile(base, name), { force: true });
62
+ }
63
+
64
+ async function readLegacyIndex(base, name) {
65
+ const list = await readJson(legacyIndexFile(base, name));
66
+ return Array.isArray(list) ? list.filter((k) => typeof k === 'string') : null;
67
+ }
68
+
69
+ async function readIndex(base, name) {
70
+ const keys = new Set(await readLegacyIndex(base, name) || []);
71
+ const dirPath = indexDir(base, name);
72
+ let files;
73
+ try { files = await readdir(dirPath); } catch { return [...keys]; }
74
+ for (const f of files) {
75
+ if (!f.endsWith('.json')) continue;
76
+ const k = await readJson(join(dirPath, f));
77
+ if (typeof k === 'string') keys.add(k);
78
+ }
79
+ return [...keys];
53
80
  }
54
81
 
55
82
  async function deleteByIndex(base, name) {
56
- const file = indexFile(base, name);
57
- const keys = await readJson(file);
58
- if (!keys) return [];
83
+ const keys = await readIndex(base, name);
59
84
  for (const k of keys) await removeKey(k);
60
- await rm(file, { force: true });
85
+ await rm(indexDir(base, name), { recursive: true, force: true });
86
+ await rm(legacyIndexFile(base, name), { force: true });
61
87
  return keys;
62
88
  }
63
89
 
@@ -92,24 +118,35 @@ export function createFilesystemStore({ dir }) {
92
118
  return out;
93
119
  }
94
120
 
121
+ // Every key crossing into this store is redacted first, exactly as the Redis
122
+ // store does. The entry FILENAME was always safe (hashKey hashes the whole
123
+ // key), but the key is also written into the file BODY and into every reverse
124
+ // index file, and the vary segment carries raw header/cookie VALUES. A route
125
+ // declaring `vary: ['cookie:session']` was therefore writing each visitor's
126
+ // session token to disk in cleartext, three copies per entry, with no TTL on
127
+ // the index files: readable from a container image, a volume snapshot, a
128
+ // backup, or any log shipper tailing the cache dir. Redaction is idempotent,
129
+ // so the internal helpers (which read keys back out of index files) can pass
130
+ // them around without tracking whether they have been redacted already.
95
131
  return {
96
132
  async get(key) {
97
- const rec = await readJson(entryFile(key));
133
+ const rec = await readJson(entryFile(redactVary(key)));
98
134
  return rec ? rec.entry : null;
99
135
  },
100
136
  async set(key, entry) {
137
+ const rk = redactVary(key);
101
138
  // de-index any previous version's tags/path first
102
- const prev = await readJson(entryFile(key));
139
+ const prev = await readJson(entryFile(rk));
103
140
  if (prev && prev.entry) {
104
- for (const t of prev.entry.tags || []) await removeFromIndex(tagsDir, t, key);
105
- if (prev.entry.path) await removeFromIndex(pathsDir, prev.entry.path, key);
141
+ for (const t of prev.entry.tags || []) await removeFromIndex(tagsDir, t, rk);
142
+ if (prev.entry.path) await removeFromIndex(pathsDir, prev.entry.path, rk);
106
143
  }
107
- await atomicWrite(entryFile(key), JSON.stringify({ key, entry }));
108
- for (const t of entry.tags || []) await addToIndex(tagsDir, t, key);
109
- if (entry.path) await addToIndex(pathsDir, entry.path, key);
144
+ await atomicWrite(entryFile(rk), JSON.stringify({ key: rk, entry }));
145
+ for (const t of entry.tags || []) await addToIndex(tagsDir, t, rk);
146
+ if (entry.path) await addToIndex(pathsDir, entry.path, rk);
110
147
  },
111
148
  async delete(key) {
112
- return removeKey(key);
149
+ return removeKey(redactVary(key));
113
150
  },
114
151
  async deleteByTag(tag) {
115
152
  return deleteByIndex(tagsDir, tag);
@@ -1,6 +1,9 @@
1
1
  // Redis/KV cache store — the multi-instance story (N app servers share one
2
2
  // cache). Takes an INJECTED client (ioredis / node-redis shaped:
3
- // get/set/del/sadd/srem/smembers, optional keys) so this package keeps zero deps.
3
+ // get/set/del/sadd/srem/smembers, optional expire/scan/keys) so this package
4
+ // keeps zero deps.
5
+
6
+ import { redactVary } from '../key.js';
4
7
 
5
8
  export function createRedisStore({ client, namespace = 'what' } = {}) {
6
9
  if (!client) throw new Error('[what-isr] createRedisStore requires { client }');
@@ -9,6 +12,43 @@ export function createRedisStore({ client, namespace = 'what' } = {}) {
9
12
  const tk = (tag) => `${namespace}:tag:${tag}`;
10
13
  const pk = (path) => `${namespace}:path:${path}`;
11
14
 
15
+ // Redis must reclaim an entry once it is past its swr window, otherwise the
16
+ // keyspace grows without bound. Entries with no expiry (durable static pages)
17
+ // get no TTL and are dropped by explicit purge only.
18
+ function ttlSeconds(entry, now = Date.now()) {
19
+ const expiresAt = entry && entry.expiresAt;
20
+ if (expiresAt == null || expiresAt === Infinity || !Number.isFinite(expiresAt)) return 0;
21
+ const swr = Number(entry.swrWindow) || 0;
22
+ return Math.max(1, Math.ceil((expiresAt - now) / 1000) + swr);
23
+ }
24
+
25
+ // SCAN, never KEYS: KEYS is O(N) and blocks single-threaded Redis for the
26
+ // whole keyspace scan.
27
+ async function scanKeys(pattern) {
28
+ if (typeof client.scan === 'function') {
29
+ const found = new Set();
30
+ let cursor = '0';
31
+ do {
32
+ const res = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 500);
33
+ const [next, batch] = Array.isArray(res) ? res : [res.cursor, res.keys];
34
+ cursor = String(next);
35
+ for (const k of batch || []) found.add(k);
36
+ } while (cursor !== '0');
37
+ return [...found];
38
+ }
39
+ if (typeof client.keys === 'function') return (await client.keys(pattern)) || [];
40
+ return [];
41
+ }
42
+
43
+ // Every key crossing into Redis is redacted first: Redis stores key names and
44
+ // set members verbatim, so a raw vary segment would expose session cookies to
45
+ // SCAN, MONITOR and any RDB/AOF backup. Redaction is stable, so the path and
46
+ // tag reverse indexes (whose members are redacted keys) still resolve.
47
+ async function readEntry(rk) {
48
+ const v = await client.get(ck(rk));
49
+ return v ? JSON.parse(v) : null;
50
+ }
51
+
12
52
  async function deindex(key, entry) {
13
53
  if (!entry) return;
14
54
  for (const t of entry.tags || []) await client.srem(tk(t), key);
@@ -24,20 +64,23 @@ export function createRedisStore({ client, namespace = 'what' } = {}) {
24
64
 
25
65
  return {
26
66
  async get(key) {
27
- const v = await client.get(ck(key));
28
- return v ? JSON.parse(v) : null;
67
+ return readEntry(redactVary(key));
29
68
  },
30
69
  async set(key, entry) {
31
- const prev = await this.get(key);
32
- if (prev) await deindex(key, prev);
33
- await client.set(ck(key), JSON.stringify(entry));
34
- for (const t of entry.tags || []) await client.sadd(tk(t), key);
35
- if (entry.path) await client.sadd(pk(entry.path), key);
70
+ const rk = redactVary(key);
71
+ const prev = await readEntry(rk);
72
+ if (prev) await deindex(rk, prev);
73
+ await client.set(ck(rk), JSON.stringify(entry));
74
+ const ttl = ttlSeconds(entry);
75
+ if (ttl > 0 && typeof client.expire === 'function') await client.expire(ck(rk), ttl);
76
+ for (const t of entry.tags || []) await client.sadd(tk(t), rk);
77
+ if (entry.path) await client.sadd(pk(entry.path), rk);
36
78
  },
37
79
  async delete(key) {
38
- const entry = await this.get(key);
39
- await client.del(ck(key));
40
- await deindex(key, entry);
80
+ const rk = redactVary(key);
81
+ const entry = await readEntry(rk);
82
+ await client.del(ck(rk));
83
+ await deindex(rk, entry);
41
84
  return !!entry;
42
85
  },
43
86
  async deleteByTag(tag) {
@@ -47,15 +90,11 @@ export function createRedisStore({ client, namespace = 'what' } = {}) {
47
90
  return deleteBySet(pk(path));
48
91
  },
49
92
  async clear() {
50
- if (typeof client.keys === 'function') {
51
- const all = await client.keys(`${namespace}:*`);
52
- for (const k of all) await client.del(k);
53
- }
93
+ for (const k of await scanKeys(`${namespace}:*`)) await client.del(k);
54
94
  },
55
95
  async keys() {
56
- if (typeof client.keys !== 'function') return [];
57
96
  const prefix = `${namespace}:cache:`;
58
- const all = await client.keys(`${prefix}*`);
97
+ const all = await scanKeys(`${prefix}*`);
59
98
  return all.map((k) => k.slice(prefix.length));
60
99
  },
61
100
  };
@@ -15,6 +15,7 @@
15
15
  // @property {number} swrWindow Grace seconds an expired entry is still served.
16
16
  // @property {number} [status] HTTP status (for 404 stubs / fallback skeletons).
17
17
  // @property {boolean} [partial] True for fallback skeletons (never durable).
18
+ // @property {boolean} [private] True for per-user renders (never stored/shared).
18
19
  //
19
20
  // @typedef {Object} CacheStore
20
21
  // @property {(key:string)=>Promise<Entry|null>} get
@@ -25,13 +26,36 @@
25
26
  // @property {()=>Promise<void>} clear
26
27
  // @property {()=>Promise<string[]>} keys
27
28
 
29
+ // A hybrid route renders per-request data, so an entry without an explicit
30
+ // `revalidate` must NOT be fresh forever. Static routes are content-addressed by
31
+ // their build and stay durable until an explicit purge.
32
+ const HYBRID_MAX_AGE = 60;
33
+
28
34
  /**
29
35
  * Fill an Entry's time fields from `now` + a route config. Used by the ISR
30
36
  * engine so every store receives consistent expiry metadata.
31
37
  */
32
38
  export function makeEntry(out, config = {}, now = Date.now()) {
33
- const maxAge = Number(config.revalidate) || 0;
39
+ // `??`, not `||`: an explicit `revalidate: 0` is a real value and must not be
40
+ // replaced by the hybrid default.
41
+ const fallbackMaxAge = config.mode === 'hybrid' ? HYBRID_MAX_AGE : 0;
42
+ const hasExplicitRevalidate = config.revalidate != null;
43
+ const declaredMaxAge = Number(config.revalidate ?? fallbackMaxAge);
44
+ const maxAge = Number.isFinite(declaredMaxAge) ? declaredMaxAge : fallbackMaxAge;
34
45
  const swrWindow = config.swr != null ? Number(config.swr) : maxAge;
46
+
47
+ // maxAge 0 has two completely opposite meanings and they must not collapse:
48
+ // - NOT declared, static route: the page is content-addressed by its build
49
+ // and stays durable until an explicit purge. expiresAt = Infinity.
50
+ // - declared `revalidate: 0`: "always revalidate", the same meaning it has
51
+ // in Next. expiresAt = now, so isFresh() is false from the first read and
52
+ // every request regenerates.
53
+ // Reading it as Infinity in the second case turned the one setting a developer
54
+ // reaches for to DISABLE caching into cache-forever, which is the worst
55
+ // possible direction for the bug to point.
56
+ const expiresAt = maxAge > 0
57
+ ? now + maxAge * 1000
58
+ : (hasExplicitRevalidate ? now : Infinity);
35
59
  return {
36
60
  html: out.html || '',
37
61
  head: out.head || '',
@@ -40,10 +64,11 @@ export function makeEntry(out, config = {}, now = Date.now()) {
40
64
  path: out.path || config.path,
41
65
  status: out.status || 200,
42
66
  partial: !!out.partial,
67
+ private: !!(out.private || out.usedRequestHeaders),
43
68
  renderedAt: now,
44
69
  maxAge,
45
70
  swrWindow,
46
- expiresAt: maxAge > 0 ? now + maxAge * 1000 : Infinity,
71
+ expiresAt,
47
72
  };
48
73
  }
49
74
 
package/src/webhook.js CHANGED
@@ -2,23 +2,27 @@
2
2
  // secret purges paths/tags so a Sanity/Contentful/WP "published" event can warm
3
3
  // or drop cache entries. The adapter mounts this at e.g. /__what_revalidate.
4
4
 
5
- // Constant-time string compare (timing-attack safe). Self-contained so the cache
6
- // package stays dependency-free and decoupled from what-server.
5
+ import { createHash, timingSafeEqual } from 'node:crypto';
6
+
7
+ // Constant-time compare over fixed-width digests, so neither the secret's
8
+ // contents nor its LENGTH leak through response timing.
7
9
  function safeEqual(a, b) {
8
10
  if (typeof a !== 'string' || typeof b !== 'string') return false;
9
- if (a.length !== b.length) return false;
10
- let result = 0;
11
- for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
12
- return result === 0;
11
+ const digest = (s) => createHash('sha256').update(s).digest();
12
+ return timingSafeEqual(digest(a), digest(b));
13
13
  }
14
14
 
15
+ // One request must not be able to force an unbounded number of blocking origin
16
+ // renders. CMS webhooks publish a handful of paths at a time.
17
+ const MAX_BATCH = 100;
18
+
15
19
  /**
16
20
  * @param engine cache engine (revalidatePath / revalidateTag)
17
- * @param options { secret, header='x-what-revalidate-secret', regenerate=false }
18
- * @returns async (reqLike:{headers, body:{paths?,tags?,regenerate?}}) -> { status, body }
21
+ * @param options { secret, header='x-what-revalidate-secret', regenerate=false, maxBatch=100 }
22
+ * @returns async (reqLike:{headers, body:{paths?,tags?}}) -> { status, body }
19
23
  */
20
24
  export function createRevalidateWebhook(engine, options = {}) {
21
- const { secret, header = 'x-what-revalidate-secret', regenerate = false } = options;
25
+ const { secret, header = 'x-what-revalidate-secret', regenerate = false, maxBatch = MAX_BATCH } = options;
22
26
 
23
27
  return async function handle(reqLike) {
24
28
  const provided = (reqLike.headers || {})[header] || (reqLike.headers || {})[header.toLowerCase()];
@@ -31,21 +35,27 @@ export function createRevalidateWebhook(engine, options = {}) {
31
35
  return { status: 400, body: { message: 'Invalid body' } };
32
36
  }
33
37
 
34
- const { paths, tags, regenerate: regen = regenerate } = body;
38
+ // `regenerate` is operator policy: the request body must not be able to turn
39
+ // blocking re-renders on.
40
+ const { paths, tags } = body;
35
41
  if (!Array.isArray(paths) && !Array.isArray(tags)) {
36
42
  return { status: 400, body: { message: 'Provide `paths` and/or `tags` arrays' } };
37
43
  }
44
+ const total = (Array.isArray(paths) ? paths.length : 0) + (Array.isArray(tags) ? tags.length : 0);
45
+ if (total > maxBatch) {
46
+ return { status: 400, body: { message: `Too many entries: ${total} exceeds the ${maxBatch} per-request limit` } };
47
+ }
38
48
 
39
49
  const revalidated = { paths: [], tags: [] };
40
50
  if (Array.isArray(paths)) {
41
51
  for (const p of paths) {
42
- await engine.revalidatePath(p, { regenerate: regen });
52
+ await engine.revalidatePath(p, { regenerate });
43
53
  revalidated.paths.push(p);
44
54
  }
45
55
  }
46
56
  if (Array.isArray(tags)) {
47
57
  for (const t of tags) {
48
- await engine.revalidateTag(t, { regenerate: regen });
58
+ await engine.revalidateTag(t, { regenerate });
49
59
  revalidated.tags.push(t);
50
60
  }
51
61
  }