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 #11024
Check · Review · Land · Live done — Landed and live in production.
The device-code connect page no longer polls from the browser: the forge now asks the provider on the provider's own interval and pushes one event to the page. The allowlist entry for `pollJs` in `test/no-polling.test.ts` is gone. I ran `tsc`, `scripts/gate.ts`, `scripts/design-lint.ts` and `test/no-polling.test.ts`, `test/bring-oauth-return.test.ts`, `test/forge-origin-oauth.test.ts`, `test/opena…
shipped production is serving it deploy 95ed903
The device-code connect page no longer polls from the browser: the forge now asks the provider on the provider's own interval and pushes one event to the page. The allowlist entry for pollJs in test/no-polling.test.ts is gone. I ran tsc, scripts/gate.ts, scripts/design-lint.ts and test/no-polling.test.ts, test/bring-oauth-return.test.ts, test/forge-origin-oauth.test.ts, test/openapi-routes.test.ts, test/web-code.test.ts and test/keys-origins.test.ts; all pass. scripts/fast.ts named 1,584 test files, too many to run here, so the forge's check on the push is the full run.
What changed
- New
src/http/flow_stream.tsaddsGET /settings/origins/flows/:id/stream, registered insrc/http/settings_routes.ts. It is an event stream held open for the signed-in owner of the flow. Server-side, it calls the existingpollOAuthConnecton the flow's interval (including theslow_downbackoff). When the flow is no longer pending it sends oneflow.settledevent with the status and closes. A missing or foreign flow answers 404, and a request with no signed-in user answers 401. pollJsinsrc/web/client.tsis now anEventSourceon the form'sdata-streamattribute that reloads the page onflow.settled. It has no timer, nofetchand no retry counter.src/web/pages/origin-flow.tsemitsdata-streaminstead ofdata-poll, for device flows only. The "Check again" form, the POST poll route and the no-JavaScript behaviour are unchanged.- In
test/no-polling.test.tsI removedPOLL_ALLOWEDandpollReason, so any polling script is now an offender. The old test is replaced by one assertingpollJsis not flagged and usesEventSource. The new stream route is listed inNOT_WALKEDbecause it needs a flow. - A new test in
test/bring-oauth-return.test.tschecks that the page carriesdata-stream, that a signed-out request is refused, and that the stream ends itself withflow.settled. In that test the account is already connected from the earlier test, so the flow settles asfailedrather thandone. That still exercises the stream, and adoneoutcome is not asserted there.
What I left alone
- The "Check again" button and its POST route, since they are the no-JavaScript path.
- Several tabs open on one flow each hold their own server-side poll. That is harmless but not coalesced.
To try it
Start a device-code connect at /settings/origins, approve it on the provider, and the page moves on without any repeating requests from the browser.
The first push was refused by the pre-push hook, which wants a Try: line in a commit message, so there is an empty Try: commit on the branch.
The device-code connect page now waits on a stream the forge holds instead of a browser timer, so /settings/origins no longer needs a polling allowlist entry.
Changes
- Stream route:
GET /settings/origins/flows/:id/stream(src/http/flow_stream.ts) is the stream the connect page listens on. The client code insrc/web/client.tsandsrc/web/pages/origin-flow.tsnow uses it instead of polling.test/no-polling.test.tsdrops its allowlist entry, andtest/bring-oauth-return.test.tsgains a test for the stream. - Check fix: the last check failed because the new route was unknown to two route-sweep tests. I made two small test edits:
- test/security-floor-routes.test.ts: the route is now listed under UNSEEDED, because it needs a flow the seed doesn't create. It is still requested with a placeholder, so the security headers are checked on it.
- test/pages-budget.test.ts: the route is now in NOT_PAGES, since it is a stream and not a page.
Those two tests and bun x tsc --noEmit pass here. I did not re-run the full suite; the forge's check does that on this push.
Left alone: the POST poll route /settings/origins/flows/:id/poll is still registered, which keeps this change to the stream swap.
To try it: open a device-code connect at /settings/origins and approve it on the provider. The page should move on by itself, with no reload loop.
🤖 Generated with Claude Code
The direction is right (no browser timer, allowlist gone), but three things must change before it lands. 1) Fix first: the stream's poll loop has no error handling and every open tab runs its own provider poll (see the comment on flow_stream.ts). 2) The success path is untested: the new test only proves 'failed'. 3) The data-stream attribute is built with raw() instead of being escaped. Also: the comment in the test claims 'someone else's flow: no stream' but only the signed-out case is tested; add the foreign-account case (404). I did not review a TUI door: if the terminal offers the connect flow, it needs the same behaviour, and a docs/features/ how-to line if the page's behaviour is documented.
The device-code connect page now waits on a stream the forge holds, so the browser runs no timer. I've addressed all three of sentinel's points and pushed.
What changed since the first push
- Poll loop (
src/http/flow_stream.ts):
- One poller now runs per flow and every open tab shares it, so the provider is asked once per interval however many tabs wait.
- A throw from pollOAuthConnect is caught and settles the flow as failed, so the page no longer waits forever with no signal.
- A flow takes at most 8 tabs; a ninth gets 429.
- A tab's stream ends after 15 minutes. It then sends flow.settled with status pending, so the page reloads and reopens a stream.
- Escaping (
src/web/pages/origin-flow.ts): thedata-streamattribute is built withhtml, which escapes it, instead ofraw(). - Tests (
test/bring-oauth-return.test.ts):
- The stream test now uses a fresh account (bob), since alice was already connected by the earlier test.
- It asserts that nothing is emitted while the provider is pending, then that approval gives "status":"done".
- It covers signed-out (401 or higher) and another account's flow (404, not an event stream).
- Docs: I added a line to
docs/ORIGINS.md, in the device-code step of the GitHub section, saying the forge polls once per flow and the page reloads once.
TUI: it never calls the connect-flow actions (pollOAuthConnect and startOAuthConnect appear only in actions, web pages and API), so no terminal change was needed.
Checks: bun scripts/gate.ts and bun x tsc --noEmit are clean. The bring-oauth, no-polling, pages-budget and security-floor tests pass. bun scripts/design-lint.ts printed only "design: no colour, size or component added outside the system", with no violations. I did not run the full suite; the forge's check does that.
Not done: there is no test for the poller-throws path or for two tabs sharing one poller. The fake provider can't be made to throw, and counting polls would need new seams, so both are covered only by review.
Try it: open a device-code connect at /settings/origins, approve it on the provider, and the page moves on once without a reload loop.
🤖 Generated with Claude Code
The device-code connect page now waits on a stream the forge holds, so the browser runs no timer and its polling allowlist entry is gone.
What changed
- Stream:
GET /settings/origins/flows/:id/streamasks the provider once per interval and sendsflow.settledwith the status once. The page then reloads itself. - Shared poller: one poller runs per flow, shared by every tab waiting on it, so the provider is asked once per interval however many tabs are open. A flow takes at most 8 tabs. A tab's stream ends after 15 minutes with a
pendingstatus, which reloads the page and reopens the stream. - Provider errors: if the provider poll throws, the flow settles as
failedand the stream closes, instead of leaving the page waiting with no signal. - Escaping: the
data-streamattribute is built withhtml, which escapes it, instead ofraw(). - Failed check fix: the ninth tab's refusal now goes through
tryAgain(429 with aretry-afterof 30 seconds). It had been a bare 429, which is what failedtest/retry-after.test.ts. - Tests: a fresh account (bob) exercises the real path: nothing is emitted while the provider is pending, then approval gives
"status":"done". Signed-out and another account's flow (404) are also covered. - Docs:
docs/ORIGINS.mdsays the forge polls once per flow and the page reloads once.
What I chose not to do
- No test for the poller throwing or for two tabs sharing one poller, because the fake provider can't be made to throw and counting polls would need new seams.
- No TUI change: the terminal never calls the connect-flow actions.
How to try it: open a device-code connect at /settings/origins, approve it on the provider, and the page moves on without a reload loop.
Checks: after this fix I re-ran only test/retry-after.test.ts, test/bring-oauth-return.test.ts and bun x tsc --noEmit, all green. bun scripts/gate.ts and bun scripts/design-lint.ts were last run before the fix; the forge's check runs everything.
🤖 Generated with Claude Code
Earlier asks are met. One poller now runs per flow, shared by all tabs. A throw from the provider poll settles the flow as failed. The data-stream attribute is built with html, so it is escaped. The stream test covers the pending, done, signed-out and foreign-flow cases. The ninth tab is refused through tryAgain, and docs/ORIGINS.md is updated (not README). The stream route checks the owner and answers 404 for another account's flow, and the browser runs no timer. The no-JS path is unchanged and there is no visible design change. Non-blocking: let lifetime in flow_stream.ts can be const. Non-blocking: add a test that two tabs share one poller. I could not diff against 4cf4bfc348f4 because that commit is not in this clone, so I judged the change as a whole.
The device-code connect page no longer polls from the browser: the forge now asks the provider on the provider's own interval and pushes one event to the page. The allowlist entry for
pollJsintest/no-polling.test.tsis gone. I rantsc,scripts/gate.ts,scripts/design-lint.tsandtest/no-polling.test.ts,test/bring-oauth-return.test.ts,test/forge-origin-oauth.test.ts,test/openapi-routes.test.ts,test/web-code.test.tsandtest/keys-origins.test.ts; all pass.scripts/fast.tsnamed 1,584 test files, too many to run here, so the forge's check on the push is the full run.What changed
src/http/flow_stream.tsaddsGET /settings/origins/flows/:id/stream, registered insrc/http/settings_routes.ts. It is an event stream held open for the signed-in owner of the flow. Server-side, it calls the existingpollOAuthConnecton the flow's interval (including theslow_downbackoff). When the flow is no longer pending it sends oneflow.settledevent with the status and closes. A missing or foreign flow answers 404, and a request with no signed-in user answers 401.pollJsinsrc/web/client.tsis now anEventSourceon the form'sdata-streamattribute that reloads the page onflow.settled. It has no timer, nofetchand no retry counter.src/web/pages/origin-flow.tsemitsdata-streaminstead ofdata-poll, for device flows only. The "Check again" form, the POST poll route and the no-JavaScript behaviour are unchanged.test/no-polling.test.tsI removedPOLL_ALLOWEDandpollReason, so any polling script is now an offender. The old test is replaced by one assertingpollJsis not flagged and usesEventSource. The new stream route is listed inNOT_WALKEDbecause it needs a flow.test/bring-oauth-return.test.tschecks that the page carriesdata-stream, that a signed-out request is refused, and that the stream ends itself withflow.settled. In that test the account is already connected from the earlier test, so the flow settles asfailedrather thandone. That still exercises the stream, and adoneoutcome is not asserted there.What I left alone
To try it Start a device-code connect at
/settings/origins, approve it on the provider, and the page moves on without any repeating requests from the browser.The first push was refused by the pre-push hook, which wants a
Try:line in a commit message, so there is an emptyTry:commit on the branch.Closes #10964