what-isr 0.12.3 → 0.13.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 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};
1
+ import{createHash as z}from"node:crypto";var L="\0",G=new Set(["fbclid","gclid","_","mc_cid","mc_eid"]),Q=["utm_"];function M(t){if(G.has(t))return!0;for(let e of Q)if(t.startsWith(e))return!0;return!1}function $(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 Z(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 Z(t).filter(([r])=>!M(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 O(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 P(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),w=l.indexOf(":"),m=w<0?"header":l.slice(0,w).toLowerCase(),n=w<0?l:l.slice(w+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 R({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?P(r,s):d?r:null;if(h===null)throw Object.assign(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)"),{code:"ERR_ISR_VARY_UNRESOLVED"});return[$(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)+z("sha256").update(s).digest("hex"):e}function B(t){let e=z("sha256").update(t).digest("hex");return`${e.slice(0,2)}/${e.slice(2,4)}/${e.slice(4)}`}function I(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,w=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:w}}function N(t,e=Date.now()){return t.expiresAt===1/0||e<t.expiresAt}function V(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 W(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 _(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 P(O(a),a.varyHeaders||a.headers)}function l(a){let g=h(a);if(g===null)throw Object.assign(new Error("[what-isr] route declares `vary` but the adapter supplied no request headers; refusing to cache"),{code:"ERR_ISR_VARY_NO_HEADERS"});return R({path:a.path,query:a.query,vary:g})}function w(a,g,S){let y=d.get(a);if(y)return y;let i=S||e,u=(async()=>{let p=await i(g,{}),x=I({...p,path:g.path},g.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,g,S){return{html:a.html,head:a.head,state:a.state,status:a.status||200,cacheStatus:g,headers:W(a,S.config||{},g,O(S))}}async function n(a,g){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 A=await(g||e)(a,{}),b=I({...A,path:a.path,private:y===null||A.private},S,s());return m(b,"BYPASS",a)}let i=R({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&&V(u,p))return w(i,a,g).catch(A=>c.error?.("[what-isr] background regenerate failed:",A)),m(u,"STALE",a);if(u&&S.onMiss==="stale-if-error")try{let A=await w(i,a,g);return m(A,"MISS",a)}catch(A){return c.error?.("[what-isr] regenerate failed, serving stale:",A),m(u,"STALE",a)}let x=await w(i,a,g);return m(x,"MISS",a)}async function o(a,{regenerate:g=!1,routeResolver:S}={}){let y=_(typeof a=="string"?a:"");if(y===null)return c.warn?.(`[what-isr] refusing to revalidate a non-local path: ${a}`),[];let i=$(y),u=await t.deleteByPath(i);if(r&&r.purge&&await r.purge([y]),g){let p=S?S(i):{path:i,query:{},config:{}};try{await w(l(p),p)}catch(x){c.error?.("[what-isr] regen after revalidatePath failed:",x)}}return u}async function f(a,{regenerate:g=!1,routeResolver:S}={}){let y=await t.deleteByTag(a);if(r&&r.purgeTags&&await r.purgeTags([a]),g&&S)for(let i of y){let u=S(i);if(u)try{await w(l(u),u)}catch{}}return y}return{handle:n,regenerate:a=>w(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 w(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 g of a)w(g);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;w(f)}},async delete(n){return w(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 E,readdir as j,stat as J}from"node:fs/promises";import{join as v,dirname as ut}from"node:path";import{createHash as ft,randomBytes as lt}from"node:crypto";function H(t){return ft("sha256").update(String(t)).digest("hex")}function dt({dir:t}){let e=v(t,"entries"),r=v(t,"tags"),s=v(t,"paths"),c=i=>v(e,B(i)+".json"),d=(i,u)=>v(i,H(u)),h=(i,u,p)=>v(d(i,u),H(p)+".json"),l=(i,u)=>v(i,H(u)+".json");async function w(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 w(h(i,u,p),JSON.stringify(p))}async function o(i,u,p){await E(h(i,u,p),{force:!0});let x=await f(i,u);if(!x)return;let A=x.filter(b=>b!==p);A.length!==x.length&&(A.length?await w(l(i,u),JSON.stringify(A)):await E(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),A;try{A=await j(x)}catch{return[...p]}for(let b of A){if(!b.endsWith(".json"))continue;let k=await m(v(x,b));typeof k=="string"&&p.add(k)}return[...p]}async function g(i,u){let p=await a(i,u);for(let x of p)await S(x);return await E(d(i,u),{recursive:!0,force:!0}),await E(l(i,u),{force:!0}),p}async function S(i){let u=await m(c(i));if(!u)return!1;await E(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 j(e)}catch{return i}for(let p of u){let x=v(e,p),A;try{A=await J(x)}catch{continue}if(A.isDirectory())for(let b of await j(x)){let k=v(x,b);try{if(!(await J(k)).isDirectory())continue}catch{continue}for(let K of await j(k)){if(!K.endsWith(".json"))continue;let F=await m(v(k,K));F&&F.key!=null&&i.push(F.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 A of x.entry.tags||[])await o(r,A,p);x.entry.path&&await o(s,x.entry.path,p)}await w(c(p),JSON.stringify({key:p,entry:u}));for(let A of u.tags||[])await n(r,A,p);u.path&&await n(s,u.path,p)},async delete(i){return S(C(i))},async deleteByTag(i){return g(r,i)},async deleteByPath(i){return g(s,i)},async clear(){await E(t,{recursive:!0,force:!0})},async keys(){return y()}}}function pt({client:t,namespace:e="what"}={}){if(!t)throw Object.assign(new Error("[what-isr] createRedisStore requires { client }"),{code:"ERR_ISR_MISSING_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),[g,S]=Array.isArray(a)?a:[a.cursor,a.keys];f=String(g);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 w(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 w(f,a),await t.set(r(f),JSON.stringify(o));let g=d(o);g>0&&typeof t.expire=="function"&&await t.expire(r(f),g);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 w(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 gt(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 wt(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 At=100;function vt(t,e={}){let{secret:r,header:s="x-what-revalidate-secret",regenerate:c=!1,maxBatch:d=At}=e;return async function(l){let w=(l.headers||{})[s]||(l.headers||{})[s.toLowerCase()];if(!r||!St(w||"",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 g of n)await t.revalidatePath(g,{regenerate:c}),a.paths.push(g);if(Array.isArray(o))for(let g of o)await t.revalidateTag(g,{regenerate:c}),a.tags.push(g);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=[],w=[],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?g(y):w.push(y))}async function g(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&&w.length;)g(w.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 w.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=_(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 w=c(l);w&&await fetch(w,{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{W as buildCacheHeaders,yt as buildPath,R as cacheKey,nt as createCacheEngine,U as createCloudflareCDN,X as createFastlyCDN,dt as createFilesystemStore,at as createMemoryStore,pt as createRedisStore,vt as createRevalidateWebhook,bt as createScheduler,Y as createVercelCDN,wt as decideFallback,B as hashKey,N as isFresh,gt as isKnownParams,V as isServableStale,I as makeEntry,$ as normalizePath,q as normalizeQuery,D as normalizeVaryDeclaration,ht as resolveStaticPaths,P as resolveVary};
package/index.d.ts CHANGED
@@ -20,12 +20,30 @@ export interface PageCacheConfig {
20
20
  */
21
21
  vary?: string[] | string;
22
22
  fallback?: 'blocking' | boolean;
23
- onMiss?: 'blocking' | string;
24
- /** Background regeneration interval, in seconds. */
25
- pollInterval?: number;
23
+ /** 'stale-if-error' serves an expired entry rather than failing when a cold render throws. */
24
+ onMiss?: 'stale-if-error' | 'blocking' | string;
26
25
  }
26
+ // `pollInterval` was declared on the config above and read by nothing, here or
27
+ // in what-server: setting it produced no error and no regeneration. Poll
28
+ // regeneration itself is real, but it is driven explicitly, because it only
29
+ // means anything in a long-lived process. Build a scheduler, register the routes
30
+ // worth keeping warm, and hand it to the Node adapter, which starts and stops
31
+ // it with the server:
32
+ //
33
+ // const scheduler = createScheduler(engine);
34
+ // scheduler.register({ path: '/', query: {}, params: {}, config: page, route }, { intervalMs: 300_000 });
35
+ // createServer({ routes, cache: engine, scheduler });
36
+ //
37
+ // Declaring the key again is only honest once something resolves it into that
38
+ // register() call for every route that sets it.
27
39
 
28
40
  // --- Stores ---
41
+ // The time fields are the ones makeEntry() writes and isFresh() /
42
+ // isServableStale() / the Redis TTL read. They were declared as
43
+ // `createdAt` + `revalidate` + `swr`, none of which exist on a real entry: a
44
+ // hand-built entry following that shape has no `expiresAt`, so isFresh()
45
+ // compares against undefined, every read is a miss, and the cache silently
46
+ // never serves. Build entries with makeEntry() rather than by hand.
29
47
  export interface CacheEntry {
30
48
  html: string;
31
49
  head?: string;
@@ -33,21 +51,28 @@ export interface CacheEntry {
33
51
  status?: number;
34
52
  tags?: string[];
35
53
  path?: string;
54
+ /** A partial render (skeleton/streamed shell). */
55
+ partial?: boolean;
36
56
  /** Per-user render: never stored, never served from a shared cache. */
37
57
  private?: boolean;
38
- /** Epoch ms when the entry was created. */
39
- createdAt: number;
40
- /** Seconds-to-stale snapshot from the page config. */
41
- revalidate?: number;
42
- swr?: number;
58
+ /** Epoch ms when the entry was rendered. */
59
+ renderedAt: number;
60
+ /** Seconds until stale. 0 means "always revalidate" when `revalidate: 0` was declared. */
61
+ maxAge: number;
62
+ /** Extra seconds the entry may be served stale while it regenerates. */
63
+ swrWindow: number;
64
+ /** Epoch ms the entry goes stale, or Infinity for a durable static entry. */
65
+ expiresAt: number;
43
66
  }
44
67
 
45
68
  export interface CacheStore {
46
69
  get(key: string): Promise<CacheEntry | undefined>;
47
70
  set(key: string, entry: CacheEntry): Promise<void>;
48
71
  delete(key: string): Promise<boolean>;
72
+ /** Both delete-by helpers return the keys they removed. */
49
73
  deleteByTag(tag: string): Promise<string[]>;
50
- deleteByPath?(path: string): Promise<string[]>;
74
+ /** Not optional: revalidatePath() calls this unconditionally, so a store without it throws on the first purge. */
75
+ deleteByPath(path: string): Promise<string[]>;
51
76
  clear(): Promise<void>;
52
77
  keys(): Promise<string[]>;
53
78
  }
@@ -56,7 +81,18 @@ export function createMemoryStore(options?: { max?: number }): CacheStore;
56
81
  export function createFilesystemStore(options: { dir: string; shards?: number }): CacheStore;
57
82
  export function createRedisStore(options: { client: unknown; prefix?: string }): CacheStore;
58
83
 
59
- export function makeEntry(out: Partial<CacheEntry>, config?: PageCacheConfig, now?: number): CacheEntry;
84
+ /** Fill an entry's time fields from a render result plus the route config. */
85
+ export function makeEntry(
86
+ out: Partial<RenderResult> & {
87
+ path?: string;
88
+ partial?: boolean;
89
+ private?: boolean;
90
+ /** A render that read request headers is per-user, so it stores as `private`. */
91
+ usedRequestHeaders?: boolean;
92
+ },
93
+ config?: PageCacheConfig,
94
+ now?: number,
95
+ ): CacheEntry;
60
96
  export function isFresh(entry: CacheEntry, now?: number): boolean;
61
97
  export function isServableStale(entry: CacheEntry, now?: number): boolean;
62
98
 
@@ -95,10 +131,20 @@ export function createFastlyCDN(options: { serviceId: string; apiToken: string;
95
131
  export function createVercelCDN(options: { projectId: string; token: string; teamId?: string }): CDNAdapter;
96
132
 
97
133
  // --- Headers ---
98
- export interface CacheHeaderOptions {
99
- cdn?: boolean;
100
- }
101
- export function buildCacheHeaders(entry?: CacheEntry, status?: 'HIT' | 'STALE' | 'MISS', options?: CacheHeaderOptions): Record<string, string>;
134
+ export type CacheStatus = 'HIT' | 'STALE' | 'MISS' | 'BYPASS';
135
+ /**
136
+ * Build the Cache-Control / X-What-Cache / Cache-Tag headers for a response.
137
+ * The second argument is the ROUTE CONFIG, not the cache status: the previous
138
+ * declaration ((entry, status, { cdn })) described a function that does not
139
+ * exist, so a caller who followed it passed the status where the config goes.
140
+ */
141
+ export function buildCacheHeaders(
142
+ entry?: Partial<CacheEntry>,
143
+ config?: PageCacheConfig,
144
+ cacheStatus?: CacheStatus,
145
+ /** The declared vary the cache key used. Falls back to `config.vary`. */
146
+ vary?: string[],
147
+ ): Record<string, string>;
102
148
 
103
149
  // --- Static paths ---
104
150
  export interface StaticPathEntry {
@@ -132,14 +178,17 @@ export interface RenderResult {
132
178
  status?: number;
133
179
  tags?: string[];
134
180
  }
135
- export type RenderFn = (routeMatch: RouteMatch) => RenderResult | Promise<RenderResult>;
181
+ /** The engine calls render(routeMatch, ctx); `ctx` is reserved and currently `{}`. */
182
+ export type RenderFn = (routeMatch: RouteMatch, ctx?: Record<string, unknown>) => RenderResult | Promise<RenderResult>;
136
183
 
137
184
  // --- ISR engine ---
138
185
  export interface ServeResult {
139
186
  html: string;
187
+ head?: string;
188
+ state?: unknown;
140
189
  status: number;
141
190
  headers: Record<string, string>;
142
- cacheStatus: 'HIT' | 'STALE' | 'MISS';
191
+ cacheStatus: CacheStatus;
143
192
  }
144
193
  export interface RevalidateOptions {
145
194
  regenerate?: boolean;
@@ -147,8 +196,13 @@ export interface RevalidateOptions {
147
196
  }
148
197
  export interface CacheEngine {
149
198
  handle(routeMatch: RouteMatch, renderOverride?: RenderFn): Promise<ServeResult>;
150
- revalidatePath(path: string, options?: RevalidateOptions): Promise<void>;
151
- revalidateTag(tag: string, options?: RevalidateOptions): Promise<void>;
199
+ /** Render and re-store one route now. What the poll scheduler calls per tick. */
200
+ regenerate(routeMatch: RouteMatch): Promise<CacheEntry>;
201
+ /** Both purges resolve to the cache keys they deleted, not to void. */
202
+ revalidatePath(path: string, options?: RevalidateOptions): Promise<string[]>;
203
+ revalidateTag(tag: string, options?: RevalidateOptions): Promise<string[]>;
204
+ /** The cache key for a route match. Throws when the route declares `vary` and no headers were supplied. */
205
+ keyFor(routeMatch: RouteMatch): string;
152
206
  store: CacheStore;
153
207
  }
154
208
  export function createCacheEngine(options?: {
@@ -174,9 +228,24 @@ export function createRevalidateWebhook(
174
228
  ): (req: WebhookRequest) => Promise<WebhookResponse>;
175
229
 
176
230
  // --- Poll scheduler ---
231
+ // Register the routes to keep warm, then start it (or hand it to what-server's
232
+ // createServer, which starts it and stops it on SIGTERM/SIGINT). Every method
233
+ // returns the scheduler so registration can chain.
177
234
  export interface Scheduler {
178
- register(route: RouteMatch, options: { intervalMs: number }): void;
179
- start(): void;
180
- stop(): void;
235
+ register(route: RouteMatch, options: { intervalMs: number }): Scheduler;
236
+ start(): Scheduler;
237
+ stop(): Scheduler;
238
+ }
239
+ // The runtime reads maxConcurrent/random/setTimer/clearTimer/logger. The
240
+ // `concurrency` and `jitter` declared before were read by nothing, so a caller
241
+ // capping concurrency got the default 4 and no warning.
242
+ export interface SchedulerOptions {
243
+ /** Regenerations allowed in flight at once. Default 4. */
244
+ maxConcurrent?: number;
245
+ /** Jitter source (0..1), injectable for deterministic tests. Intervals are spread by up to +10%. */
246
+ random?: () => number;
247
+ setTimer?: (fn: () => void, ms: number) => any;
248
+ clearTimer?: (timer: any) => void;
249
+ logger?: Pick<Console, 'error' | 'warn' | 'log'>;
181
250
  }
182
- export function createScheduler(engine: CacheEngine, options?: { concurrency?: number; jitter?: number }): Scheduler;
251
+ export function createScheduler(engine: CacheEngine, options?: SchedulerOptions): Scheduler;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-isr",
3
- "version": "0.12.3",
3
+ "version": "0.13.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.12.3"
33
+ "what-server": "^0.13.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "what-server": {
@@ -1,6 +1,11 @@
1
1
  // Cloudflare CDN adapter — edge purge by URL or cache-tag (Enterprise tag purge).
2
2
  // Uses global fetch (Node 18+); credentials injected by the deploy adapter.
3
3
 
4
+ /**
5
+ * @param {object} [credentials]
6
+ * @param {string} [credentials.zoneId]
7
+ * @param {string} [credentials.apiToken]
8
+ */
4
9
  export function createCloudflareCDN({ zoneId, apiToken } = {}) {
5
10
  const endpoint = `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`;
6
11
  const headers = { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' };
package/src/cdn/fastly.js CHANGED
@@ -2,8 +2,15 @@
2
2
 
3
3
  import { safeLocalPath } from '../local-path.js';
4
4
 
5
+ /**
6
+ * @param {object} [credentials]
7
+ * @param {string} [credentials.serviceId]
8
+ * @param {string} [credentials.apiToken]
9
+ * @param {string} [credentials.baseUrl]
10
+ */
5
11
  export function createFastlyCDN({ serviceId, apiToken, baseUrl } = {}) {
6
- const headers = { 'Fastly-Key': apiToken, Accept: 'application/json' };
12
+ /** @type {Record<string, string>} */
13
+ const headers = { 'Fastly-Key': apiToken ?? '', Accept: 'application/json' };
7
14
 
8
15
  // A URL purge is a request to an arbitrary host carrying the Fastly API key,
9
16
  // so only local paths resolved against the configured baseUrl are ever sent.
package/src/cdn/vercel.js CHANGED
@@ -2,6 +2,12 @@
2
2
  // a thin on-demand purge via the project API when a token is supplied; it
3
3
  // no-ops (header-only mode) without one.
4
4
 
5
+ /**
6
+ * @param {object} [credentials]
7
+ * @param {string} [credentials.token]
8
+ * @param {string} [credentials.projectId]
9
+ * @param {string} [credentials.teamId]
10
+ */
5
11
  export function createVercelCDN({ token, projectId, teamId } = {}) {
6
12
  const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
7
13
  const qs = teamId ? `?teamId=${teamId}` : '';
package/src/isr.js CHANGED
@@ -13,6 +13,14 @@ import { makeEntry, isFresh, isServableStale } from './stores/store-interface.js
13
13
  import { buildCacheHeaders } from './headers.js';
14
14
  import { safeLocalPath } from './local-path.js';
15
15
 
16
+ /**
17
+ * @param {object} [options]
18
+ * @param {any} [options.store] cache store (see stores/store-interface.js)
19
+ * @param {(routeMatch: any, ctx?: any) => any} [options.render] injected by the adapter
20
+ * @param {any} [options.cdn] optional CDN adapter for edge fan-out
21
+ * @param {() => number} [options.now]
22
+ * @param {any} [options.logger]
23
+ */
16
24
  export function createCacheEngine({ store, render, cdn, now = Date.now, logger = console } = {}) {
17
25
  const inFlight = new Map(); // key -> Promise<entry> (dedupe)
18
26
 
@@ -26,7 +34,11 @@ export function createCacheEngine({ store, render, cdn, now = Date.now, logger =
26
34
  function keyFor(routeMatch) {
27
35
  const vary = varyFor(routeMatch);
28
36
  if (vary === null) {
29
- throw new Error('[what-isr] route declares `vary` but the adapter supplied no request headers; refusing to cache');
37
+ // ERROR_CODES.ISR_VARY_NO_HEADERS
38
+ throw Object.assign(
39
+ new Error('[what-isr] route declares `vary` but the adapter supplied no request headers; refusing to cache'),
40
+ { code: 'ERR_ISR_VARY_NO_HEADERS' },
41
+ );
30
42
  }
31
43
  return cacheKey({ path: routeMatch.path, query: routeMatch.query, vary });
32
44
  }
@@ -113,6 +125,10 @@ export function createCacheEngine({ store, render, cdn, now = Date.now, logger =
113
125
 
114
126
  // --- On-demand invalidation (origin purge + optional CDN fan-out) ---
115
127
 
128
+ /**
129
+ * @param {string} path
130
+ * @param {{ regenerate?: boolean, routeResolver?: (path: string) => any }} [options]
131
+ */
116
132
  async function revalidatePath(path, { regenerate: regen = false, routeResolver } = {}) {
117
133
  // Never let a caller-supplied target reach the CDN adapter: a purge carries
118
134
  // the CDN API token, so an absolute URL would exfiltrate it (SSRF).
@@ -138,6 +154,10 @@ export function createCacheEngine({ store, render, cdn, now = Date.now, logger =
138
154
  return deleted;
139
155
  }
140
156
 
157
+ /**
158
+ * @param {string} tag
159
+ * @param {{ regenerate?: boolean, routeResolver?: (key: string) => any }} [options]
160
+ */
141
161
  async function revalidateTag(tag, { regenerate: regen = false, routeResolver } = {}) {
142
162
  const deleted = await store.deleteByTag(tag);
143
163
  if (cdn && cdn.purgeTags) await cdn.purgeTags([tag]);
package/src/key.js CHANGED
@@ -157,8 +157,16 @@ function varyString(vary) {
157
157
  .join('&');
158
158
  }
159
159
 
160
- /** Build the canonical cache key for a request. `vary` may be a resolved
161
- * name -> value map, or a declared list resolved here against `headers`. */
160
+ /**
161
+ * Build the canonical cache key for a request. `vary` may be a resolved
162
+ * name -> value map, or a declared list resolved here against `headers`.
163
+ *
164
+ * @param {object} [request]
165
+ * @param {string} [request.path]
166
+ * @param {string|Record<string, any>} [request.query]
167
+ * @param {string|string[]|Record<string, any>} [request.vary]
168
+ * @param {Record<string, any>} [request.headers]
169
+ */
162
170
  export function cacheKey({ path, query, vary, headers } = {}) {
163
171
  // A string or an array is a DECLARATION and has to be resolved against the
164
172
  // request. A plain object is an already-resolved name -> value map (what the
@@ -169,7 +177,11 @@ export function cacheKey({ path, query, vary, headers } = {}) {
169
177
  const isResolvedMap = vary == null || (typeof vary === 'object' && !Array.isArray(vary));
170
178
  const resolved = isDeclaration ? resolveVary(vary, headers) : (isResolvedMap ? vary : null);
171
179
  if (resolved === null) {
172
- 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)');
180
+ // ERROR_CODES.ISR_VARY_UNRESOLVED
181
+ throw Object.assign(
182
+ 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)'),
183
+ { code: 'ERR_ISR_VARY_UNRESOLVED' },
184
+ );
173
185
  }
174
186
  return [normalizePath(path), normalizeQuery(query), varyString(resolved)].join(KEY_SEP);
175
187
  }
@@ -5,8 +5,18 @@
5
5
 
6
6
  import { redactVary } from '../key.js';
7
7
 
8
+ /**
9
+ * @param {object} [options]
10
+ * @param {any} [options.client] ioredis / node-redis shaped client
11
+ * @param {string} [options.namespace]
12
+ */
8
13
  export function createRedisStore({ client, namespace = 'what' } = {}) {
9
- if (!client) throw new Error('[what-isr] createRedisStore requires { client }');
14
+ // ERROR_CODES.ISR_MISSING_CLIENT
15
+ if (!client) {
16
+ throw Object.assign(new Error('[what-isr] createRedisStore requires { client }'), {
17
+ code: 'ERR_ISR_MISSING_CLIENT',
18
+ });
19
+ }
10
20
 
11
21
  const ck = (key) => `${namespace}:cache:${key}`;
12
22
  const tk = (tag) => `${namespace}:tag:${tag}`;
package/src/webhook.js CHANGED
@@ -17,9 +17,14 @@ function safeEqual(a, b) {
17
17
  const MAX_BATCH = 100;
18
18
 
19
19
  /**
20
- * @param engine cache engine (revalidatePath / revalidateTag)
21
- * @param options { secret, header='x-what-revalidate-secret', regenerate=false, maxBatch=100 }
22
- * @returns async (reqLike:{headers, body:{paths?,tags?}}) -> { status, body }
20
+ * @param {{ revalidatePath: Function, revalidateTag: Function }} engine cache engine
21
+ * @param {object} [options]
22
+ * @param {string} [options.secret] shared secret the caller must present
23
+ * @param {string} [options.header] header carrying the secret
24
+ * @param {boolean} [options.regenerate] re-render each invalidated entry
25
+ * @param {number} [options.maxBatch] cap on paths + tags per request
26
+ * @returns {(reqLike: { headers?: Record<string, any>, body?: any }) =>
27
+ * Promise<{ status: number, body: any }>}
23
28
  */
24
29
  export function createRevalidateWebhook(engine, options = {}) {
25
30
  const { secret, header = 'x-what-revalidate-secret', regenerate = false, maxBatch = MAX_BATCH } = options;
@@ -46,6 +51,7 @@ export function createRevalidateWebhook(engine, options = {}) {
46
51
  return { status: 400, body: { message: `Too many entries: ${total} exceeds the ${maxBatch} per-request limit` } };
47
52
  }
48
53
 
54
+ /** @type {{ paths: any[], tags: any[] }} */
49
55
  const revalidated = { paths: [], tags: [] };
50
56
  if (Array.isArray(paths)) {
51
57
  for (const p of paths) {