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