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 @@
1import { afterEach, describe, expect, it, vi } from "@effect/vitest";
2
3import { SetupStatusError, fetchNeedsSetup } from "../web/setup-status";
4
5afterEach(() => {
6 vi.unstubAllGlobals();
7});
8
9describe("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
6import { OrganizationProvider } from "@executor-js/react/api/organization-context";6import { OrganizationProvider } from "@executor-js/react/api/organization-context";
7import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate";7import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate";
8import { Toaster } from "@executor-js/react/components/sonner";8import { Toaster } from "@executor-js/react/components/sonner";
9import { Button } from "@executor-js/react/components/button";
10import {
11 Card,
12 CardContent,
13 CardDescription,
14 CardHeader,
15 CardTitle,
16} from "@executor-js/react/components/card";
9import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";17import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";
10import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";18import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
11import { plugins as clientPlugins } from "virtual:executor/plugins-client";19import { plugins as clientPlugins } from "virtual:executor/plugins-client";
@@ -48,26 +56,72 @@ const Loading = () => ( · 36 unmodified lines
48 </div>56 </div>
49);57);
50 58
59const 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
51function AuthGate({ children }: { children: ReactNode }) {78function AuthGate({ children }: { children: ReactNode }) {
52 const auth = useAuth();79 const auth = useAuth();
53 // When unauthenticated, decide between first-run setup and sign-in by asking80 // When unauthenticated, decide between first-run setup and sign-in by asking
54 // the server whether the instance still has zero members. `null` = checking.81 // the server whether the instance still has zero members.
55 const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);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 });
56 useEffect(() => {87 useEffect(() => {
57 if (auth.status !== "unauthenticated") return;88 if (auth.status !== "unauthenticated") return;
58 let alive = true;89 let alive = true;
59 void fetchNeedsSetup().then((value) => {90 setSetupStatus((current) => ({ state: "checking", attempt: current.attempt }));
60 if (alive) setNeedsSetup(value);91 void fetchNeedsSetup().then(
61 });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 );
62 return () => {104 return () => {
63 alive = false;105 alive = false;
64 };106 };
65 }, [auth.status]);107 }, [auth.status, setupStatus.attempt]);
66 108
67 if (auth.status === "loading") return <Loading />;109 if (auth.status === "loading") return <Loading />;
68 if (auth.status === "unauthenticated") {110 if (auth.status === "unauthenticated") {
69 if (needsSetup === null) return <Loading />;111 if (setupStatus.state === "checking") return <Loading />;
70 return needsSetup ? <SetupPage /> : <LoginPage />;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 />;
71 }125 }
72 return <>{children}</>;126 return <>{children}</>;
73}127}
apps/host-selfhost/web/setup-status.ts+29−12
@@ -1,17 +1,34 @@
1// Pre-login check of whether the instance still needs first-run setup (its one1// Pre-login check of whether the instance still needs first-run setup (its one
2// org has zero members). Read by the auth gate to choose the setup vs sign-in2// org has zero members). Read by the auth gate to choose the setup vs sign-in
3// screen. A plain same-origin fetch — the same boundary the /join + setup3// 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` keeps4// screens use, which run before the atom registry exists.
5// it Promise.catch-free; any failure falls back to "no setup needed" (sign-in).5
6const retryDelaysMs = [250, 500, 1_000] as const;
7
8const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
9
10export class SetupStatusError extends Error {
11 constructor() {
12 super("Unable to check setup status");
13 this.name = "SetupStatusError";
14 }
15}
16
6export const fetchNeedsSetup = async (): Promise<boolean> => {17export const fetchNeedsSetup = async (): Promise<boolean> => {
7 const response = await fetch("/api/setup-status", { credentials: "same-origin" }).then(18 for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) {
8 (r) => r,19 const response = await fetch("/api/setup-status", { credentials: "same-origin" }).then(
9 () => null,20 (r) => r,
10 );21 () => null,
11 if (!response || !response.ok) return false;22 );
12 const data = (await response.json().then(23 if (response?.ok) {
13 (d) => d,24 const data = (await response.json().then(
14 () => ({}),25 (d) => d,
15 )) as { needsSetup?: boolean };26 () => ({}),
16 return data.needsSetup === true;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();
17};34};