Skip to content

Instantly share code, notes, and snippets.

@nghyane
Last active July 25, 2026 05:54
Show Gist options
  • Select an option

  • Save nghyane/92a2c43484f1ddab0e7a793b038e7a07 to your computer and use it in GitHub Desktop.

Select an option

Save nghyane/92a2c43484f1ddab0e7a793b038e7a07 to your computer and use it in GitHub Desktop.
edgekit — tiny self-hosted popunder ad loader for content sites (round-robin, tab-under, retention-first)

edgekit

Tiny (~5 KB) self-hosted popunder ad loader for content sites (manga/anime readers, streaming, forums). One static file, zero dependencies: round-robin across your ad networks, fire-on-click, idle-time injection (no LCP hit), optional per-network daily/hourly cap-skip, and an undetectable per-network audit counter.

Built by reverse-engineering how popunder networks (BetterJsPop/HilltopAds, AdMaven) actually behave, then tuning against real reader behavior. Not a network — a loader you point at your own ad networks.

Model in one line

Enter page → click → pop fires. Next page → click → pop fires. Each page-load serves the next network in your list (round-robin), re-injected fresh. No artificial session cap — pacing is left to each network's own dashboard (where it belongs).

Why it's built this way

  • Multi-page reality: on a normal content site every navigation is a full page-load, so any injected tag is gone on the next page. edgekit re-injects the next network on every page-load — persisting only a tiny rotation cursor and click counter in localStorage.
  • Round-robin, pick-next-available: each page-load advances to the next network, but skips any network that hit its declared cap (see below) — so a maxed-out network never wastes a page-load loading a script that won't pop; the turn goes to a network that can still fill. Want a higher-eCPM network more often? List it more than once in networks.
  • Performance-first injection: ad scripts are heavy and third-party. edgekit injects the tag at requestIdleCallback (or the user's first interaction, whichever comes first, 1.5 s cap) — never during the critical render path, so it doesn't hurt LCP/FID. preconnect warms the network domain up front so the idle-time fetch is fast.
  • Optional per-network cap (opt-in): declare cap: { hour, day } matching a network's own dashboard setting and edgekit will skip loading it once the client-side audit hits that count. This is a skip-load optimization — it never blocks window.open (that would hijack the tab). Leave cap off and the network just self-throttles server-side as before.
  • Never blocks a tag's window.open — the one hard rule (see below). Blocking it hijacks the user's tab.
  • Self-hosted, first-party: one static file on your own domain is harder for filter lists to block than a known ad-network URL, and config changes need no site redeploy.

Install

<script>
  window.EdgeKitConfig = {
    networks: [
      { tag: '//your-network-tag/....js' },   // JS-tag popunder (inject each turn, native)
      { tag: 'https://x.com/tag.min.js', data: { zone: '11383304' } }, // zone-tag (Monetag-style): data-* attrs
      { key: 'popads', cap: { day: 2 }, html: '<script>…any snippet verbatim…</script>' }, // universal inline
      { url: 'https://your-smartlink/xxx', mode: 'under', cap: { hour: 3 } },  // direct-link (self-fired)
    ],
    unlockClicks: 2,   // for self-fired `url` networks: clicks needed to fire one pop
  };
</script>
<script async src="https://edgekit-sdk.pages.dev/edgekit.js"></script>

Or via data-*:

<script async src="https://edgekit-sdk.pages.dev/edgekit.js" data-unlock-clicks="2"></script>

mode (self-fired url only): 'under' (default — tab-under: content opens in a new tab, the ad replaces the current one) or 'popup' (ad opens in a new foreground tab, current tab untouched).

data (JS-tag only): a map applied as data-* attributes on the injected <script> (set before src, so the tag reads them on load). For zone-based loaders like Monetag — { tag: 'https://x.com/tag.min.js', data: { zone: '11383304' } } injects <script src="…" data-zone="11383304">.

html (universal): paste the exact snippet a network gives you — inline <script> bootstraps (PopAds-style), banners, anything. edgekit parses it and re-creates the <script> elements so they execute (setting innerHTML alone won't run scripts). Use this for any format that isn't a plain src tag. Add key: 'somelabel' so it has a stable name in the audit (there's no URL to derive one from).

cap (any network): { hour?, day? } — skip loading this network once the client-side audit reaches that many pops in the rolling hour/day. Set it to match the network's own dashboard frequency cap. Omit for no skip.

Self-host edgekit.min.js for production. Serve with a short Cache-Control so config edits apply without touching your site.

How it works

State persisted across navigation (localStorage):
  _ek6 = rotation cursor   _ek4:<net> = per-link click counter   _eks = per-network audit {h,d}

Each page-load:
  pick = first network from _ek6 onward whose cap isn't reached (skip capped ones); _ek6 = pick+1
         (all capped → this page loads no ad)
  tag/html → wrap window.open (sync, to count) → inject at IDLE / first interaction (perf, no LCP hit).
             {tag}=src(+data-*), {html}=verbatim snippet. Engine pops on click, self-throttles.
             The window.open wrapper only COUNTS fires (audit) — it never blocks.
  url → on a real gesture (isTrusted): first pop of the hour fires immediately (peak eCPM),
        then every `unlockClicks` clicks (per-link counter):
          window.open(clicked-href) → new tab (content, foreground)
          location.replace(adUrl)   → old tab (ad)              ← tab-under

Inspect the per-network audit any time:

JSON.parse(localStorage._eks)
// { c: { "network-a.com": {h:1,hT:…,d:5,dT:…}, "popads": {h:0,…,d:2,…} } }

h / d = real pops that network fired in the rolling hour / day — this is what cap checks against, and it lets you spot a network firing far more or less than its dashboard says.

The one hard rule: never block a tag's window.open

WebKit allows only one window.open per click, so every popunder engine has a fallback: if window.open is denied, it redirects the current tab to the ad (if (win) {...} else { location.href = url }). If edgekit returned null to a running tag to enforce some limit, the engine would read that as "blocked" (indistinguishable from an adblocker) and fire that fallback — hijacking the current tab and destroying the user's navigation. So the wrapper only ever counts; it never blocks, and it never delays injecting a tag either. Any limiting you want on a tag belongs in that network's own dashboard. (Self-fired url networks are different: we call window.open ourselves, so gating them with unlockClicks is completely safe — there's no engine to trigger a fallback on.)

Config reference

key default meaning
networks [] ordered rotation list. Each entry is one of: {tag:'//src', data?} (JS tag), {html:'…', key?} (verbatim snippet), or {url:'https://…', mode?} (self-fired link). Any entry may add cap:{hour?,day?}. Repeat an entry to give it a bigger share.
unlockClicks 2 clicks needed to fire one self-fired-url pop (tag networks are ungated — their engine decides)

Notes / limits

  • Mobile background tabs run load-time JS then freeze — the ad's impression pixel fires on load, so it counts. Verified against a live network.
  • iOS PWA (standalone) / iframe: can't safely swap the top window → self-fired ad opens in a separate tab regardless of mode.
  • Adblock blocks known network domains (EasyList) regardless of loader — self-hosting + shortlink urls recover some. Nothing recovers a blocked domain fully.
  • JS-tag format matters: a tag set to a redirect format (not popunder) navigates the current tab. Use popunder/tab-under zones for reading sites.
  • SEO: self-fire triggers only on real gestures (isTrusted) → bots see clean content. Never touches the back button (avoids Google's 2026 back-button-hijack spam policy).
  • Tag-fire attribution is by "which network is injected on this page", not by inspecting the call stack — stacks are often redacted/empty for cross-origin scripts (notably iOS Safari).
  • Popunder is inherently gray. Measure returning-user rate alongside RPM. Don't run it where you can't afford the retention hit.

License

MIT. No warranty.

(()=>{var _={networks:[],unlockClicks:2};function M(t){let e=+t;return isFinite(e)?e:void 0}function U(t){let e={};if(!t||!t.dataset)return e;let n=M(t.dataset.unlockClicks);return n!==void 0&&(e.unlockClicks=n),e}function F(){let t={},e=window.EdgeKitConfig;return e&&typeof e=="object"&&(Array.isArray(e.networks)&&(t.networks=e.networks),e.unlockClicks!=null&&(t.unlockClicks=e.unlockClicks)),t}function y(t){return Object.assign({},_,U(t),F())}var l=typeof window.open=="function"?window.open.bind(window):null,Y=(()=>{try{return Function.prototype.toString.call(window.open)}catch(t){return"function open() { [native code] }"}})();function k(t){if(l)try{let e=function(){try{t&&t()}catch(n){}return l.apply(null,arguments)};e.toString=()=>Y;try{Object.defineProperty(e,"name",{value:"open",configurable:!0})}catch(n){}try{Object.defineProperty(e,"length",{value:3,configurable:!0})}catch(n){}window.open=e}catch(e){}}function f(t){if(!l)return null;try{return l(t,"_blank")}catch(e){return null}}function v(t,e){if(!t)return;let n=document.createElement("script");if(n.settings={},e)for(let o in e)try{n.dataset[o]=e[o]}catch(r){}n.src=t,n.async=!0,n.referrerPolicy="no-referrer-when-downgrade",(document.body||document.documentElement).appendChild(n)}function E(t){if(!t)return;let e=document.createElement("div");e.innerHTML=t;let n=document.body||document.documentElement;Array.prototype.slice.call(e.childNodes).forEach(o=>{if(o.tagName==="SCRIPT"){let r=document.createElement("script");Array.prototype.slice.call(o.attributes).forEach(c=>{try{r.setAttribute(c.name,c.value)}catch(i){}}),r.text=o.textContent||"",n.appendChild(r)}else n.appendChild(o)})}function T(t){let e=0,n=null;document.addEventListener("pointerdown",r=>{if(r.pointerType!=="mouse"||r.button!==0)return;let c=r.target&&r.target.closest("a[href]");c&&(n=c.href)},!0),document.addEventListener("touchstart",r=>{let c=r.target&&r.target.closest("a[href]");c&&(n=c.href)},!0);function o(){let r=u=>{u.preventDefault(),u.stopPropagation(),c()},c=()=>{document.removeEventListener("click",r,!0),clearTimeout(i)};document.addEventListener("click",r,!0);let i=setTimeout(c,800)}document.addEventListener("pointerdown",r=>{if(r.pointerType!=="mouse"||r.button!==0)return;let c=r.target&&r.target.closest("a[href]");if(!c)return;let i=t(r,c,c.href);e=Date.now(),i&&o()},!0),document.addEventListener("click",r=>{if(Date.now()-e<800)return;let c=r.target&&r.target.closest("a[href]");c&&t(r,c,n||c.href)&&(r.preventDefault(),r.stopPropagation())},!0)}var x=window!==window.top,C=window.matchMedia&&window.matchMedia("(display-mode: standalone)").matches||window.navigator.standalone===!0;function b(t){return!(t&&t.isTrusted===!1||navigator.userActivation&&navigator.userActivation.isActive===!1)}function O(t){let e={};t.forEach(n=>{let o;try{o=new URL(n,location.href).origin}catch(r){return}e[o]||(e[o]=1,["dns-prefetch","preconnect"].forEach(r=>{let c=document.createElement("link");c.rel=r,c.href=o,r==="preconnect"&&(c.crossOrigin=""),document.head.appendChild(c)}))})}function L(t,e,n,o){if(o==="popup"||C||x){let c=f(t);return c?(q(c,n),o==="popup"?"popup":"external"):!1}let r=f(e);if(!r)return!1;try{r.opener=null}catch(c){}try{window.location.replace(t)}catch(c){}return"swap"}function q(t,e){if(!e||!t||!("closed"in t))return;let n=0,o=setInterval(()=>{try{if(t.closed){clearInterval(o),e();return}}catch(r){clearInterval(o);return}++n>=12&&clearInterval(o)},100)}function S(t){try{return localStorage.getItem(t)}catch(e){return null}}function a(t,e){try{localStorage.setItem(t,String(e))}catch(n){}}function d(t){let e=+S(t);return isFinite(e)?e:0}function p(){let t;try{t=JSON.parse(S("_eks")||"null")}catch(e){t=null}return(!t||!t.c)&&(t={c:{}}),t}function A(t){try{localStorage.setItem("_eks",JSON.stringify(t))}catch(e){}}function D(t,e){let n=t.c[e]||{};return{h:+n.h||0,hT:+n.hT||0,d:+n.d||0,dT:+n.dT||0}}function h(t){let e=D(p(),t),n=Date.now();return{h:n-e.hT>36e5?0:e.h,d:n-e.dT>864e5?0:e.d}}function I(t){let e=p(),n=D(e,t),o=Date.now();o-n.hT>36e5&&(n.h=0,n.hT=o),n.h++,o-n.dT>864e5&&(n.d=0,n.dT=o),n.d++,e.c[t]=n,A(e)}function R(t){let e=p(),n=e.c[t];n&&(n.h>0&&n.h--,n.d>0&&n.d--,A(e))}var G=document.currentScript,m=y(G),s=m.networks||[],j=t=>t.startsWith("//")||t.startsWith("http")?t:"//"+t,H=t=>{if(t.key)return t.key;let e=t.tag||t.url;if(!e)return"inline";let n=e.indexOf("//")===0?"https:"+e:/^https?:/.test(e)?e:"https://"+e;try{return new URL(n).hostname}catch(o){return"inline"}};function J(t){let e=!1,n=["pointerdown","touchstart","keydown"],o=()=>r(),r=()=>{if(!e){e=!0,n.forEach(c=>removeEventListener(c,o,!0));try{t()}catch(c){}}};typeof requestIdleCallback=="function"?requestIdleCallback(r,{timeout:1500}):setTimeout(r,800),n.forEach(c=>addEventListener(c,o,{capture:!0,passive:!0}))}var W=t=>{if(!t.cap)return!0;let e=h(H(t));return!(t.cap.hour&&e.h>=t.cap.hour||t.cap.day&&e.d>=t.cap.day)};if(s.length){let t=d("_ek6"),e=-1;for(let n=0;n<s.length;n++){let o=(t+n)%s.length;if(W(s[o])){e=o;break}}if(e!==-1){a("_ek6",e+1);let n=s[e],o=H(n),r=n.tag||n.url;r&&O([j(r)]);let c=()=>I(o);if((n.tag||n.html)&&(k(c),J(()=>{n.html?E(n.html):v(j(n.tag),n.data)})),n.url){let i="_ek4:"+o;T((u,K,N)=>{if(!b(u))return!1;let P=h(o).h===0?1:m.unlockClicks,w=d(i)+1;if(a(i,w),w<P)return!1;let g=L(n.url,N||location.href,()=>{a(i,m.unlockClicks),R(o)},n.mode);return g?(a(i,0),c(),g==="swap"):!1})}}}})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment