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 +1 -1
- package/index.d.ts +91 -22
- package/package.json +2 -2
- package/src/cdn/cloudflare.js +5 -0
- package/src/cdn/fastly.js +8 -1
- package/src/cdn/vercel.js +6 -0
- package/src/isr.js +21 -1
- package/src/key.js +15 -3
- package/src/stores/redis-store.js +11 -1
- package/src/webhook.js +9 -3
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
|
-
|
|
24
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
/** Seconds
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
151
|
-
|
|
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 }):
|
|
179
|
-
start():
|
|
180
|
-
stop():
|
|
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?:
|
|
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.
|
|
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.
|
|
33
|
+
"what-server": "^0.13.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependenciesMeta": {
|
|
36
36
|
"what-server": {
|
package/src/cdn/cloudflare.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
161
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
* @param
|
|
22
|
-
* @
|
|
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) {
|