Public: anyone can read this; the crew can change it.
The studio sees a preview the instant it serves: listen for `app.serving` #89
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/81-the-studio-sees-a-preview-the-instant-it → main 1 commit fork
3 files
changed +78
−2
src/web/live.ts +10 −2
| @@ -135,6 +135,14 @@ export const liveClientJs = /* js */ ` | ||
| 135 | 135 | return isApp && data && data.kind && data.kind.indexOf('run.') === 0 && |
| 136 | 136 | data.payload && data.payload.kind === 'deploy'; |
| 137 | 137 | } |
| 138 | // 'app.serving' fires at the swap-in — the instant a preview answers — | |
| 139 | // which is seconds to half a minute before its tests (and so its | |
| 140 | // run.finished) are done. It carries no run.kind, so it is matched on its | |
| 141 | // own kind and on the app/env it names, never on a deploy run's shape. | |
| 142 | function isServing(data) { | |
| 143 | return isApp && data && data.kind === 'app.serving' && data.payload && | |
| 144 | typeof data.payload.app === 'string' && typeof data.payload.env === 'string'; | |
| 145 | } | |
| 138 | 146 | function refreshApp() { |
| 139 | 147 | if (appBusy) { appQueued = true; return; } |
| 140 | 148 | appBusy = true; |
| @@ -160,7 +168,7 @@ export const liveClientJs = /* js */ ` | ||
| 160 | 168 | function onEvent(e) { |
| 161 | 169 | var data; |
| 162 | 170 | try { data = JSON.parse(e.data); } catch (_) { return; } |
| 163 | if (isDeploy(data)) { refreshApp(); return; } | |
| 171 | if (isDeploy(data) || isServing(data)) { refreshApp(); return; } | |
| 164 | 172 | if (relevant(data) && conv && (data.kind === thread + '.commented')) { |
| 165 | 173 | appendNew(); |
| 166 | 174 | } else if (relevant(data) || !thread) { |
| @@ -175,7 +183,7 @@ export const liveClientJs = /* js */ ` | ||
| 175 | 183 | 'pull.opened', 'pull.closed', 'pull.reopened', 'pull.merged', 'pull.commented', 'pull.reviewed', |
| 176 | 184 | 'branch.created', 'branch.deleted', |
| 177 | 185 | 'label.created', 'label.updated', 'label.deleted', |
| 178 | 'run.queued', 'run.started', 'run.finished', | |
| 186 | 'run.queued', 'run.started', 'run.finished', 'app.serving', | |
| 179 | 187 | 'app.created', 'app.updated', 'app.deleted', 'app.stopped', |
| 180 | 188 | 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed', |
| 181 | 189 | ]; |
test/live-pages.test.ts +4
| @@ -104,6 +104,10 @@ describe("live browsing pages", () => { | ||
| 104 | 104 | expect(html).toContain('id="live"'); |
| 105 | 105 | expect(html).toContain('data-app="1"'); |
| 106 | 106 | expect(html).toContain("new EventSource"); |
| 107 | // `app.serving` fires at the swap-in, seconds to half a minute before | |
| 108 | // the deploy run's own `run.finished` — the client must listen for it | |
| 109 | // directly, not only learn a preview is live once its tests are done. | |
| 110 | expect(html).toContain("app.serving"); | |
| 107 | 111 | }); |
| 108 | 112 | |
| 109 | 113 | test("the studio home wires up asks, apps, now and on-you as live regions, signed in; a signed-out visitor gets neither", async () => { |
test/studio.test.ts +64
| @@ -34,6 +34,7 @@ import { | ||
| 34 | 34 | claimRun, |
| 35 | 35 | createRun, |
| 36 | 36 | finishRun, |
| 37 | getRun, | |
| 37 | 38 | markServing, |
| 38 | 39 | noteRunProgress, |
| 39 | 40 | } from "../src/core/runs.ts"; |
| @@ -1466,6 +1467,69 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1466 | 1467 | expect(live).toContain(">live<"); |
| 1467 | 1468 | expect(live).toContain(link); |
| 1468 | 1469 | }); |
| 1470 | ||
| 1471 | test("app.serving reaches the repo's stream with the shape the live client keys on, the instant the swap-in happens — before its run has finished", async () => { | |
| 1472 | const deployRun = createRun(app.db, { | |
| 1473 | repoId: repo.id, | |
| 1474 | kind: "deploy", | |
| 1475 | env: "production", | |
| 1476 | commitOid: "deadbeef", | |
| 1477 | reason: "push", | |
| 1478 | }); | |
| 1479 | claimRun(app.db, deployRun.id); | |
| 1480 | const served = markServing(app.db, deployRun.id); | |
| 1481 | expect(served?.servingAt).toBeDefined(); | |
| 1482 | ||
| 1483 | const stream = await fetch(`${base}/alice/vault/stream`, { | |
| 1484 | headers: { cookie: owner }, | |
| 1485 | }); | |
| 1486 | await Bun.sleep(50); | |
| 1487 | ||
| 1488 | // The exact append `AppsRuntime` makes at the swap-in (runtime.ts), | |
| 1489 | // republished here so the test does not have to spin up a real deploy. | |
| 1490 | app.bus.append({ | |
| 1491 | kind: "app.serving", | |
| 1492 | repoId: repo.id, | |
| 1493 | payload: { | |
| 1494 | app: record.slug, | |
| 1495 | env: "production", | |
| 1496 | commitOid: deployRun.commitOid, | |
| 1497 | servingAt: served!.servingAt, | |
| 1498 | testsRunning: true, | |
| 1499 | }, | |
| 1500 | }); | |
| 1501 | ||
| 1502 | const reader = stream.body!.getReader(); | |
| 1503 | const decoder = new TextDecoder(); | |
| 1504 | let buffer = ""; | |
| 1505 | let event: Record<string, unknown> | undefined; | |
| 1506 | const deadline = Date.now() + 5_000; | |
| 1507 | while (!event && Date.now() < deadline) { | |
| 1508 | const { done, value } = await reader.read(); | |
| 1509 | if (done) break; | |
| 1510 | buffer += decoder.decode(value); | |
| 1511 | const frames = buffer.split("\n\n"); | |
| 1512 | buffer = frames.pop() ?? ""; | |
| 1513 | for (const frame of frames) { | |
| 1514 | if (!/^event: app\.serving$/m.test(frame)) continue; | |
| 1515 | const data = /^data: (.*)$/m.exec(frame)?.[1]; | |
| 1516 | if (data !== undefined) event = JSON.parse(data); | |
| 1517 | } | |
| 1518 | } | |
| 1519 | await reader.cancel().catch(() => {}); | |
| 1520 | expect(event).toBeDefined(); | |
| 1521 | expect(event?.["kind"]).toBe("app.serving"); | |
| 1522 | const payload = event?.["payload"] as Record<string, unknown>; | |
| 1523 | expect(payload["app"]).toBe(record.slug); | |
| 1524 | expect(payload["env"]).toBe("production"); | |
| 1525 | expect(payload["commitOid"]).toBe(deployRun.commitOid); | |
| 1526 | expect(payload["servingAt"]).toBe(served!.servingAt); | |
| 1527 | expect(payload["testsRunning"]).toBe(true); | |
| 1528 | ||
| 1529 | // finishRun never ran: run.finished has not fired, yet app.serving | |
| 1530 | // already has. That gap is the whole point of the issue. | |
| 1531 | expect(getRun(app.db, deployRun.id)?.state).toBe("running"); | |
| 1532 | }); | |
| 1469 | 1533 | }); |
| 1470 | 1534 | |
| 1471 | 1535 | /** |
new above
offline