History

Retry setup-status checks before showing auth (#1260)

Rhys Sullivan committed 4 months ago

Commit 9a1856e1 parent 76fcb1c3 files · +124 −20Browse files

Unified · Split

3 files
  1. apps/host-selfhost/src/setup-status.web.test.ts +33 −0
  2. apps/host-selfhost/web/routes/__root.tsx +62 −8
  3. apps/host-selfhost/web/setup-status.ts +29 −12
apps/host-selfhost/src/setup-status.web.test.ts+33−0
@@ -0,0 +1,33 @@
1+import { afterEach, describe, expect, it, vi } from "@effect/vitest";
2+
3+import { SetupStatusError, fetchNeedsSetup } from "../web/setup-status";
4+
5+afterEach(() => {
6+ vi.unstubAllGlobals();
7+});
8+
9+describe("fetchNeedsSetup", () => {
10+ it("returns false when the server says setup is complete", async () => {
11+ vi.stubGlobal(
12+ "fetch",
13+ async () =>
14+ new Response(JSON.stringify({ needsSetup: false }), {
15+ status: 200,
16+ headers: { "content-type": "application/json" },
17+ }),
18+ );
19+
20+ await expect(fetchNeedsSetup()).resolves.toBe(false);
21+ });
22+
23+ it("retries failed setup checks before surfacing an error", async () => {
24+ let calls = 0;
25+ vi.stubGlobal("fetch", async () => {
26+ calls += 1;
27+ return new Response("unavailable", { status: 503 });
28+ });
29+
30+ await expect(fetchNeedsSetup()).rejects.toBeInstanceOf(SetupStatusError);
31+ expect(calls).toBe(3);
32+ });
33+});
apps/host-selfhost/web/routes/__root.tsx+62−8
@@ -6,6 +6,14 @@ import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; · 5 unmodified lines
66 import { OrganizationProvider } from "@executor-js/react/api/organization-context";
77 import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate";
88 import { Toaster } from "@executor-js/react/components/sonner";
9+import { Button } from "@executor-js/react/components/button";
10+import {
11+ Card,
12+ CardContent,
13+ CardDescription,
14+ CardHeader,
15+ CardTitle,
16+} from "@executor-js/react/components/card";
917 import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";
1018 import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
1119 import { plugins as clientPlugins } from "virtual:executor/plugins-client";
@@ -48,26 +56,72 @@ const Loading = () => ( · 36 unmodified lines
4856 </div>
4957 );
5058
59+const SetupStatusErrorCard = ({ onRetry }: { onRetry: () => void }) => (
60+ <div className="flex min-h-screen items-center justify-center bg-background p-6">
61+ <Card className="w-full max-w-md">
62+ <CardHeader>
63+ <CardTitle>Can't reach the server</CardTitle>
64+ <CardDescription>
65+ Executor couldn't check this instance's setup state. Make sure the server is running, then
66+ retry.
67+ </CardDescription>
68+ </CardHeader>
69+ <CardContent>
70+ <Button type="button" onClick={onRetry}>
71+ Retry
72+ </Button>
73+ </CardContent>
74+ </Card>
75+ </div>
76+);
77+
5178 function AuthGate({ children }: { children: ReactNode }) {
5279 const auth = useAuth();
5380 // When unauthenticated, decide between first-run setup and sign-in by asking
54− // the server whether the instance still has zero members. `null` = checking.
55− const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
81+ // the server whether the instance still has zero members.
82+ const [setupStatus, setSetupStatus] = useState<
83+ | { state: "checking"; attempt: number }
84+ | { state: "ready"; needsSetup: boolean }
85+ | { state: "error"; attempt: number }
86+ >({ state: "checking", attempt: 0 });
5687 useEffect(() => {
5788 if (auth.status !== "unauthenticated") return;
5889 let alive = true;
59− void fetchNeedsSetup().then((value) => {
60− if (alive) setNeedsSetup(value);
61− });
90+ setSetupStatus((current) => ({ state: "checking", attempt: current.attempt }));
91+ void fetchNeedsSetup().then(
92+ (value) => {
93+ if (alive) setSetupStatus({ state: "ready", needsSetup: value });
94+ },
95+ () => {
96+ if (alive) {
97+ setSetupStatus((current) => ({
98+ state: "error",
99+ attempt: current.state === "ready" ? 0 : current.attempt,
100+ }));
101+ }
102+ },
103+ );
62104 return () => {
63105 alive = false;
64106 };
65− }, [auth.status]);
107+ }, [auth.status, setupStatus.attempt]);
66108
67109 if (auth.status === "loading") return <Loading />;
68110 if (auth.status === "unauthenticated") {
69− if (needsSetup === null) return <Loading />;
70− return needsSetup ? <SetupPage /> : <LoginPage />;
111+ if (setupStatus.state === "checking") return <Loading />;
112+ if (setupStatus.state === "error") {
113+ return (
114+ <SetupStatusErrorCard
115+ onRetry={() =>
116+ setSetupStatus((current) => ({
117+ state: "checking",
118+ attempt: current.state === "ready" ? 0 : current.attempt + 1,
119+ }))
120+ }
121+ />
122+ );
123+ }
124+ return setupStatus.needsSetup ? <SetupPage /> : <LoginPage />;
71125 }
72126 return <>{children}</>;
73127 }
apps/host-selfhost/web/setup-status.ts+29−12
@@ -1,17 +1,34 @@
11 // Pre-login check of whether the instance still needs first-run setup (its one
22 // org has zero members). Read by the auth gate to choose the setup vs sign-in
33 // screen. A plain same-origin fetch — the same boundary the /join + setup
4−// screens use, which run before the atom registry exists. Two-arg `then` keeps
5−// it Promise.catch-free; any failure falls back to "no setup needed" (sign-in).
4+// screens use, which run before the atom registry exists.
5+
6+const retryDelaysMs = [250, 500, 1_000] as const;
7+
8+const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
9+
10+export class SetupStatusError extends Error {
11+ constructor() {
12+ super("Unable to check setup status");
13+ this.name = "SetupStatusError";
14+ }
15+}
16+
617 export const fetchNeedsSetup = async (): Promise<boolean> => {
7− const response = await fetch("/api/setup-status", { credentials: "same-origin" }).then(
8− (r) => r,
9− () => null,
10− );
11− if (!response || !response.ok) return false;
12− const data = (await response.json().then(
13− (d) => d,
14− () => ({}),
15− )) as { needsSetup?: boolean };
16− return data.needsSetup === true;
18+ for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) {
19+ const response = await fetch("/api/setup-status", { credentials: "same-origin" }).then(
20+ (r) => r,
21+ () => null,
22+ );
23+ if (response?.ok) {
24+ const data = (await response.json().then(
25+ (d) => d,
26+ () => ({}),
27+ )) as { needsSetup?: boolean };
28+ return data.needsSetup === true;
29+ }
30+ if (attempt < retryDelaysMs.length - 1) await sleep(retryDelaysMs[attempt]);
31+ }
32+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: pre-Effect setup-status fetch rejects so the auth gate can surface retry failure
33+ throw new SetupStatusError();
1734 };