1import {
2 useEffect,
3 useRef,
4 useState,
5 type CSSProperties,
6 type ReactNode,
7} from "react";
8import { Line, sleep, TermChrome, useSpinner } from "./_terminal";
9
10/* ────────────────────────────────────────────────────────────
11 * Choreography
12 *
13 * A single state machine drives both the terminal log on the
14 * right and the rendered "page" inside the pseudo-browser on the
15 * left, so the two stay in lock-step (the user sees the cause
16 * and the effect).
17 * ──────────────────────────────────────────────────────────── */
18
19type Phase =
20 /** initial bring-up of the stack */
21 | "boot-photos"
22 | "boot-sessions"
23 | "boot-api"
24 | "ready-v1"
25 /** edit src/Api.ts → worker reloads, page rerenders with new copy */
26 | "edit-api"
27 | "reload-api"
28 | "request-hello"
29 | "ready-v2"
30 /** edit alchemy.run.ts → new Queue resource wired to Api */
31 | "edit-stack"
32 | "diff-queue"
33 | "wire-queue"
34 | "ready-v3";
35
36const PHASES: { phase: Phase; ms: number }[] = [
37 { phase: "boot-photos", ms: 600 },
38 { phase: "boot-sessions", ms: 500 },
39 { phase: "boot-api", ms: 700 },
40 { phase: "ready-v1", ms: 1300 },
41 { phase: "edit-api", ms: 1000 },
42 { phase: "reload-api", ms: 600 },
43 { phase: "request-hello", ms: 900 },
44 { phase: "ready-v2", ms: 1600 },
45 { phase: "edit-stack", ms: 1100 },
46 { phase: "diff-queue", ms: 700 },
47 { phase: "wire-queue", ms: 700 },
48 { phase: "ready-v3", ms: 2000 },
49];
50
51/* ────────────────────────────────────────────────────────────
52 * Terminal log driven by phase
53 * ──────────────────────────────────────────────────────────── */
54
55interface LogLine {
56 text: ReactNode;
57 key: string;
58}
59
60let _id = 0;
61const mkLine = (text: ReactNode): LogLine => ({ text, key: `${++_id}` });
62
63function bootLine(label: string, detail: string) {
64 return mkLine(
65 <>
66 <span style={{ color: "var(--alc-success)" }}>✓ </span>
67 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
68 {label}
69 </span>
70 <span style={{ color: "var(--alc-code-comment)" }}>{` (${detail})`}</span>
71 <span style={{ color: "var(--alc-success)" }}> created</span>
72 </>,
73 );
74}
75
76const READY_LINES: LogLine[] = [
77 mkLine(
78 <>
79 <span style={{ color: "var(--alc-code-comment)" }}> → </span>
80 <span style={{ color: "var(--alc-accent-bright)" }}>
81 http://localhost:1337
82 </span>
83 </>,
84 ),
85 mkLine(
86 <span style={{ color: "var(--alc-code-comment)" }}>
87 Watching for changes…
88 </span>,
89 ),
90];
91
92function editLine(file: string) {
93 return mkLine(
94 <>
95 <span style={{ color: "var(--alc-warn)" }}>↻ </span>
96 <span style={{ color: "var(--alc-code-comment)" }}>{file} changed</span>
97 </>,
98 );
99}
100
101const RELOAD_LINE = mkLine(
102 <>
103 <span style={{ color: "var(--alc-success)" }}>✓ </span>
104 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>Api</span>
105 <span style={{ color: "var(--alc-code-comment)" }}>{` reloaded in `}</span>
106 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>38ms</span>
107 </>,
108);
109
110function requestLine() {
111 return mkLine(
112 <>
113 <span
114 style={{ color: "var(--alc-code-comment)" }}
115 >{`[${new Date().toLocaleTimeString().slice(0, 8)}] `}</span>
116 <span style={{ color: "var(--alc-fg-invert)" }}>
117 GET /object/hello.txt
118 </span>
119 <span style={{ color: "var(--alc-success)" }}> 200</span>
120 </>,
121 );
122}
123
124const DIFF_LINE = mkLine(
125 <>
126 <span style={{ color: "var(--alc-success)" }}>+ </span>
127 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
128 Queue
129 </span>
130 <span
131 style={{ color: "var(--alc-code-comment)" }}
132 >{` (Cloudflare.Queues.Queue)`}</span>
133 <span style={{ color: "var(--alc-success)" }}> created</span>
134 </>,
135);
136
137const WIRE_LINE = mkLine(
138 <>
139 <span style={{ color: "var(--alc-warn)" }}>~ </span>
140 <span style={{ color: "var(--alc-code-comment)" }}>wired </span>
141 <span style={{ color: "var(--alc-code-type)" }}>Api → Queue.bind</span>
142 </>,
143);
144
145const PROMPT_LINE = mkLine(
146 <>
147 <span style={{ color: "var(--alc-code-comment)" }}>$ </span>
148 <span>alchemy dev</span>
149 </>,
150);
151
152/* ────────────────────────────────────────────────────────────
153 * Browser preview (the "rendered site")
154 * ──────────────────────────────────────────────────────────── */
155
156type AppVersion = "v1" | "v2" | "v3";
157
158function appVersionFor(phase: Phase): AppVersion {
159 switch (phase) {
160 case "ready-v1":
161 case "edit-api":
162 return "v1";
163 case "reload-api":
164 case "request-hello":
165 case "ready-v2":
166 case "edit-stack":
167 case "diff-queue":
168 return "v2";
169 case "wire-queue":
170 case "ready-v3":
171 return "v3";
172 default:
173 return "v1";
174 }
175}
176
177function isReloading(phase: Phase) {
178 // Worker is rebuilding (or app hasn't booted yet).
179 return (
180 phase === "boot-photos" ||
181 phase === "boot-sessions" ||
182 phase === "boot-api" ||
183 phase === "reload-api" ||
184 phase === "wire-queue"
185 );
186}
187
188function isBooting(phase: Phase) {
189 return (
190 phase === "boot-photos" || phase === "boot-sessions" || phase === "boot-api"
191 );
192}
193
194function PhotoTile({ label }: { label: string }) {
195 return (
196 <div className="bhr-tile">
197 <div className="bhr-tile__img" aria-hidden />
198 <div className="bhr-tile__label">{label}</div>
199 </div>
200 );
201}
202
203function PreviewApp({ version }: { version: AppVersion }) {
204 const heading = version === "v1" ? "Photos" : "Hello, photos";
205 return (
206 <div className="bhr-app" key={version}>
207 <header className="bhr-app__header">
208 <div>
209 <div className="bhr-app__eyebrow">my-app · cloudflare workers</div>
210 <h3 className="bhr-app__title">{heading}</h3>
211 </div>
212 {version === "v3" && (
213 <button type="button" className="bhr-app__btn">
214 + Send to Queue
215 </button>
216 )}
217 </header>
218 <div className="bhr-app__grid">
219 <PhotoTile label="hello.txt" />
220 <PhotoTile label="sunset.jpg" />
221 <PhotoTile label="forest.png" />
222 <PhotoTile label="diner.heic" />
223 </div>
224 {version === "v3" && (
225 <div className="bhr-app__footer">
226 <span className="bhr-app__chip">
227 queue: <strong>uploads</strong>
228 </span>
229 <span className="bhr-app__chip">
230 bucket: <strong>Photos</strong>
231 </span>
232 <span className="bhr-app__chip">
233 kv: <strong>Sessions</strong>
234 </span>
235 </div>
236 )}
237 </div>
238 );
239}
240
241/* ────────────────────────────────────────────────────────────
242 * Browser chrome
243 * ──────────────────────────────────────────────────────────── */
244
245function BrowserChrome({
246 reloading,
247 children,
248 style,
249}: {
250 reloading: boolean;
251 children: ReactNode;
252 style?: CSSProperties;
253}) {
254 return (
255 // Simulated browser chrome — decoration, kept out of search snippets.
256 <div className="bhr-browser" style={style} data-nosnippet="">
257 <div className="bhr-browser__header">
258 <span
259 className="alc-code-block__dot"
260 style={{ background: "var(--alc-danger)" }}
261 />
262 <span
263 className="alc-code-block__dot"
264 style={{ background: "var(--alc-warn)" }}
265 />
266 <span
267 className="alc-code-block__dot"
268 style={{ background: "var(--alc-accent-bright)" }}
269 />
270 <div className="bhr-browser__nav" aria-hidden>
271 <span className="bhr-browser__navbtn">‹</span>
272 <span className="bhr-browser__navbtn">›</span>
273 <span
274 className={
275 "bhr-browser__navbtn bhr-browser__reload" +
276 (reloading ? " is-spinning" : "")
277 }
278 >
279 ↻
280 </span>
281 </div>
282 <div className="bhr-browser__url">
283 <span className="bhr-browser__lock" aria-hidden>
284 ●
285 </span>
286 <span className="bhr-browser__urlhost">localhost</span>
287 <span className="bhr-browser__urlmuted">:1337/</span>
288 <span style={{ flex: 1 }} />
289 <span className="bhr-browser__hmr">
290 <span className="bhr-browser__hmrdot" /> HMR
291 </span>
292 </div>
293 </div>
294 <div className="bhr-browser__body">
295 {reloading && <div className="bhr-browser__progress" aria-hidden />}
296 {children}
297 </div>
298 </div>
299 );
300}
301
302/* ────────────────────────────────────────────────────────────
303 * Main component
304 * ──────────────────────────────────────────────────────────── */
305
306export default function BrowserHotReload() {
307 const [phase, setPhase] = useState<Phase>("boot-photos");
308 const [lines, setLines] = useState<LogLine[]>([PROMPT_LINE]);
309 const [busy, setBusy] = useState(false);
310 const cancelRef = useRef(false);
311
312 useEffect(() => {
313 cancelRef.current = false;
314 const aborted = () => cancelRef.current;
315
316 const push = (...newLines: LogLine[]) =>
317 setLines((ls) => [...ls, ...newLines].slice(-14));
318
319 const reset = () => {
320 setLines([PROMPT_LINE]);
321 };
322
323 const run = async () => {
324 while (!aborted()) {
325 reset();
326 setPhase("boot-photos");
327 await sleep(300);
328
329 for (const { phase: next, ms } of PHASES) {
330 if (aborted()) return;
331 setPhase(next);
332
333 // Show the spinner during clearly "busy" steps so the
334 // browser progress bar and the terminal feel coupled.
335 const showSpinner =
336 isBooting(next) || next === "reload-api" || next === "wire-queue";
337 setBusy(showSpinner);
338 await sleep(ms);
339 setBusy(false);
340
341 switch (next) {
342 case "boot-photos":
343 push(bootLine("Photos", "Cloudflare.R2.Bucket"));
344 break;
345 case "boot-sessions":
346 push(bootLine("Sessions", "Cloudflare.KV.Namespace"));
347 break;
348 case "boot-api":
349 push(bootLine("Api", "Cloudflare.Worker · local → workerd"));
350 break;
351 case "ready-v1":
352 case "ready-v2":
353 case "ready-v3":
354 push(...READY_LINES.map((l) => mkLine(l.text)));
355 break;
356 case "edit-api":
357 push(editLine("src/Api.ts"));
358 break;
359 case "reload-api":
360 push(RELOAD_LINE);
361 break;
362 case "request-hello":
363 push(requestLine());
364 break;
365 case "edit-stack":
366 push(editLine("alchemy.run.ts"));
367 break;
368 case "diff-queue":
369 push(DIFF_LINE);
370 break;
371 case "wire-queue":
372 push(WIRE_LINE);
373 break;
374 }
375 }
376
377 await sleep(1800);
378 }
379 };
380
381 run();
382 return () => {
383 cancelRef.current = true;
384 };
385 }, []);
386
387 const spinner = useSpinner(busy);
388 const reloading = isReloading(phase);
389 const version = appVersionFor(phase);
390 const booting = isBooting(phase);
391
392 return (
393 <div className="bhr-stage">
394 {/* Browser preview — hidden on small screens */}
395 <BrowserChrome reloading={reloading} style={{ minWidth: 0 }}>
396 {booting ? (
397 <div className="bhr-app bhr-app--boot">
398 <div className="bhr-app__skeleton" />
399 <div className="bhr-app__skeleton bhr-app__skeleton--sm" />
400 <div className="bhr-app__grid">
401 <div className="bhr-tile bhr-tile--ghost" />
402 <div className="bhr-tile bhr-tile--ghost" />
403 <div className="bhr-tile bhr-tile--ghost" />
404 <div className="bhr-tile bhr-tile--ghost" />
405 </div>
406 </div>
407 ) : (
408 <PreviewApp version={version} />
409 )}
410 </BrowserChrome>
411
412 {/* Terminal — always visible */}
413 <div className="bhr-term">
414 <TermChrome
415 title="~/my-app"
416 badge="DEV"
417 badgeColor="var(--alc-accent-bright)"
418 maxLines={15}
419 >
420 {lines.map((l) => (
421 <Line key={l.key}>{l.text}</Line>
422 ))}
423 {busy && (
424 <Line>
425 <span style={{ color: "var(--alc-code-comment)" }}>
426 {spinner}{" "}
427 </span>
428 </Line>
429 )}
430 </TermChrome>
431 </div>
432 </div>
433 );
434}