Deploy a static store, then wrap it with an AI chatbot Worker β no code changes to your origin.
Deploy one Worker that auto-migrates images from your origin to R2 on first load. Zero egress fees. Zero origin changes.
Architecture
Architecture
Choose a scenario
How this demo binds Workers to your domain
Both Workers bind to a hostname on a Cloudflare-managed zone. This must exist before you deploy either Worker. Pick a subdomain you'll use for the demo, e.g. store.yourdomain.com.
store.yourdomain.com) β you'll use it in Step 1 (Custom Domain for Worker A) and Step 2 (Route for Worker B). .workers.dev? Routes only fire on Cloudflare-managed zones. Worker B injects the chatbot via a Route; without a Custom Hostname, Worker B never intercepts traffic and the demo will not work.
Worker A serves the static HTML store. Bind it as a Custom Domain so it becomes the origin for store.yourdomain.com.
my-demo-store β click Deploy store.yourdomain.com β Add Custom Domain. Cloudflare creates the DNS record and SSL cert (~30 s). https://store.yourdomain.com β you should see the store page. // worker-a.js β Single-file static store (HTML embedded)
// β
Paste this ENTIRE file into the Worker editor β no other files needed
// β
Deploy Worker A β then deploy Worker B on the same domain with a route
const STORE_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HΓMSTYLE β Scandinavian Furniture</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f5f5f5;color:#111}
.topbar{background:#111;color:#fff;text-align:center;padding:8px;font-size:12px}
.topbar span{color:#FFDA1A;font-weight:700}
header{background:#0058A3;position:sticky;top:0;z-index:100}
.hdr{max-width:1200px;margin:0 auto;padding:0 24px;height:68px;
display:flex;align-items:center;gap:20px}
.logo{font-size:24px;font-weight:900;color:#fff}
.logo span{color:#FFDA1A}
.search{flex:1;display:flex;max-width:480px}
.search input{flex:1;border:none;padding:10px 14px;font-size:14px;
border-radius:4px 0 0 4px;outline:none}
.search button{background:#FFDA1A;border:none;padding:0 14px;
border-radius:0 4px 4px 0;cursor:pointer}
.hdr-right{margin-left:auto;display:flex;gap:16px}
.hdr-link{color:#b3d1f0;font-size:12px;text-decoration:none;
text-align:center;cursor:pointer}
.hdr-link .i{font-size:18px;display:block}
.catnav{background:#003e7e}
.catnav-inner{max-width:1200px;margin:0 auto;display:flex;overflow-x:auto}
.catnav a{color:#b3d1f0;text-decoration:none;padding:10px 16px;
font-size:13px;white-space:nowrap}
.catnav a:hover,.catnav a.act{background:#0058A3;color:#fff}
.hero{background:#FFDA1A;padding:0}
.hero-inner{max-width:1200px;margin:0 auto;padding:48px 40px;
display:flex;align-items:center;justify-content:space-between;gap:24px}
.hero h1{font-size:42px;font-weight:900;color:#111;line-height:1.05;margin-bottom:14px}
.hero p{font-size:15px;color:#333;margin-bottom:24px;max-width:420px}
.hero-tag{font-size:11px;font-weight:800;color:#0058A3;
text-transform:uppercase;letter-spacing:1.5px;margin-bottom:10px}
.btn-p{background:#0058A3;color:#fff;border:none;padding:14px 28px;
font-size:15px;font-weight:800;border-radius:4px;cursor:pointer}
.btn-s{background:transparent;color:#0058A3;border:2px solid #0058A3;
padding:13px 28px;font-size:15px;font-weight:800;border-radius:4px;cursor:pointer}
.hero-vis{display:flex;flex-wrap:wrap;gap:10px;width:240px}
.hero-vis div{background:rgba(255,255,255,.6);border-radius:8px;
width:105px;height:105px;display:flex;align-items:center;
justify-content:center;font-size:50px}
.promo{max-width:1200px;margin:24px auto;padding:0 24px;
display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.promo-c{border-radius:8px;padding:18px;display:flex;align-items:center;gap:14px}
.promo-c h3{font-size:13px;font-weight:800;margin-bottom:3px}
.promo-c p{font-size:12px;color:#555;line-height:1.4}
.section{max-width:1200px;margin:0 auto;padding:28px 24px}
.sec-h{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
.sec-h h2{font-size:24px;font-weight:900}
.sec-h a{color:#0058A3;font-size:13px;font-weight:700;text-decoration:none}
.rooms{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.room{background:#fff;border:1px solid #e5e7eb;border-radius:8px;
overflow:hidden;cursor:pointer;text-align:center;transition:transform .15s}
.room:hover{transform:translateY(-2px);border-color:#0058A3}
.room-img{background:#f0f4f8;height:100px;display:flex;
align-items:center;justify-content:center;font-size:48px}
.room-name{font-size:12px;font-weight:800;padding:8px 6px 10px}
.products{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px}
.card{background:#fff;border:1px solid #e5e7eb;border-radius:6px;
overflow:hidden;cursor:pointer;position:relative;transition:box-shadow .15s}
.card:hover{box-shadow:0 4px 20px rgba(0,88,163,.15);border-color:#0058A3}
.card-img{aspect-ratio:1;background:#f5f5f5;display:flex;
align-items:center;justify-content:center;font-size:76px;position:relative}
.badge{position:absolute;top:10px;left:10px;background:#FFDA1A;color:#111;
font-size:10px;font-weight:900;padding:2px 8px;border-radius:3px}
.badge.sale{background:#cc0000;color:#fff}
.card-body{padding:14px}
.card-cat{font-size:11px;color:#767676;text-transform:uppercase;
letter-spacing:.5px;margin-bottom:3px}
.card-name{font-size:15px;font-weight:900;color:#111;margin-bottom:3px}
.card-desc{font-size:12px;color:#767676;margin-bottom:8px;line-height:1.4}
.stars{color:#FFDA1A;font-size:12px}
.rating{font-size:11px;color:#767676;margin-bottom:10px}
.card-ft{display:flex;align-items:center;justify-content:space-between}
.price{font-size:20px;font-weight:900;color:#0058A3}
.was{font-size:11px;color:#cc0000;font-weight:700}
.add{background:#0058A3;color:#fff;border:none;padding:9px 16px;
font-size:12px;font-weight:800;border-radius:4px;cursor:pointer}
.inspire{background:linear-gradient(135deg,#003e7e,#0058A3);
border-radius:12px;padding:44px 40px;color:#fff;
display:flex;align-items:center;justify-content:space-between;gap:20px}
.inspire h2{font-size:26px;font-weight:900;margin-bottom:10px}
.inspire p{font-size:14px;color:rgba(255,255,255,.8);
max-width:360px;line-height:1.6;margin-bottom:18px}
.ir{display:flex;gap:10px;flex-wrap:wrap}
.ir div{background:rgba(255,255,255,.15);border-radius:6px;
padding:10px 16px;font-size:13px;font-weight:700;cursor:pointer}
.reviews{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}
.rv{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:18px}
.rv-h{display:flex;justify-content:space-between;margin-bottom:10px}
.rv-name{font-weight:700;font-size:13px}
.rv-prod{font-size:11px;color:#767676;margin-bottom:6px}
.rv-text{font-size:12px;color:#333;line-height:1.6}
.support{background:#fff8e1;border:1px solid #fde68a;border-radius:8px;
padding:18px 22px;margin:0 24px 24px;font-size:13px;
color:#92400e;display:flex;align-items:center;gap:14px}
footer{background:#111;color:#aaa;padding:36px 24px}
.fi{max-width:1200px;margin:0 auto;
display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:28px}
footer h4{color:#fff;font-size:12px;font-weight:800;margin-bottom:10px;
text-transform:uppercase;letter-spacing:.5px}
footer a{display:block;color:#888;text-decoration:none;font-size:12px;margin-bottom:5px}
footer a:hover{color:#fff}
.fb{max-width:1200px;margin:20px auto 0;padding-top:20px;
border-top:1px solid #333;font-size:11px;color:#555;text-align:center}
@media(max-width:768px){
.hero-inner{flex-direction:column;padding:28px 20px}
.hero h1{font-size:28px}
.hero-vis,.ir{display:none}
.promo{grid-template-columns:1fr}
.inspire{flex-direction:column}
}
</style>
</head>
<body>
<div class="topbar">π Free delivery over ΰΈΏ2,500 Β· <span>SAVE15</span> β 15% off first order</div>
<header>
<div class="hdr">
<div class="logo">HΓM<span>STYLE</span></div>
<div class="search">
<input placeholder="Search products, rooms, brandsβ¦">
<button>π</button>
</div>
<div class="hdr-right">
<a class="hdr-link"><span class="i">π€</span>Wishlist</a>
<a class="hdr-link"><span class="i">π€</span>Account</a>
<a class="hdr-link"><span class="i">π</span>Cart</a>
</div>
</div>
</header>
<div class="catnav">
<div class="catnav-inner">
<a href="#" class="act">All Products</a>
<a href="#">Sofas & Armchairs</a>
<a href="#">Bedroom</a>
<a href="#">Storage</a>
<a href="#">Desks & Office</a>
<a href="#">Dining</a>
<a href="#">Lighting</a>
<a href="#">Living Room</a>
<a href="#" style="color:#FFDA1A;font-weight:700">π· Sale</a>
</div>
</div>
<div class="hero">
<div class="hero-inner">
<div>
<div class="hero-tag">Summer 2026 Collection</div>
<h1>Designed for<br>the way you live.</h1>
<p>Scandinavian-inspired furniture β functional, lasting, and affordable. Free delivery over ΰΈΏ2,500.</p>
<div style="display:flex;gap:12px;flex-wrap:wrap">
<button class="btn-p">Shop All Products</button>
<button class="btn-s">Room Inspiration</button>
</div>
</div>
<div class="hero-vis">
<div>πͺ</div><div>ποΈ</div><div>π</div><div>π₯οΈ</div>
</div>
</div>
</div>
<div class="promo">
<div class="promo-c" style="background:#eff6ff"><div style="font-size:32px">π</div><div><h3>Free Delivery</h3><p>On orders over ΰΈΏ2,500. Bangkok & metro area.</p></div></div>
<div class="promo-c" style="background:#f0fdf4"><div style="font-size:32px">π</div><div><h3>365-Day Returns</h3><p>Return any product within a year, hassle-free.</p></div></div>
<div class="promo-c" style="background:#fff7ed"><div style="font-size:32px">π </div><div><h3>Assembly Service</h3><p>Professional home assembly from ΰΈΏ299.</p></div></div>
</div>
<div class="section">
<div class="sec-h"><h2>Shop by Room</h2><a href="#">View all β</a></div>
<div class="rooms">
<div class="room"><div class="room-img">ποΈ</div><div class="room-name">Living Room</div></div>
<div class="room"><div class="room-img">ποΈ</div><div class="room-name">Bedroom</div></div>
<div class="room"><div class="room-img">π₯οΈ</div><div class="room-name">Home Office</div></div>
<div class="room"><div class="room-img">π½οΈ</div><div class="room-name">Dining Room</div></div>
<div class="room"><div class="room-img">π¦</div><div class="room-name">Storage</div></div>
<div class="room"><div class="room-img">π‘</div><div class="room-name">Lighting</div></div>
</div>
</div>
<div class="section" style="padding-top:0">
<div class="sec-h"><h2>Bestsellers This Month</h2><a href="#">See all β</a></div>
<div class="products">
<div class="card"><div class="card-img">πͺ<span class="badge">Best Seller</span></div><div class="card-body"><div class="card-cat">Sofas & Armchairs</div><div class="card-name">POΓNG Armchair</div><div class="card-desc">Birch veneer, cushion included. Layer upon layer of comfort.</div><div class="rating"><span class="stars">β
β
β
β
β
</span> 4.7 (18,432 reviews)</div><div class="card-ft"><div class="price">ΰΈΏ6,990</div><button class="add">Add to Cart</button></div></div></div>
<div class="card"><div class="card-img">π</div><div class="card-body"><div class="card-cat">Storage & Shelving</div><div class="card-name">BILLY Bookcase</div><div class="card-desc">Most versatile bookcase. Adjustable shelves, easy assembly.</div><div class="rating"><span class="stars">β
β
β
β
β
</span> 4.8 (42,100 reviews)</div><div class="card-ft"><div class="price">ΰΈΏ3,490</div><button class="add">Add to Cart</button></div></div></div>
<div class="card"><div class="card-img">ποΈ</div><div class="card-body"><div class="card-cat">Sofas & Armchairs</div><div class="card-name">KIVIK 3-seat Sofa</div><div class="card-desc">Deep, generous seating. Washable covers in 10 colours.</div><div class="rating"><span class="stars">β
β
β
β
β</span> 4.4 (9,876 reviews)</div><div class="card-ft"><div class="price">ΰΈΏ31,490</div><button class="add">Add to Cart</button></div></div></div>
<div class="card"><div class="card-img">π₯οΈ<span class="badge">New</span></div><div class="card-body"><div class="card-cat">Desks & Office</div><div class="card-name">BEKANT Sit/Stand Desk</div><div class="card-desc">Electric height adjustment. Silent motor, memory positions.</div><div class="rating"><span class="stars">β
β
β
β
β</span> 4.5 (7,203 reviews)</div><div class="card-ft"><div class="price">ΰΈΏ14,990</div><button class="add">Add to Cart</button></div></div></div>
<div class="card"><div class="card-img">π¦</div><div class="card-body"><div class="card-cat">Storage & Shelving</div><div class="card-name">KALLAX Shelf Unit</div><div class="card-desc">Cubes of possibility. Mix open and closed storage.</div><div class="rating"><span class="stars">β
β
β
β
β
</span> 4.9 (31,200 reviews)</div><div class="card-ft"><div class="price">ΰΈΏ2,990</div><button class="add">Add to Cart</button></div></div></div>
<div class="card"><div class="card-img">πΊ</div><div class="card-body"><div class="card-cat">Desks & Office</div><div class="card-name">MARKUS Office Chair</div><div class="card-desc">Ergonomic mesh back, lumbar support. 8-hour comfort.</div><div class="rating"><span class="stars">β
β
β
β
β</span> 4.5 (5,432 reviews)</div><div class="card-ft"><div class="price">ΰΈΏ11,990</div><button class="add">Add to Cart</button></div></div></div>
<div class="card"><div class="card-img">πͺ</div><div class="card-body"><div class="card-cat">Sofas & Armchairs</div><div class="card-name">STRANDMON Wing Chair</div><div class="card-desc">High back for excellent neck support. Great for reading.</div><div class="rating"><span class="stars">β
β
β
β
β
</span> 4.6 (11,203 reviews)</div><div class="card-ft"><div class="price">ΰΈΏ10,490</div><button class="add">Add to Cart</button></div></div></div>
<div class="card"><div class="card-img">ποΈ<span class="badge sale">Sale</span></div><div class="card-body"><div class="card-cat">Bedroom</div><div class="card-name">HEMNES Bed Frame</div><div class="card-desc">Solid pine, traditional style. Works with all IKEA mattresses.</div><div class="rating"><span class="stars">β
β
β
β
β</span> 4.3 (8,921 reviews)</div><div class="card-ft"><div><div class="price">ΰΈΏ10,990</div><div class="was">Was ΰΈΏ13,490</div></div><button class="add">Add to Cart</button></div></div></div>
</div>
</div>
<div class="section" style="padding-top:0">
<div class="inspire">
<div>
<h2>Create your perfect space</h2>
<p>Browse room inspiration guides β mix and match furniture to build the home you've always imagined. Free interior design consultation available.</p>
<button class="btn-p">Get Inspired</button>
</div>
<div class="ir">
<div>ποΈ Living Room</div><div>ποΈ Bedroom</div>
<div>π₯οΈ Home Office</div><div>π½οΈ Dining</div>
</div>
</div>
</div>
<div class="section" style="padding-top:0">
<div class="sec-h"><h2>What Customers Say</h2><a href="#">All reviews β</a></div>
<div class="reviews">
<div class="rv"><div class="rv-h"><div><div class="rv-name">Natchaya K. <span class="stars">β
β
β
β
β
</span></div><div class="rv-prod">POΓNG Armchair Β· Feb 2024</div></div><div style="color:#22c55e;font-size:11px;font-weight:700">β Verified</div></div><div class="rv-text">"I've had my POΓNG for 12 years and it still looks perfect. Just bought a second one. Unbeatable quality for the price."</div></div>
<div class="rv"><div class="rv-h"><div><div class="rv-name">Tanawat P. <span class="stars">β
β
β
β
β</span></div><div class="rv-prod">KALLAX Shelf Unit Β· Jan 2024</div></div><div style="color:#22c55e;font-size:11px;font-weight:700">β Verified</div></div><div class="rv-text">"I have 8 KALLAX units across my apartment in different configurations. Added inserts to half for closed storage. Incredibly versatile."</div></div>
<div class="rv"><div class="rv-h"><div><div class="rv-name">Siriporn T. <span class="stars">β
β
β
β
β
</span></div><div class="rv-prod">MARKUS Chair Β· Feb 2024</div></div><div style="color:#22c55e;font-size:11px;font-weight:700">β Verified</div></div><div class="rv-text">"MARKUS is genuinely comfortable for 8-hour work days. The lumbar support is excellent. Assembly took an hour but worth it."</div></div>
</div>
</div>
<div class="support">
<div style="font-size:28px">β</div>
<div>
<strong>Have a question?</strong> Email <strong>support@homstyle.com</strong> Β·
<span style="color:#c0392b;font-weight:700">β οΈ Response time: 3β5 business days</span> Β·
Phone: 02-123-4567 (MonβFri 9amβ6pm)
<!-- NO AI CHATBOT β deploy Worker B to add instant AI support -->
</div>
</div>
<footer>
<div class="fi">
<div><div style="font-size:20px;font-weight:900;color:#fff;margin-bottom:8px">HΓM<span style="color:#FFDA1A">STYLE</span></div><p style="font-size:12px;line-height:1.6">Scandinavian-inspired furniture for modern living.</p></div>
<div><h4>Products</h4><a href="#">Sofas & Armchairs</a><a href="#">Bedroom</a><a href="#">Storage</a><a href="#">Office</a><a href="#">Dining</a></div>
<div><h4>Service</h4><a href="#">Order Status</a><a href="#">Returns</a><a href="#">Assembly</a><a href="#">Store Locator</a></div>
<div><h4>About</h4><a href="#">Our Story</a><a href="#">Sustainability</a><a href="#">Careers</a></div>
</div>
<div class="fb">© 2026 HΓMSTYLE Co., Ltd. Β· Demo site for Cloudflare Workers workshops.</div>
</footer>
</body>
</html>`
// Request handler
export default {
async fetch(request) {
const url = new URL(request.url)
if (!url.pathname.includes('.') || url.pathname.endsWith('.html')) {
return new Response(STORE_HTML, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=300',
}
})
}
return new Response('Not found', { status: 404, headers: { 'Content-Type': 'text/plain' } })
}
}
// What this demonstrates:
// β
Worker serves a full IKEA-style store globally (300+ Cloudflare PoPs)
// β
No origin server needed β the Worker IS the origin
// β
Zero cold starts, <1ms boot time
// β
Free: 100,000 requests/day on the free tier
// β
Worker B wraps this Worker via a route β no origin changes needed Worker B wraps Worker A and injects the AI chatbot. Bind it as a Route on the same hostname β Cloudflare runs Worker B before requests reach Worker A.
X-Worker-Bypass to avoid fetch loops, and same-zone fetch() from Route β Custom Domain works without a service binding. my-demo-chatbot β Deploy store.yourdomain.com/* β Zone: yourdomain.com β Add Route https://store.yourdomain.com β Worker B intercepts, fetches Worker A, injects the chatbot widget. π /**
* Worker B: AI chatbot
*
* How it reads products:
* 1. Fetches HTML from Worker A
* 2. Parses product names + prices from the HTML response (server-side)
* 3. Injects window.__cfProducts = "..." into the page as a JS variable
* 4. Also injects the chat widget into <body>
* 5. /api/chat receives window.__cfProducts from the browser and uses it
* to build the system prompt dynamically β no hardcoding needed
*
* Change Worker A's products β Worker B reads the new list on the next request.
*
* β οΈ Required: Add Workers AI binding in dashboard BEFORE deploying
* Settings β Variables β AI Bindings β Add binding β name: AI
*/
// Worker code is hostname-agnostic β bind it to your Cloudflare zone via
// wrangler.toml [[routes]] block (or the dashboard Settings β Routes).
// Loop prevention: Worker B adds X-Worker-Bypass: 1 when fetching origin.
// On re-entry the Worker sees that header and passes straight through,
// so Cloudflare routes to the real Worker A without re-triggering this code.
// ββ Chat widget HTML βββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Rules: no backticks, no single-quotes inside single-quoted strings,
// all event handlers via addEventListener in the <script> block
const CHAT_WIDGET = [
'<style>',
'#cf-btn{position:fixed;bottom:24px;right:24px;width:54px;height:54px;',
'border-radius:50%;background:#0058A3;color:#fff;border:none;font-size:22px;',
'cursor:pointer;box-shadow:0 4px 20px rgba(0,88,163,.5);z-index:9999}',
'#cf-win{display:none;position:fixed;bottom:88px;right:24px;width:320px;',
'height:420px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;',
'box-shadow:0 8px 40px rgba(0,0,0,.15);z-index:9999;flex-direction:column;overflow:hidden}',
'#cf-win.open{display:flex}',
'#cf-hd{background:#0058A3;color:#fff;padding:14px 16px;font-weight:800;font-size:14px;display:flex;align-items:center;gap:8px}',
'#cf-dot{width:8px;height:8px;border-radius:50%;background:#FFDA1A;animation:cfp 2s infinite}',
'@keyframes cfp{0%,100%{opacity:1}50%{opacity:.4}}',
'#cf-ms{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}',
'.m{max-width:85%;padding:8px 12px;border-radius:10px;font-size:13px;line-height:1.5}',
'.m.bot{background:#eff6ff;color:#1e3a5f;align-self:flex-start}',
'.m.usr{background:#0058A3;color:#fff;align-self:flex-end}',
'#cf-row{padding:10px;border-top:1px solid #f0f0f0;display:flex;gap:8px}',
'#cf-in{flex:1;border:1px solid #d1d5db;border-radius:8px;padding:8px 12px;font-size:13px;outline:none}',
'#cf-sb{background:#FFDA1A;color:#111;border:none;padding:8px 14px;border-radius:8px;font-weight:800;cursor:pointer}',
'</style>',
// HTML β no onclick attributes (all events wired in <script> block below)
'<button id="cf-btn">💬</button>',
'<div id="cf-win">',
'<div id="cf-hd"><div id="cf-dot"></div>Store AI ⚡ Workers AI</div>',
'<div id="cf-ms"><div class="m bot">Hi! Ask me anything about our products.</div></div>',
'<div id="cf-row">',
'<input id="cf-in" placeholder="Ask about any product...">',
'<button id="cf-sb">→</button>',
'</div></div>',
// Script β addEventListener instead of onclick so no quote escaping needed
'<script>',
'(function(){',
' var btn=document.getElementById("cf-btn");',
' var win=document.getElementById("cf-win");',
' var inp=document.getElementById("cf-in");',
' var sb =document.getElementById("cf-sb");',
' var ms =document.getElementById("cf-ms");',
' btn.addEventListener("click",function(){win.classList.toggle("open");});',
' sb.addEventListener("click",cfS);',
' inp.addEventListener("keydown",function(e){if(e.key==="Enter")cfS();});',
' function add(s,r){',
' var d=document.createElement("div");',
' d.className="m "+r; d.textContent=s;',
' ms.appendChild(d); ms.scrollTop=ms.scrollHeight; return d;',
' }',
// window.__cfProducts is injected server-side by Worker B before this script runs
' async function cfS(){',
' var t=inp.value.trim(); if(!t)return; inp.value="";',
' add(t,"usr"); var p=add("...","bot");',
' try{',
' var res=await fetch("/api/chat",{',
' method:"POST",',
' headers:{"Content-Type":"application/json"},',
' body:JSON.stringify({message:t,products:window.__cfProducts||""})});',
' var d=await res.json();',
' p.textContent=d.response||(d.error||"Try again.");',
' }catch(e){p.textContent="Connection error.";}',
' ms.scrollTop=ms.scrollHeight;',
' }',
'})()',
'</script>'
].join("")
// ββ Server-side product extraction from HTML response βββββββββββββββββββββ
// Worker B reads the HTML from Worker A and extracts product data
// before serving it to the browser. No DOM scraping needed.
function extractProducts(html) {
var names = []
var prices = []
// Extract all .card-name text values
var nm = html.match(/class="card-name"[^>]*>([^<]+)</g) || []
nm.forEach(function(s){ var v = s.replace(/^[^>]+>/, '').replace(/<$/, '').trim(); if(v) names.push(v) })
// Extract all .price / .card-price text values
var pr = html.match(/class="(?:price|card-price)"[^>]*>([^<]+)</g) || []
pr.forEach(function(s){ var v = s.replace(/^[^>]+>/, '').replace(/<$/, '').trim(); if(v) prices.push(v) })
var results = []
var len = Math.min(names.length, prices.length)
for (var i = 0; i < len; i++) {
results.push(names[i] + ' (' + prices[i] + ')')
}
return results.join('; ')
}
export default {
async fetch(request, env) {
const url = new URL(request.url)
// ββ Bypass pass-through βββββββββββββββββββββββββββββββββββββββββββββββββ
// Worker B fetched origin with this header to avoid an infinite loop.
// Pass straight through β Cloudflare routes to Worker A.
if (request.headers.get('X-Worker-Bypass') === '1') {
return fetch(request)
}
if (request.method === 'OPTIONS') {
return new Response(null, { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'Content-Type' } })
}
// ββ POST /api/chat ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (url.pathname === '/api/chat' && request.method === 'POST') {
try {
if (!env.AI) {
return Response.json(
{ response: 'Workers AI binding missing. Go to dashboard: Worker Settings β Variables β AI Bindings β Add β name: AI', error: 'no_ai_binding' },
{ status: 503, headers: { 'Access-Control-Allow-Origin': '*' } }
)
}
const { message, products } = await request.json()
// System prompt built from products extracted by Worker B server-side
const systemPrompt = products
? 'You are a helpful store assistant. The store sells: ' + products + '. Answer in 2-3 sentences. Be friendly.'
: 'You are a helpful store assistant. Answer in 2-3 sentences.'
const result = await env.AI.run('@cf/zai-org/glm-4.7-flash', {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: message }
],
max_tokens: 150
})
return Response.json(
{ response: result.response },
{ headers: { 'Access-Control-Allow-Origin': '*' } }
)
} catch (err) {
return Response.json(
{ response: 'Error: ' + err.message, error: err.message },
{ status: 500, headers: { 'Access-Control-Allow-Origin': '*' } }
)
}
}
// ββ Fetch HTML from origin using bypass header (no infinite loop) βββββββ
const originReq = new Request(request, {
headers: { ...Object.fromEntries(request.headers), 'X-Worker-Bypass': '1' },
})
const res = await fetch(originReq)
if (!res.headers.get('Content-Type')?.includes('text/html')) return res
// Read HTML as text so we can:
// 1. Extract product data server-side (no DOM scraping in browser)
// 2. Inject window.__cfProducts = "..." into the page
// 3. Inject the chat widget
const html = await res.text()
const products = extractProducts(html)
// Inject product data as a JS variable + chat widget before </body>
const injection =
'<script>window.__cfProducts = "' + products.replace(/"/g, '\"') + '";</script>' +
CHAT_WIDGET
const modified = html.replace('</body>', injection + '</body>')
return new Response(modified, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
})
}
} AI β Save. Then redeploy. Free tier: 10,000 neurons/day.
https://store.yourdomain.com β Worker B intercepts, Worker A renders the store, and the chat widget is injected. Ask "What's a good sofa under ΰΈΏ20,000?" β Llama 3.1 8B Fast answers instantly.
How the R2 cache binds to your domain
images.yourdomain.com). This is your origin.*images.yourdomain.com/images/*) so it intercepts only image requests; everything else passes through to Pages.Routes only fire on Cloudflare-managed zones. Pick a subdomain you'll use for the demo, e.g. images.yourdomain.com.
images.yourdomain.com). You'll attach Pages to it in Step 1, and bind the Worker Route to it in Step 2. Start here
Pure static HTML + 30 AI-generated images (~18 MB total, no build step). Upload it to Cloudflare Pages as your origin, then deploy the R2 Cache Worker in front β egress drops from $1,620/million loads to $0.
The ZIP contains only index.html + images/ β no wrangler.toml, no build step, no Node.js. Pages Direct Upload accepts it immediately.
Go to dash.cloudflare.com β left sidebar: Compute β Pages
Click Create application β Direct Upload
Name your project (e.g. my-origin-demo)
Click Select from computer β upload the r2-origin-demo.zip file
Click Deploy site β Cloudflare extracts the ZIP and publishes it immediately
Add your Custom Domain to Pages: project β Custom domains β Set up a custom domain β enter images.yourdomain.com β Activate. Pages auto-creates the proxied (orange-clouded) DNS record. The Worker Route in Step 2 needs this record to exist.
Visit https://images.yourdomain.com β verify the gallery loads.
.pages.dev URL works too, but the Worker Route in Step 2 must target your Custom Domain, so use images.yourdomain.com from this point forward.
One Worker intercepts image requests, checks R2, caches on miss. No URL to edit β it works on any hostname automatically.
Left sidebar: Compute β Workers & Pages β Create β Create Worker
Name it my-r2-cache β Deploy β Edit code
Replace all code with the Worker below. No code edits β Route binding (Step 6) decides which traffic this Worker sees.
Save and deploy
Go to Worker Settings β Bindings β Add β choose R2 Bucket Β· Variable: R2 Β· then left sidebar: Storage & databases β R2 β Create bucket β name it my-images β Save binding β Redeploy
Bind the Route on your Custom Hostname: Worker β Settings β Domains & Routes β Add β Route β Pattern: images.yourdomain.com/images/* β Zone: yourdomain.com β Add Route β Redeploy. Pages-managed proxied DNS from Step 1 makes this work; the Worker now sits in front of Pages for image paths only.
// R2 Image Cache Worker
// Hostname-agnostic β bind via Route in wrangler.toml or Dashboard Settings β Routes.
// The Route pattern (e.g. *yourdomain.com/images/*) decides which requests this
// Worker intercepts. Loop prevention: adds X-R2-Bypass: 1 when fetching origin
// so the Worker doesn't intercept its own internal request.
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Expose-Headers':
'X-Served-By, X-R2-Cached-At, X-R2-Size, X-R2-Origin, X-Cache',
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url)
// Internal bypass β skip R2 and pass straight to origin
if (request.headers.get('X-R2-Bypass') === '1') {
return fetch(request)
}
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: CORS })
}
// Only intercept image paths β pass everything else through unchanged
if (!url.pathname.startsWith('/images/')) return fetch(request)
const key = url.pathname.slice(1) // e.g. "images/golden-retriever.jpg"
// HEAD β read R2 metadata without downloading image bytes
if (request.method === 'HEAD') {
const meta = await env.R2.head(key)
if (meta) {
const m = meta.customMetadata ?? {}
return new Response(null, {
headers: {
...CORS,
'Content-Type': meta.httpMetadata?.contentType ?? 'image/jpeg',
'Content-Length': String(meta.size),
'X-Served-By': 'cloudflare-r2',
'X-R2-Cached-At': m['cached-at'] ?? '',
'X-R2-Size': m['size-bytes'] ?? String(meta.size),
'X-R2-Origin': m['origin-url'] ?? '',
},
})
}
return new Response(null, {
status: 200,
headers: { ...CORS, 'X-Cache': 'MISS', 'X-Served-By': 'origin' },
})
}
// GET β serve from R2 if cached
const cached = await env.R2.get(key)
if (cached) {
const m = cached.customMetadata ?? {}
const headers = new Headers(CORS)
cached.writeHttpMetadata(headers)
headers.set('Cache-Control', 'public, max-age=31536000')
headers.set('X-Served-By', 'cloudflare-r2')
headers.set('X-R2-Cached-At', m['cached-at'] ?? '')
headers.set('X-R2-Size', m['size-bytes'] ?? String(cached.size))
headers.set('X-R2-Origin', m['origin-url'] ?? '')
return new Response(cached.body, { headers })
}
// Cache miss β fetch from real origin using bypass header (no loop)
const originReq = new Request(request, {
headers: { ...Object.fromEntries(request.headers), 'X-R2-Bypass': '1' },
})
const originRes = await fetch(originReq)
if (!originRes.ok) {
const headers = new Headers(originRes.headers)
Object.entries(CORS).forEach(([k, v]) => headers.set(k, v))
return new Response(originRes.body, { status: originRes.status, headers })
}
const sizeBytes = originRes.headers.get('content-length') ?? '0'
// Store in R2 in background β user gets response immediately
ctx.waitUntil((async () => {
const buf = await originRes.clone().arrayBuffer()
await env.R2.put(key, buf, {
httpMetadata: {
contentType: originRes.headers.get('content-type') ?? 'image/jpeg',
cacheControl: 'public, max-age=31536000',
},
customMetadata: {
'cached-at': new Date().toISOString(), // when first cached
'size-bytes': sizeBytes, // original file size in bytes
'origin-url': url.toString(), // full URL it was fetched from
},
})
})())
const headers = new Headers(originRes.headers)
Object.entries(CORS).forEach(([k, v]) => headers.set(k, v))
headers.set('X-Cache', 'MISS-copying-to-r2')
headers.set('X-Served-By', 'origin')
return new Response(originRes.body, { status: originRes.status, headers })
},
} https://images.yourdomain.com β load any image. First request: fetched from Pages origin + copied to R2. Every request after: X-Served-By: cloudflare-r2, zero egress, served from the nearest Cloudflare PoP.
Under the hood
Under the hood
Go further
Every Cloudflare product demoed in context β HΓMSTYLE furniture store and STREAMVAULT streaming.