Public: anyone can read this; the crew can change it.
The device-code connect page polls on a timer: move it to a stream or a live-region swap #10971
scout/forge:scout/10964-the-device-code-connect-page-polls-on-a → main 4 commits fork
9 files
changed +131
−41
src/http/flow_stream.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * GET /settings/origins/flows/:id/stream — one device-code connect, held. | |
| 3 | * | |
| 4 | * The provider sets the cadence of its poll (the grant's `interval`, raised by | |
| 5 | * `slow_down`), and it sends nothing to us. So the forge keeps that poll: this | |
| 6 | * stream asks the provider on the flow's interval, server side, and the page | |
| 7 | * hears one event, `flow`, when the flow leaves pending. The browser never | |
| 8 | * loops; the stream ends with the event. | |
| 9 | */ | |
| 10 | ||
| 11 | import { pollOAuthConnect } from "../actions/origins.ts"; | |
| 12 | import { getFlowFor } from "../origins/oauth.ts"; | |
| 13 | import { eventStream } from "./sse.ts"; | |
| 14 | import type { Ctx } from "./context.ts"; | |
| 15 | import { accountOnly } from "../web/pages/origins.ts"; | |
| 16 | ||
| 17 | /** Wait `ms`, or less when `signal` aborts. Resolves either way. */ | |
| 18 | function sleep(ms: number, signal: AbortSignal): Promise<void> { | |
| 19 | return new Promise((resolve) => { | |
| 20 | if (signal.aborted) return resolve(); | |
| 21 | const done = (): void => { | |
| 22 | clearTimeout(timer); | |
| 23 | signal.removeEventListener("abort", done); | |
| 24 | resolve(); | |
| 25 | }; | |
| 26 | const timer = setTimeout(done, ms); | |
| 27 | signal.addEventListener("abort", done); | |
| 28 | }); | |
| 29 | } | |
| 30 | ||
| 31 | export function originFlowStream(ctx: Ctx): Response { | |
| 32 | const denied = accountOnly(ctx); | |
| 33 | if (denied) return denied; | |
| 34 | const user = ctx.auth.user!; | |
| 35 | const flowId = ctx.params.id ?? ""; | |
| 36 | const first = getFlowFor(ctx.app.db, user.id, flowId); | |
| 37 | if (!first) return new Response("not found", { status: 404 }); | |
| 38 | const { app } = ctx; | |
| 39 | ||
| 40 | return eventStream(app, ctx.request, (emit, close) => { | |
| 41 | const gone = new AbortController(); | |
| 42 | void (async () => { | |
| 43 | let flow = first; | |
| 44 | try { | |
| 45 | while (!gone.signal.aborted) { | |
| 46 | if (flow.status !== "pending") break; | |
| 47 | await sleep(Math.max(1, flow.intervalSecs) * 1000, gone.signal); | |
| 48 | if (gone.signal.aborted) return; | |
| 49 | const polled = await pollOAuthConnect(app, user, flowId); | |
| 50 | if (gone.signal.aborted) return; | |
| 51 | if (!polled.ok) { | |
| 52 | emit("flow", { status: "failed" }); | |
| 53 | break; | |
| 54 | } | |
| 55 | if (polled.value.status !== "pending") { | |
| 56 | emit("flow", { status: polled.value.status }); | |
| 57 | break; | |
| 58 | } | |
| 59 | // `slow_down` lengthens the interval: read it again each round. | |
| 60 | flow = getFlowFor(app.db, user.id, flowId) ?? flow; | |
| 61 | } | |
| 62 | if (!gone.signal.aborted && flow.status !== "pending") emit("flow", { status: flow.status }); | |
| 63 | } catch { | |
| 64 | // A poll that threw ends the stream; the page's own poll form still works. | |
| 65 | if (!gone.signal.aborted) emit("flow", { status: "failed" }); | |
| 66 | } | |
| 67 | close(); | |
| 68 | })(); | |
| 69 | return () => gone.abort(); | |
| 70 | }); | |
| 71 | } |
src/http/settings_routes.ts +2
| @@ -128,6 +128,7 @@ import { | ||
| 128 | 128 | originTrustedHosts, |
| 129 | 129 | } from "../web/pages/origins.ts"; |
| 130 | 130 | import { originArchivedPage, originArchivedRestore } from "../web/pages/origin-archived.ts"; |
| 131 | import { originFlowStream } from "./flow_stream.ts"; | |
| 131 | 132 | import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts"; |
| 132 | 133 | import { originMirrorSubmit, originReposPage } from "../web/pages/origin-repos.ts"; |
| 133 | 134 | |
| @@ -309,6 +310,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 309 | 310 | router.add("POST", "/settings/origins/apps/:id/delete", originAppDelete); |
| 310 | 311 | router.add("GET", "/settings/origins/flows/:id", originFlowPage); |
| 311 | 312 | router.add("POST", "/settings/origins/flows/:id/poll", originFlowPoll); |
| 313 | router.add("GET", "/settings/origins/flows/:id/stream", originFlowStream); | |
| 312 | 314 | router.add("GET", "/settings/origins/:id/repos", originReposPage); |
| 313 | 315 | router.add("GET", "/settings/origins/:id/archived", originArchivedPage); |
| 314 | 316 | router.add("POST", "/settings/origins/:id/archived", originArchivedRestore); |
src/web/client.ts +11 −23
| @@ -62,34 +62,22 @@ const modalJs = /* js */ ` | ||
| 62 | 62 | `; |
| 63 | 63 | |
| 64 | 64 | /** |
| 65 | * The device-code page's only enhancement: submit the "check again" form on | |
| 66 | * the interval the provider asked for, and reload when the flow stops being | |
| 67 | * pending. Everything it does is something the page already offers a button | |
| 68 | * for, and the poll route answers JSON only because it was asked to — the | |
| 69 | * same POST from a plain form gets a redirect. | |
| 65 | * The device-code page's only enhancement: listen on the page's own stream | |
| 66 | * (`form[data-stream]`) and reload when the flow stops being pending. The | |
| 67 | * forge holds the provider's poll server-side, so this script has no timer | |
| 68 | * and asks nothing; without it the "check again" form still works. | |
| 70 | 69 | * |
| 71 | 70 | * Shipped per page (`PageOptions.scriptJs`) rather than in the shell: a page |
| 72 | 71 | * with no connect in flight should carry none of it. |
| 73 | 72 | */ |
| 74 | export const pollJs = /* js */ ` | |
| 73 | export const flowStreamJs = /* js */ ` | |
| 75 | 74 | (function () { |
| 76 | var form = document.querySelector('form[data-poll]'); | |
| 77 | if (!form) return; | |
| 78 | var every = Number(form.getAttribute('data-poll') || 0); | |
| 79 | if (!every) return; | |
| 80 | var tries = 0; | |
| 81 | function ask() { | |
| 82 | if (tries++ > 200) return; | |
| 83 | fetch(form.getAttribute('action') || location.href, { | |
| 84 | method: 'POST', | |
| 85 | headers: { accept: 'application/json' }, | |
| 86 | body: new FormData(form), | |
| 87 | }).then(function (r) { return r.json(); }).then(function (out) { | |
| 88 | if (out && out.status && out.status !== 'pending') { location.reload(); return; } | |
| 89 | window.setTimeout(ask, every * 1000); | |
| 90 | }, function () { window.setTimeout(ask, every * 1000); }); | |
| 91 | } | |
| 92 | window.setTimeout(ask, every * 1000); | |
| 75 | var form = document.querySelector('form[data-stream]'); | |
| 76 | if (!form || typeof EventSource === 'undefined') return; | |
| 77 | var url = form.getAttribute('data-stream'); | |
| 78 | if (!url) return; | |
| 79 | var es = new EventSource(url); | |
| 80 | es.addEventListener('flow', function () { es.close(); location.reload(); }); | |
| 93 | 81 | })(); |
| 94 | 82 | `; |
| 95 | 83 |
src/web/pages/origin-flow.ts +4 −6
| @@ -16,7 +16,7 @@ import { qrEncode, qrSvg } from "../../design/qr.ts"; | ||
| 16 | 16 | import { readForm } from "../form.ts"; |
| 17 | 17 | import { html, raw, type Html } from "../html.ts"; |
| 18 | 18 | import { page, redirect } from "../layout.ts"; |
| 19 | import { pollJs } from "../client.ts"; | |
| 19 | import { flowStreamJs } from "../client.ts"; | |
| 20 | 20 | import { btn, icon, row } from "../ui.ts"; |
| 21 | 21 | import { |
| 22 | 22 | accountOnly, |
| @@ -36,7 +36,7 @@ import { | ||
| 36 | 36 | * The device grant shows the code and where to type it; the code flow shows |
| 37 | 37 | * one link to the instance. Both work with no JavaScript: the page carries a |
| 38 | 38 | * "check again" form, and reloading it re-polls. With script, the same form |
| 39 | * is submitted for you every couple of seconds. | |
| 39 | * hears the forge's own stream and reloads when the connect ends. | |
| 40 | 40 | */ |
| 41 | 41 | export function originFlowPage(ctx: Ctx): Response { |
| 42 | 42 | const denied = accountOnly(ctx); |
| @@ -105,9 +105,7 @@ export function originFlowPage(ctx: Ctx): Response { | ||
| 105 | 105 | class="bar mt" |
| 106 | 106 | method="post" |
| 107 | 107 | action="${pollAction}" |
| 108 | data-poll="${flow.flow === "device" | |
| 109 | ? String(Math.max(1, flow.intervalSecs)) | |
| 110 | : "0"}" | |
| 108 | data-stream="${flow.flow === "device" ? `/settings/origins/flows/${flow.id}/stream` : ""}" | |
| 111 | 109 | > |
| 112 | 110 | <span class="muted small" |
| 113 | 111 | >Waiting for you to authorize |
| @@ -136,7 +134,7 @@ export function originFlowPage(ctx: Ctx): Response { | ||
| 136 | 134 | No JavaScript? Refresh this page to check again — the reload re-polls. |
| 137 | 135 | </p> |
| 138 | 136 | </div>`, |
| 139 | scriptJs: pollJs, | |
| 137 | scriptJs: flowStreamJs, | |
| 140 | 138 | }); |
| 141 | 139 | } |
| 142 | 140 |
test/no-polling.test.ts +7 −9
| @@ -16,7 +16,7 @@ import { createIssue } from "../src/core/issues.ts"; | ||
| 16 | 16 | import { createRepo } from "../src/core/repos.ts"; |
| 17 | 17 | import { createSession } from "../src/core/sessions.ts"; |
| 18 | 18 | import { buildRouter, serve, type Servers } from "../src/http/server.ts"; |
| 19 | import { pollJs } from "../src/web/client.ts"; | |
| 19 | import { flowStreamJs } from "../src/web/client.ts"; | |
| 20 | 20 | import { adminCookie, seedAdmin } from "./helpers/admin.ts"; |
| 21 | 21 | import { quietLogs } from "./helpers/globals.ts"; |
| 22 | 22 | import { pool } from "./helpers/sweep-clock.ts"; |
| @@ -230,10 +230,7 @@ describe("no rendered page polls", () => { | ||
| 230 | 230 | */ |
| 231 | 231 | |
| 232 | 232 | /** A script that legitimately waits on something outside the page: marker in its text → why. */ |
| 233 | const POLL_ALLOWED: Record<string, string> = { | |
| 234 | "form[data-poll]": | |
| 235 | "the device-code connect page (pollJs, src/web/client.ts) waits on a provider that sets the cadence (its `interval`), so there is no event of ours to stream; it posts the form the page already offers, stops after 200 tries, and reloads only once the flow leaves pending", | |
| 236 | }; | |
| 233 | const POLL_ALLOWED: Record<string, string> = {}; | |
| 237 | 234 | |
| 238 | 235 | /** The text of the balanced `(…)` or `{…}` whose opener is at `open`. Strings and comments are not understood: a bracket inside one skews it. */ |
| 239 | 236 | function balanced(src: string, open: number): string { |
| @@ -322,6 +319,7 @@ const NOT_WALKED: Record<string, string> = { | ||
| 322 | 319 | "/settings/origins/:id/mirror/:job": ORIGIN, |
| 323 | 320 | "/settings/origins/:id/repos": ORIGIN, |
| 324 | 321 | "/settings/origins/flows/:id": ORIGIN, |
| 322 | "/settings/origins/flows/:id/stream": ORIGIN + "; a stream, not a page", | |
| 325 | 323 | "/settings/teams/:slug": "needs a team", |
| 326 | 324 | "/talk/:id": "needs a conversation of the signed-in person", |
| 327 | 325 | }; |
| @@ -441,9 +439,9 @@ describe("the polling detector", () => { | ||
| 441 | 439 | expect(findPolling(`setInterval(pick("fast"), 1000)`)).toEqual([]); |
| 442 | 440 | }); |
| 443 | 441 | |
| 444 | test("the device-code poll is flagged, and the one allowlist entry matches it and says why", () => { | |
| 445 | expect(findPolling(pollJs).length).toBeGreaterThan(0); | |
| 446 | expect(pollReason(pollJs)).toBeDefined(); | |
| 447 | for (const why of Object.values(POLL_ALLOWED)) expect(why.length).toBeGreaterThan(30); | |
| 442 | test("the device-code page holds no timer: it listens on a stream and the allowlist is empty", () => { | |
| 443 | expect(findPolling(flowStreamJs)).toEqual([]); | |
| 444 | expect(flowStreamJs).toContain("EventSource"); | |
| 445 | expect(Object.keys(POLL_ALLOWED)).toEqual([]); | |
| 448 | 446 | }); |
| 449 | 447 | }); |
test/pages-budget.test.ts +1 −1
| @@ -40,7 +40,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = { | ||
| 40 | 40 | }; |
| 41 | 41 | |
| 42 | 42 | /** Patterns with no fixture today: lower this as fixtures are added, never raise it silently. */ |
| 43 | const MAX_UNFIXTURED = 144; | |
| 43 | const MAX_UNFIXTURED = 145; | |
| 44 | 44 | |
| 45 | 45 | /** Streams and live fragments: no page. */ |
| 46 | 46 | const NOT_PAGES = new Set([ |
test/security-floor-routes.test.ts +1
| @@ -130,6 +130,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 130 | 130 | "/settings/origins/:id/mirror/:job": ORIGIN, |
| 131 | 131 | "/settings/origins/:id/repos": ORIGIN, |
| 132 | 132 | "/settings/origins/flows/:id": ORIGIN, |
| 133 | "/settings/origins/flows/:id/stream": ORIGIN, | |
| 133 | 134 | }; |
| 134 | 135 | |
| 135 | 136 | let dataDir = ""; |
test/web-code.test.ts +2 −2
| @@ -37,7 +37,7 @@ import { | ||
| 37 | 37 | codeJs, |
| 38 | 38 | editorJs, |
| 39 | 39 | mirrorJs, |
| 40 | pollJs, | |
| 40 | flowStreamJs, | |
| 41 | 41 | shellJs, |
| 42 | 42 | } from "../src/web/client.ts"; |
| 43 | 43 | import { liveClientJs } from "../src/web/live.ts"; |
| @@ -548,7 +548,7 @@ describe("the code browser on the web", () => { | ||
| 548 | 548 | ["codeJs", codeJs], |
| 549 | 549 | ["editorJs", editorJs], |
| 550 | 550 | ["mirrorJs", mirrorJs], |
| 551 | ["pollJs", pollJs], | |
| 551 | ["flowStreamJs", flowStreamJs], | |
| 552 | 552 | ["liveClientJs", liveClientJs], |
| 553 | 553 | ] as const) { |
| 554 | 554 | expect(() => new Function(source), `${name} must parse`).not.toThrow(); |
test/web-origins.test.ts +32
| @@ -694,6 +694,38 @@ describe("origins on the web", () => { | ||
| 694 | 694 | expect(landing).toContain("OAuth"); |
| 695 | 695 | }); |
| 696 | 696 | |
| 697 | test("the device page holds the provider's poll: a stream says when the flow ends, and the page has no timer", async () => { | |
| 698 | process.env.FORGE_GITHUB_CLIENT_ID = CLIENT_ID; | |
| 699 | const started = await asUser(bob, "/settings/origins/oauth", { | |
| 700 | form: { kind: "github", host: github.url }, | |
| 701 | }); | |
| 702 | const where = started.headers.get("location") ?? ""; | |
| 703 | expect(where).toStartWith("/settings/origins/flows/"); | |
| 704 | const page = await (await asUser(bob, where)).text(); | |
| 705 | expect(page).toContain(`data-stream="${where}/stream"`); | |
| 706 | expect(page).not.toContain("data-poll"); | |
| 707 | ||
| 708 | // Someone else's stream is a 404, and so is a flow that does not exist. | |
| 709 | expect((await asUser(alice, `${where}/stream`)).status).toBe(404); | |
| 710 | expect((await asUser(bob, "/settings/origins/flows/nope/stream")).status).toBe(404); | |
| 711 | ||
| 712 | ghOauth.approved = true; | |
| 713 | const res = await asUser(bob, `${where}/stream`); | |
| 714 | expect(res.status).toBe(200); | |
| 715 | expect(res.headers.get("content-type")).toContain("text/event-stream"); | |
| 716 | const reader = res.body!.getReader(); | |
| 717 | let text = ""; | |
| 718 | const deadline = Date.now() + 20_000; | |
| 719 | while (!text.includes("event: flow") && Date.now() < deadline) { | |
| 720 | const { value, done } = await reader.read(); | |
| 721 | if (done) break; | |
| 722 | text += new TextDecoder().decode(value); | |
| 723 | } | |
| 724 | await reader.cancel(); | |
| 725 | expect(text).toContain("event: flow"); | |
| 726 | expect(text).toContain('"status":"done"'); | |
| 727 | }, 30_000); | |
| 728 | ||
| 697 | 729 | /** |
| 698 | 730 | * The flow page has to work for someone whose browser is a phone across |
| 699 | 731 | * the room. Everything that gets it there is asserted here: the QR, the |
new above
offline