| 1 | import { useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | import { AWS_COLOR, CF_COLOR, tint } from "../marketing/diagrams/_colors"; |
| 3 | |
| 4 | const tok = |
| 5 | (color: string) => |
| 6 | ({ children }: { children: ReactNode }) => ( |
| 7 | <span style={{ color }}>{children}</span> |
| 8 | ); |
| 9 | const K = tok("var(--alc-code-keyword)"); |
| 10 | const S = tok("var(--alc-code-string)"); |
| 11 | const F = tok("var(--alc-code-fn)"); |
| 12 | const V = tok("var(--alc-code-var)"); |
| 13 | const T = tok("var(--alc-code-type)"); |
| 14 | const C = ({ children }: { children: ReactNode }) => ( |
| 15 | <span style={{ color: "var(--alc-code-comment)", fontStyle: "italic" }}> |
| 16 | {children} |
| 17 | </span> |
| 18 | ); |
| 19 | |
| 20 | type ResKind = "kv" | "ddb" | "d1"; |
| 21 | |
| 22 | interface Impl { |
| 23 | layer: string; // Layer name shown in code |
| 24 | resourceLabel: string; // node label |
| 25 | resourceSub: string; // node sub-label |
| 26 | bindCall: string; // bind call shown on the arrow |
| 27 | color: string; |
| 28 | kind: ResKind; |
| 29 | } |
| 30 | |
| 31 | const D1_COLOR = "#0080FF"; |
| 32 | |
| 33 | const IMPLS: Impl[] = [ |
| 34 | { |
| 35 | layer: "SessionsKV", |
| 36 | resourceLabel: "Sessions", |
| 37 | resourceSub: "Cloudflare.KV.Namespace", |
| 38 | bindCall: "KV.ReadWriteNamespace", |
| 39 | color: CF_COLOR, |
| 40 | kind: "kv", |
| 41 | }, |
| 42 | { |
| 43 | layer: "SessionsDynamoDB", |
| 44 | resourceLabel: "Sessions", |
| 45 | resourceSub: "AWS.DynamoDB.Table", |
| 46 | bindCall: "DynamoDB.PutItem", |
| 47 | color: AWS_COLOR, |
| 48 | kind: "ddb", |
| 49 | }, |
| 50 | { |
| 51 | layer: "SessionsD1", |
| 52 | resourceLabel: "Sessions", |
| 53 | resourceSub: "Cloudflare.D1.Database", |
| 54 | bindCall: "D1Database.bind", |
| 55 | color: D1_COLOR, |
| 56 | kind: "d1", |
| 57 | }, |
| 58 | ]; |
| 59 | |
| 60 | const DWELL_MS = 2800; |
| 61 | |
| 62 | function ResourceIcon({ kind, color }: { kind: ResKind; color: string }) { |
| 63 | const fill = tint(color, 0.18); |
| 64 | if (kind === "kv") { |
| 65 | return ( |
| 66 | <svg width="22" height="22" viewBox="0 0 32 32" fill="none" aria-hidden> |
| 67 | <circle |
| 68 | cx="11" |
| 69 | cy="16" |
| 70 | r="6" |
| 71 | fill={fill} |
| 72 | stroke={color} |
| 73 | strokeWidth="1.5" |
| 74 | /> |
| 75 | <circle cx="11" cy="16" r="2.2" fill={color} /> |
| 76 | <path |
| 77 | d="M17 16 L27 16 M23 16 L23 21 M27 16 L27 12" |
| 78 | stroke={color} |
| 79 | strokeWidth="1.5" |
| 80 | strokeLinecap="round" |
| 81 | /> |
| 82 | </svg> |
| 83 | ); |
| 84 | } |
| 85 | if (kind === "ddb") { |
| 86 | return ( |
| 87 | <svg width="22" height="22" viewBox="0 0 32 32" fill="none" aria-hidden> |
| 88 | <ellipse |
| 89 | cx="16" |
| 90 | cy="8" |
| 91 | rx="10" |
| 92 | ry="3" |
| 93 | fill={fill} |
| 94 | stroke={color} |
| 95 | strokeWidth="1.4" |
| 96 | /> |
| 97 | <ellipse |
| 98 | cx="16" |
| 99 | cy="16" |
| 100 | rx="10" |
| 101 | ry="3" |
| 102 | fill={fill} |
| 103 | stroke={color} |
| 104 | strokeWidth="1.4" |
| 105 | /> |
| 106 | <ellipse |
| 107 | cx="16" |
| 108 | cy="24" |
| 109 | rx="10" |
| 110 | ry="3" |
| 111 | fill={fill} |
| 112 | stroke={color} |
| 113 | strokeWidth="1.4" |
| 114 | /> |
| 115 | </svg> |
| 116 | ); |
| 117 | } |
| 118 | // d1 |
| 119 | return ( |
| 120 | <svg width="22" height="22" viewBox="0 0 32 32" fill="none" aria-hidden> |
| 121 | <ellipse |
| 122 | cx="16" |
| 123 | cy="8" |
| 124 | rx="10" |
| 125 | ry="3" |
| 126 | fill={fill} |
| 127 | stroke={color} |
| 128 | strokeWidth="1.5" |
| 129 | /> |
| 130 | <path |
| 131 | d="M6 8 V24 C6 25.7 10.5 27 16 27 C21.5 27 26 25.7 26 24 V8" |
| 132 | fill={fill} |
| 133 | stroke={color} |
| 134 | strokeWidth="1.5" |
| 135 | /> |
| 136 | <path |
| 137 | d="M9 14 L23 14 M9 18 L23 18 M9 22 L19 22" |
| 138 | stroke={color} |
| 139 | strokeWidth="1.2" |
| 140 | strokeLinecap="round" |
| 141 | /> |
| 142 | </svg> |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | export default function ServiceLayerSwap() { |
| 147 | const wrapRef = useRef<HTMLDivElement>(null); |
| 148 | const [idx, setIdx] = useState(0); |
| 149 | |
| 150 | useEffect(() => { |
| 151 | let timer: ReturnType<typeof setTimeout> | null = null; |
| 152 | let cancelled = false; |
| 153 | const reduced = window.matchMedia( |
| 154 | "(prefers-reduced-motion: reduce)", |
| 155 | ).matches; |
| 156 | |
| 157 | const tick = (i: number) => { |
| 158 | if (cancelled) return; |
| 159 | setIdx(i); |
| 160 | if (reduced) return; |
| 161 | timer = setTimeout(() => tick((i + 1) % IMPLS.length), DWELL_MS); |
| 162 | }; |
| 163 | const obs = new IntersectionObserver( |
| 164 | (entries) => { |
| 165 | for (const e of entries) { |
| 166 | if (e.isIntersecting) { |
| 167 | tick(0); |
| 168 | obs.disconnect(); |
| 169 | } |
| 170 | } |
| 171 | }, |
| 172 | { threshold: 0.25 }, |
| 173 | ); |
| 174 | if (wrapRef.current) obs.observe(wrapRef.current); |
| 175 | return () => { |
| 176 | cancelled = true; |
| 177 | if (timer) clearTimeout(timer); |
| 178 | obs.disconnect(); |
| 179 | }; |
| 180 | }, []); |
| 181 | |
| 182 | const active = IMPLS[idx]!; |
| 183 | |
| 184 | return ( |
| 185 | <div ref={wrapRef} className="service-swap-grid" data-nosnippet=""> |
| 186 | {/* LEFT — code: Worker that consumes Sessions, Layer name swaps */} |
| 187 | <div className="alc-code-block"> |
| 188 | <div className="alc-code-block__header"> |
| 189 | <span |
| 190 | className="alc-code-block__dot" |
| 191 | style={{ background: "var(--alc-danger)" }} |
| 192 | /> |
| 193 | <span |
| 194 | className="alc-code-block__dot" |
| 195 | style={{ background: "var(--alc-warn)" }} |
| 196 | /> |
| 197 | <span |
| 198 | className="alc-code-block__dot" |
| 199 | style={{ background: "var(--alc-accent-bright)" }} |
| 200 | /> |
| 201 | <span className="alc-code-block__filename">src/Api.ts</span> |
| 202 | </div> |
| 203 | <pre className="alc-code-block__pre"> |
| 204 | <C>{"// Service interface — defined once."}</C> |
| 205 | {"\n"} |
| 206 | <K>export class</K> <T>Sessions</T> <K>extends</K> <V>Context</V>. |
| 207 | <F>Service</F> |
| 208 | {"<"} |
| 209 | <T>Sessions</T>, {"{"} |
| 210 | {"\n "} |
| 211 | <V>get</V>: (<V>id</V>: <T>string</T>) {"=>"} <V>Effect</V>. |
| 212 | <T>Effect</T> |
| 213 | {"<"} |
| 214 | <T>Session</T> |
| 215 | {">"};{"\n "} |
| 216 | <V>put</V>: (<V>s</V>: <T>Session</T>) {"=>"} <V>Effect</V>. |
| 217 | <T>Effect</T> |
| 218 | {"<"} |
| 219 | <T>void</T> |
| 220 | {">"};{"\n"} |
| 221 | {"}>()("} |
| 222 | <S>"Sessions"</S> |
| 223 | {") {}"} |
| 224 | {"\n\n"} |
| 225 | <C>{"// Worker code never knows which Layer is providing it."}</C> |
| 226 | {"\n"} |
| 227 | <K>export default class</K> <T>Api</T> <K>extends</K>{" "} |
| 228 | <V>Cloudflare</V>.<F>Worker</F> |
| 229 | {"<"} |
| 230 | <T>Api</T> |
| 231 | {">()("} |
| 232 | {"\n "} |
| 233 | <S>"Api"</S>,{"\n "} |
| 234 | <V>Effect</V>.<F>gen</F>(<K>function</K>* () {"{"} |
| 235 | {"\n "} |
| 236 | <K>const</K> sessions = <K>yield</K>* <V>Sessions</V>;{"\n "} |
| 237 | <K>return</K> {"{ fetch: handler(sessions) };"} |
| 238 | {"\n "} |
| 239 | {"}).pipe("} |
| 240 | {"\n "} |
| 241 | <C> |
| 242 | {"// Swap one Layer — resources, bindings, IAM all swap with it."} |
| 243 | </C> |
| 244 | {"\n "} |
| 245 | <V>Effect</V>.<F>provide</F>( |
| 246 | <span |
| 247 | key={idx} |
| 248 | className="layer-swap" |
| 249 | style={{ color: active.color, fontWeight: 600 }} |
| 250 | > |
| 251 | {active.layer} |
| 252 | </span> |
| 253 | ), |
| 254 | {"\n "} |
| 255 | {"),"} |
| 256 | {"\n"} |
| 257 | {") {}"} |
| 258 | </pre> |
| 259 | </div> |
| 260 | |
| 261 | {/* RIGHT — visual: Sessions → Layer → Resource */} |
| 262 | <div className="service-swap-stage"> |
| 263 | <div className="service-swap-stage__node service-swap-stage__node--accent"> |
| 264 | <svg |
| 265 | width="22" |
| 266 | height="22" |
| 267 | viewBox="0 0 32 32" |
| 268 | fill="none" |
| 269 | aria-hidden |
| 270 | > |
| 271 | <rect |
| 272 | x="4" |
| 273 | y="6" |
| 274 | width="24" |
| 275 | height="20" |
| 276 | rx="4" |
| 277 | fill="rgba(184,138,74,0.15)" |
| 278 | stroke="var(--alc-accent-deep)" |
| 279 | strokeWidth="1.5" |
| 280 | /> |
| 281 | <path |
| 282 | d="M9 13 H23 M9 17 H19 M9 21 H21" |
| 283 | stroke="var(--alc-accent-deep)" |
| 284 | strokeWidth="1.4" |
| 285 | strokeLinecap="round" |
| 286 | /> |
| 287 | </svg> |
| 288 | <div className="service-swap-stage__label">Sessions</div> |
| 289 | <div className="service-swap-stage__sub">Context.Service</div> |
| 290 | </div> |
| 291 | |
| 292 | <div |
| 293 | className="service-swap-stage__edge service-swap-stage__edge--down" |
| 294 | style={{ borderColor: active.color }} |
| 295 | > |
| 296 | <span |
| 297 | className="service-swap-stage__edge-label" |
| 298 | style={{ color: active.color, borderColor: active.color }} |
| 299 | > |
| 300 | Layer.effect |
| 301 | </span> |
| 302 | </div> |
| 303 | |
| 304 | <div |
| 305 | key={`layer-${idx}`} |
| 306 | className="service-swap-stage__node layer-swap-node" |
| 307 | style={{ |
| 308 | borderColor: active.color, |
| 309 | boxShadow: `0 0 0 2px ${tint(active.color, 0.12)}`, |
| 310 | }} |
| 311 | > |
| 312 | <svg |
| 313 | width="22" |
| 314 | height="22" |
| 315 | viewBox="0 0 32 32" |
| 316 | fill="none" |
| 317 | aria-hidden |
| 318 | > |
| 319 | <path |
| 320 | d="M16 4 L28 10 L16 16 L4 10 Z" |
| 321 | fill={tint(active.color, 0.18)} |
| 322 | stroke={active.color} |
| 323 | strokeWidth="1.4" |
| 324 | strokeLinejoin="round" |
| 325 | /> |
| 326 | <path |
| 327 | d="M4 16 L16 22 L28 16" |
| 328 | stroke={active.color} |
| 329 | strokeWidth="1.4" |
| 330 | fill="none" |
| 331 | strokeLinejoin="round" |
| 332 | opacity="0.85" |
| 333 | /> |
| 334 | <path |
| 335 | d="M4 22 L16 28 L28 22" |
| 336 | stroke={active.color} |
| 337 | strokeWidth="1.4" |
| 338 | fill="none" |
| 339 | strokeLinejoin="round" |
| 340 | opacity="0.55" |
| 341 | /> |
| 342 | </svg> |
| 343 | <div |
| 344 | className="service-swap-stage__label" |
| 345 | style={{ color: active.color }} |
| 346 | > |
| 347 | {active.layer} |
| 348 | </div> |
| 349 | <div className="service-swap-stage__sub">Layer</div> |
| 350 | </div> |
| 351 | |
| 352 | <div |
| 353 | className="service-swap-stage__edge service-swap-stage__edge--down" |
| 354 | style={{ borderColor: active.color }} |
| 355 | > |
| 356 | <span |
| 357 | className="service-swap-stage__edge-label" |
| 358 | style={{ color: active.color, borderColor: active.color }} |
| 359 | > |
| 360 | {active.bindCall} |
| 361 | </span> |
| 362 | </div> |
| 363 | |
| 364 | <div |
| 365 | key={`res-${idx}`} |
| 366 | className="service-swap-stage__node layer-swap-node" |
| 367 | style={{ borderColor: active.color }} |
| 368 | > |
| 369 | <ResourceIcon kind={active.kind} color={active.color} /> |
| 370 | <div className="service-swap-stage__label"> |
| 371 | {active.resourceLabel} |
| 372 | </div> |
| 373 | <div className="service-swap-stage__sub">{active.resourceSub}</div> |
| 374 | </div> |
| 375 | |
| 376 | <div className="service-swap-stage__steps" aria-hidden> |
| 377 | {IMPLS.map((_, i) => ( |
| 378 | <span |
| 379 | key={i} |
| 380 | className={`service-swap-stage__dot${i === idx ? " is-active" : ""}`} |
| 381 | style={i === idx ? { background: active.color } : undefined} |
| 382 | /> |
| 383 | ))} |
| 384 | </div> |
| 385 | </div> |
| 386 | </div> |
| 387 | ); |
| 388 | } |