Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/studio-navigation-speed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudhal/cli": patch
---

Studio switches screens faster: the signed-in session is kept between navigations (asked again after five minutes, on sign-out, on revoking sessions and on any 401) instead of being checked with the server on every click, and the pending screen shows after 150 ms instead of the router's one second, so a short wait no longer looks frozen.
3 changes: 3 additions & 0 deletions apps/studio/src/lib/api/http.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** The fetch core of the Studio API client: `request`, its error type and the URL/body helpers. */
import { forgetSession } from "../session-cache.ts";

export interface ApiError {
error: string;
Expand Down Expand Up @@ -27,6 +28,8 @@ export async function request<T>(path: string, init: RequestInit = {}): Promise<
const body = (await res
.json()
.catch(() => ({ error: "invalid_json", message: res.statusText }))) as T | ApiError;
// The session ended on the server: the next navigation asks again (and lands on the login).
if (res.status === 401) forgetSession();
if (!res.ok) throw new ApiRequestError(res.status, body as ApiError);
return body as T;
}
Expand Down
16 changes: 14 additions & 2 deletions apps/studio/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { adminClient, organizationClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import { forgetSession } from "./session-cache.ts";

/** Same-origin (Vite proxies /api in dev; `cloudhal studio` serves both in prod), so no baseURL. */
export const authClient = createAuthClient({ plugins: [organizationClient(), adminClient()] });
/**
* Same-origin (Vite proxies /api in dev; `cloudhal studio` serves both in prod), so no baseURL.
* Any auth call but the session check itself (sign-in, sign-out, revoking sessions, a new
* password) may change the session, so the one kept between navigations is forgotten.
*/
export const authClient = createAuthClient({
plugins: [organizationClient(), adminClient()],
fetchOptions: {
onResponse: ({ request }) => {
if (!String(request.url).includes("/get-session")) forgetSession();
},
},
});

/** A Better Auth call that failed; `code` is Better Auth's error code (e.g. `INVALID_PASSWORD`). */
export class AuthRequestError extends Error {
Expand Down
38 changes: 38 additions & 0 deletions apps/studio/src/lib/session-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* The signed-in session, kept between navigations. Every protected route checks it in
* `beforeLoad`, and asking the server each time cost 240–640 ms before a screen could start
* (the URL changed, the screen did not). A session that was there a moment ago still is: it is
* reused for `MAX_AGE_MS`, and forgotten on sign-out, on revoking sessions and on any 401 from
* the API, so the next navigation asks again and an expired session still lands on the login.
*/
const MAX_AGE_MS = 5 * 60_000;

let kept: { at: number; session: Promise<unknown> } | undefined;

/**
* The session from `load`, or the one kept from a recent call. Concurrent callers share one
* request; a failed or empty answer is never kept.
*/
export function cachedSession<T>(
load: () => Promise<T | null>,
now = Date.now(),
): Promise<T | null> {
if (kept && now - kept.at < MAX_AGE_MS) return kept.session as Promise<T | null>;
const session = load().then(
(value) => {
if (value == null && kept?.session === session) kept = undefined;
return value;
},
(error: unknown) => {
if (kept?.session === session) kept = undefined;
throw error;
},
);
kept = { at: now, session };
return session;
}

/** Ask the server again on the next navigation (sign-out, revoked sessions, a 401). */
export function forgetSession(): void {
kept = undefined;
}
16 changes: 14 additions & 2 deletions apps/studio/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { type AnalyticsSearch, validateAnalyticsSearch } from "./lib/analytics-s
import { api } from "./lib/api.ts";
import { authClient } from "./lib/auth.ts";
import { isQueueTab, type QueueTab } from "./lib/comments-shell.ts";
import { cachedSession } from "./lib/session-cache.ts";
import { AccountShell } from "./routes/AccountShell.tsx";
import { SiteShell } from "./routes/SiteShell.tsx";

Expand Down Expand Up @@ -153,11 +154,18 @@ export interface RouterContext {
}

async function requireSession() {
const { data, error } = await authClient.getSession();
// Kept between navigations (lib/session-cache.ts): asking each time held every screen back.
const last: { status?: number } = {};
const data = await cachedSession(async () => {
const r = await authClient.getSession();
if (r.error) last.status = r.error.status;
return r.data;
});
if (data) return data;
// RFC-0036 §5: a playground has no sign-in (its API refuses /api/auth with 403) and answers
// every call as its one fixed principal.
if (error?.status === 403 && (await isPlayground())) return null;
// (A caller that shared another's request has no status of its own: it asks too.)
if ((last.status ?? 403) === 403 && (await isPlayground())) return null;
throw redirect({ to: "/login" });
}

Expand Down Expand Up @@ -498,6 +506,10 @@ export function createStudioRouter(queryClient: QueryClient) {
routeTree,
context: { queryClient },
defaultPreload: "intent",
// The router's own default waits a second before showing the pending screen, so a short wait
// looked frozen: the URL had changed and nothing else had.
defaultPendingMs: 150,
defaultPendingMinMs: 300,
defaultPendingComponent: RoutePending,
defaultErrorComponent: RouteError,
});
Expand Down
33 changes: 33 additions & 0 deletions apps/studio/test/session-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import { cachedSession, forgetSession } from "../src/lib/session-cache.ts";

describe("cachedSession (a navigation does not wait for the server's session check)", () => {
afterEach(() => forgetSession());

it("asks once, shares a request in flight, and reuses the answer for five minutes", async () => {
let calls = 0;
const load = async () => {
calls++;
return { user: "ada" };
};
const [a, b] = await Promise.all([cachedSession(load, 0), cachedSession(load, 0)]);
expect(a).toEqual({ user: "ada" });
expect(b).toBe(a);
await cachedSession(load, 4 * 60_000);
expect(calls).toBe(1);
await cachedSession(load, 6 * 60_000);
expect(calls).toBe(2);
});

it("never keeps an empty or failed answer, and forgets on request", async () => {
let calls = 0;
expect(await cachedSession(async () => (calls++, null), 0)).toBeNull();
await expect(
cachedSession(async () => (calls++, Promise.reject(new Error("x"))), 0),
).rejects.toThrow();
await cachedSession(async () => (calls++, { user: "ada" }), 0);
forgetSession();
await cachedSession(async () => (calls++, { user: "ada" }), 0);
expect(calls).toBe(4);
});
});
Loading