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
14 changes: 14 additions & 0 deletions apps/mesh/src/api/routes/public-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ import { isLocalMode } from "@/auth/local-mode";
import { getInternalUrl } from "@/core/server-constants";
import { getSettings } from "@/settings";
import { buildAuthConfig, type AuthConfig } from "@/api/routes/auth";
import pkg from "../../../package.json" with { type: "json" };

const app = new Hono();

/**
* Public configuration exposed to the UI
*/
export type PublicConfig = {
/**
* Deployed server version (apps/mesh/package.json). Compared against the
* client's build-time `__MESH_VERSION__` to detect a stale bundle.
*/
version: string;
/**
* Theme customization for light and dark modes.
* Contains CSS variable overrides that will be injected into the document.
Expand Down Expand Up @@ -94,6 +100,7 @@ function buildPosthogConfig(): PublicConfig["posthog"] {
*/
app.get("/", (c) => {
const config: PublicConfig = {
version: pkg.version,
theme: getThemeConfig(),
...(getConfig().logo && { logo: getConfig().logo }),
// Only expose internalUrl in local mode — production uses the public URL directly
Expand All @@ -111,6 +118,13 @@ app.get("/", (c) => {
},
};

// No explicit Cache-Control previously meant browser/intermediary caching
// was left to default heuristics. version-check-dialog.tsx polls this on
// a timer specifically to detect drift — a cached response would return
// the same stale value forever, defeating the poll and (via a hard
// refresh clearing that cache) making a stuck dialog look like it only
// "fixes itself" after Cmd+Shift+R.
c.header("Cache-Control", "no-store");
return c.json({ success: true, config });
});

Expand Down
41 changes: 41 additions & 0 deletions apps/mesh/src/web/components/version-check-dialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { nextDrift } from "./version-check-dialog";

const NONE = { version: null, count: 0 };

describe("nextDrift", () => {
test("resets when server version matches client", () => {
expect(nextDrift({ version: "4.0.0", count: 1 }, "4.1.0", "4.1.0")).toEqual(
NONE,
);
});

test("resets when server version is missing", () => {
expect(
nextDrift({ version: "4.0.0", count: 1 }, undefined, "4.0.0"),
).toEqual(NONE);
});

test("first drift starts a count of 1", () => {
expect(nextDrift(NONE, "4.1.0", "4.0.0")).toEqual({
version: "4.1.0",
count: 1,
});
});

test("same drift version again increments the count", () => {
const first = nextDrift(NONE, "4.1.0", "4.0.0");
expect(nextDrift(first, "4.1.0", "4.0.0")).toEqual({
version: "4.1.0",
count: 2,
});
});

test("a different drift version restarts the count at 1", () => {
const first = nextDrift(NONE, "4.1.0", "4.0.0");
expect(nextDrift(first, "4.2.0", "4.0.0")).toEqual({
version: "4.2.0",
count: 1,
});
});
});
95 changes: 95 additions & 0 deletions apps/mesh/src/web/components/version-check-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@deco/ui/components/alert-dialog.tsx";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { PublicConfig } from "@/api/routes/public-config";
import { KEYS } from "@/web/lib/query-keys";

const POLL_INTERVAL_MS = 5 * 60 * 1000;

// A rolling deploy always has old and new pods answering simultaneously for a
// stretch (inherent to maxSurge/maxUnavailable, not fully eliminable by the
// LB or nginx alone) — this app redeploys ~12x/day, so it's often mid-rollout.
// A single poll can catch that transient window and misreport drift; the
// user could then be stuck refreshing into a still-old pod until the rollout
// actually finishes, with no way to tell the difference. Requiring the SAME
// newer version on two consecutive polls (~5-10 min apart) means we only nag
// once the deploy has actually settled everywhere.
const CONFIRMATIONS_REQUIRED = 2;

type Drift = { version: string | null; count: number };

export function nextDrift(
prev: Drift,
serverVersion: string | undefined,
clientVersion: string,
): Drift {
if (!serverVersion || serverVersion === clientVersion) {
return { version: null, count: 0 };
}
return prev.version === serverVersion
? { version: serverVersion, count: prev.count + 1 }
: { version: serverVersion, count: 1 };
}

/**
* Polls /api/config on its own (short-lived, unlike the Infinity-staleTime
* publicConfig query used for boot) and prompts a refresh once the deployed
* server version drifts — consistently, across repeated polls — from this
* bundle's build-time __MESH_VERSION__.
*/
export function VersionCheckDialog() {
const { data: serverVersion, dataUpdatedAt } = useQuery({
queryKey: KEYS.appVersionCheck(),
queryFn: async () => {
// The server sends Cache-Control: no-store, but force it client-side
// too — this poll is worthless if any layer serves a cached response.
const response = await fetch("/api/config", { cache: "no-store" });
const { config }: { config: PublicConfig } = await response.json();
return config.version;
},
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: true,
staleTime: 0,
});

// `data` alone can stay referentially/value-equal across polls (same
// version reported twice), which wouldn't re-render under tracked-property
// optimization — dataUpdatedAt changes on every settled fetch, so tracking
// it here is what lets us actually see each poll, not just each change.
const [lastCheckedAt, setLastCheckedAt] = useState(0);
const [drift, setDrift] = useState<Drift>({ version: null, count: 0 });

if (dataUpdatedAt !== lastCheckedAt) {
setLastCheckedAt(dataUpdatedAt);
setDrift((prev) => nextDrift(prev, serverVersion, __MESH_VERSION__));
}

const isStale = drift.count >= CONFIRMATIONS_REQUIRED;

return (
<AlertDialog open={isStale}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>A new version is available</AlertDialogTitle>
<AlertDialogDescription>
You're viewing an outdated version of this page. Refresh to get the
latest updates.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => window.location.reload()}>
Refresh
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
3 changes: 3 additions & 0 deletions apps/mesh/src/web/layouts/shell-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { OrgAccessGate } from "@/web/components/org-access-gate";
import { SplashScreen } from "@/web/components/splash-screen";
import { SidebarReleaseCard } from "@/web/components/release-channel/sidebar-release-card";
import { KeyboardShortcutsDialog } from "@/web/components/keyboard-shortcuts-dialog";
import { VersionCheckDialog } from "@/web/components/version-check-dialog";
import { isModKey } from "@/web/lib/keyboard-shortcuts";
import RequiredAuthLayout from "@/web/layouts/required-auth-layout";
import { authClient } from "@/web/lib/auth-client";
Expand Down Expand Up @@ -375,6 +376,8 @@ function ShellLayoutContent() {
open={shortcutsDialogOpen}
onOpenChange={setShortcutsDialogOpen}
/>

<VersionCheckDialog />
</ShellProjectProvider>
);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mesh/src/web/lib/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export const KEYS = {
// Public config (no auth required)
publicConfig: () => ["publicConfig"] as const,

// Polled separately from publicConfig (which is cached forever) to detect
// a deployed version newer than the client's own build.
appVersionCheck: () => ["appVersionCheck"] as const,

// Auth-related queries
session: () => ["session"] as const,

Expand Down
Loading