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
13 changes: 8 additions & 5 deletions apps/server/src/serverLifecycleEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import * as Stream from "effect/Stream";

type LifecycleEventInput =
| Omit<Extract<ServerLifecycleStreamEvent, { type: "welcome" }>, "sequence">
| Omit<Extract<ServerLifecycleStreamEvent, { type: "ready" }>, "sequence">;
| Omit<Extract<ServerLifecycleStreamEvent, { type: "ready" }>, "sequence">
| Omit<Extract<ServerLifecycleStreamEvent, { type: "webVersionChanged" }>, "sequence">;

interface SnapshotState {
readonly sequence: number;
Expand Down Expand Up @@ -39,10 +40,12 @@ const make = Effect.gen(function* () {
...event,
sequence: nextSequence,
} satisfies ServerLifecycleStreamEvent;
const nextEvents =
nextEvent.type === "welcome"
? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")]
: [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")];
// Keep only the latest event of each type in the replay snapshot, so a
// connecting client sees the current welcome, ready, and web version.
const nextEvents = [
nextEvent,
...current.events.filter((entry) => entry.type !== nextEvent.type),
];
return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const;
}).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))),
snapshot: Ref.get(state),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngi
import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts";
import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
import { runWebVersionWatcher } from "./webVersionWatcher.ts";
import * as ServerSettings from "./serverSettings.ts";
import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
Expand Down Expand Up @@ -333,6 +334,9 @@ export const make = Effect.gen(function* () {
const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper;
const orphanSessionRecovery = yield* OrphanSessionRecovery.OrphanSessionRecovery;
const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
// Broadcast when the served web bundle is hot-swapped on disk so clients can
// offer a reload without a server restart.
yield* Effect.forkScoped(runWebVersionWatcher(lifecycleEvents));
const serverSettings = yield* ServerSettings.ServerSettingsService;
const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
const crypto = yield* Crypto.Crypto;
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/webVersionWatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeCrypto from "node:crypto";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Ref from "effect/Ref";

import { resolveStaticDir } from "./config.ts";
import type { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";

/** How often to re-read the served index.html to detect an on-disk asset swap. */
const POLL_INTERVAL = Duration.seconds(10);

/**
* Identity of the currently served web bundle: a hash of index.html. Returns
* null when no static bundle is present (e.g. the dev server proxies Vite
* instead of serving files), in which case there is nothing to watch.
*/
const readWebVersion = (staticDir: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const data = yield* fs
.readFile(path.join(staticDir, "index.html"))
.pipe(Effect.orElseSucceed(() => null));
return data === null ? null : NodeCrypto.createHash("sha1").update(data).digest("hex");
});

/**
* Broadcasts a `webVersionChanged` lifecycle event whenever the served
* index.html changes on disk, so connected clients learn that the web bundle
* was hot-swapped underneath them and can offer a reload. The current version
* is published once at startup to seed the lifecycle snapshot, so a client
* that connects later learns the version it is actually running. No-op when
* there is no packaged bundle to serve.
*
* Intended to be run with `Effect.forkScoped`; the poll loop never returns.
*/
export const runWebVersionWatcher = (lifecycleEvents: ServerLifecycleEvents["Service"]) =>
Effect.gen(function* () {
const staticDir = yield* resolveStaticDir();
if (staticDir === undefined) {
return;
}
const initial = yield* readWebVersion(staticDir);
const lastRef = yield* Ref.make(initial);
if (initial !== null) {
yield* lifecycleEvents.publish({
version: 1,
type: "webVersionChanged",
payload: { webVersion: initial },
});
}
return yield* Effect.gen(function* () {
const current = yield* readWebVersion(staticDir);
const last = yield* Ref.get(lastRef);
if (current !== null && current !== last) {
yield* Ref.set(lastRef, current);
yield* lifecycleEvents.publish({
version: 1,
type: "webVersionChanged",
payload: { webVersion: current },
});
}
}).pipe(Effect.delay(POLL_INTERVAL), Effect.forever);
});
4 changes: 3 additions & 1 deletion apps/web/src/AppRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { RouterProvider } from "@tanstack/react-router";
import { describe, expect, it } from "vite-plus/test";

import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { WebUpdateBanner } from "./components/WebUpdateBanner";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
Expand All @@ -16,9 +17,10 @@ describe("AppRoot", () => {
const children = Children.toArray(
(root as ReactElement<{ readonly children: ReactNode }>).props.children,
);
expect(children).toHaveLength(3);
expect(children).toHaveLength(4);
expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);
expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts);
expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost);
expect(isValidElement(children[3]) && children[3].type).toBe(WebUpdateBanner);
});
});
2 changes: 2 additions & 0 deletions apps/web/src/AppRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router";

import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { WebUpdateBanner } from "./components/WebUpdateBanner";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";

Expand All @@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
<RouterProvider router={router} />
<PreviewAutomationHosts />
<ElectronBrowserHost />
<WebUpdateBanner />
</AppAtomRegistryProvider>
);
}
19 changes: 19 additions & 0 deletions apps/web/src/components/WebUpdateBanner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vite-plus/test";

import { isWebUpdateAvailable } from "./WebUpdateBanner";

describe("isWebUpdateAvailable", () => {
it("is false until both a boot and a latest version are known", () => {
expect(isWebUpdateAvailable(null, null)).toBe(false);
expect(isWebUpdateAvailable(null, "v1")).toBe(false);
expect(isWebUpdateAvailable("v1", null)).toBe(false);
});

it("is false while the served version matches what the tab booted with", () => {
expect(isWebUpdateAvailable("v1", "v1")).toBe(false);
});

it("is true once the server serves a different bundle than the tab booted with", () => {
expect(isWebUpdateAvailable("v1", "v2")).toBe(true);
});
});
53 changes: 53 additions & 0 deletions apps/web/src/components/WebUpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useAtomValue } from "@effect/atom-react";
import { useRef } from "react";

import { primaryServerWebVersionAtom } from "../state/server";

/**
* Whether a newer web bundle is being served than the one this tab booted with.
* `boot` is the version observed first (what the running code was loaded from);
* `latest` is the most recent version the server has reported. Pure so the
* decision can be unit-tested without a DOM.
*/
export function isWebUpdateAvailable(boot: string | null, latest: string | null): boolean {
return boot !== null && latest !== null && boot !== latest;
}

/**
* Unobtrusive "a new version is available" affordance. The server broadcasts its
* served web-bundle version over the lifecycle stream; the first value this tab
* sees is the version it booted with, so any later, different value means the
* assets were hot-swapped on the server. We never reload automatically -- the
* user reloads when convenient. (vitePreloadRecovery still auto-reloads on a
* genuinely missing chunk, so nothing breaks if the user ignores this.)
*/
export function WebUpdateBanner() {
const latest = useAtomValue(primaryServerWebVersionAtom);
const bootVersionRef = useRef<string | null>(null);
if (latest !== null && bootVersionRef.current === null) {
bootVersionRef.current = latest;
}

if (!isWebUpdateAvailable(bootVersionRef.current, latest)) {
return null;
}

return (
<div
data-testid="web-update-banner"
className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-full border border-border bg-background/95 px-4 py-2 text-sm shadow-lg backdrop-blur"
role="status"
>
<span className="text-muted-foreground">A new version of T3 Code is available.</span>
<button
type="button"
className="rounded-full bg-primary px-3 py-1 font-medium text-primary-foreground hover:opacity-90"
onClick={() => {
window.location.reload();
}}
>
Reload
</button>
</div>
);
}
14 changes: 14 additions & 0 deletions apps/web/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@ export const primaryServerSettingsAtom = Atom.make(
(get): ServerSettings => get(primaryServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS,
).pipe(Atom.withLabel("web-primary-server-settings"));

/**
* Latest served web-bundle version reported by the primary server, or null
* before the first lifecycle event. The web update banner compares this against
* the version present when the client booted.
*/
export const primaryServerWebVersionAtom = Atom.make((get): string | null => {
const environmentId = get(primaryEnvironmentIdAtom);
if (environmentId === null) {
return null;
}
const target = { environmentId, input: {} };
return Option.getOrNull(AsyncResult.value(get(serverEnvironment.webVersion(target))));
}).pipe(Atom.withLabel("web-primary-server-web-version"));

export const primaryServerProvidersAtom = Atom.make(
(get): ReadonlyArray<ServerProvider> =>
get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS,
Expand Down
25 changes: 25 additions & 0 deletions packages/client-runtime/src/state/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
applyServerConfigProjection,
makeEnvironmentServerConfigState,
isLegacyUpdateHandoffLoss,
projectServerWebVersion,
projectServerWelcome,
resolveServerConfigValue,
resolveServerUpdateProgressResult,
Expand Down Expand Up @@ -210,6 +211,30 @@ describe("server state projection", () => {
expect(emitted).toEqual([]);
});

it("tracks the latest web version and ignores non-web lifecycle events", () => {
const [seeded, seededEmit] = projectServerWebVersion(Option.none(), {
type: "webVersionChanged",
payload: { webVersion: "v1" },
});
expect(Option.getOrThrow(seeded)).toBe("v1");
expect(seededEmit).toEqual(["v1"]);

// A welcome in the same stream must not clear or emit a web version.
const [afterWelcome, welcomeEmit] = projectServerWebVersion(seeded, {
type: "welcome",
payload: {},
});
expect(Option.getOrThrow(afterWelcome)).toBe("v1");
expect(welcomeEmit).toEqual([]);

const [afterSwap, swapEmit] = projectServerWebVersion(afterWelcome, {
type: "webVersionChanged",
payload: { webVersion: "v2" },
});
expect(Option.getOrThrow(afterSwap)).toBe("v2");
expect(swapEmit).toEqual(["v2"]);
});

it("prefers an active session config over cache until a live event arrives", () => {
const config = (source: string, serverVersion: string) =>
({
Expand Down
27 changes: 26 additions & 1 deletion packages/client-runtime/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ export function serverConfigStateChanges(environmentId: EnvironmentId) {
export function projectServerWelcome(
current: Option.Option<ServerLifecycleWelcomePayload>,
event: {
readonly type: "welcome" | "ready";
readonly type: "welcome" | "ready" | "webVersionChanged";
readonly payload: unknown;
},
): readonly [
Expand All @@ -361,6 +361,25 @@ export function projectServerWelcome(
return [Option.some(welcome), [welcome]];
}

/**
* Accumulates the latest served web-bundle version from the lifecycle stream.
* The first value a client observes is the version it is running; a later,
* different value means the server hot-swapped its web assets.
*/
export function projectServerWebVersion(
current: Option.Option<string>,
event: {
readonly type: "welcome" | "ready" | "webVersionChanged";
readonly payload: unknown;
},
): readonly [Option.Option<string>, ReadonlyArray<string>] {
if (event.type !== "webVersionChanged") {
return [current, []];
}
const { webVersion } = event.payload as { readonly webVersion: string };
return [Option.some(webVersion), [webVersion]];
}

export function resolveServerConfigValue(
projection: ServerConfigProjection | null,
initialConfig: ServerConfig | null,
Expand Down Expand Up @@ -643,6 +662,12 @@ export function createServerEnvironmentAtoms<R, E>(
Stream.mapAccum(Option.none<ServerLifecycleWelcomePayload>, projectServerWelcome),
),
}),
webVersion: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
label: "environment-data:server:web-version",
tag: WS_METHODS.subscribeServerLifecycle,
transform: (stream) =>
stream.pipe(Stream.mapAccum(Option.none<string>, projectServerWebVersion)),
}),
refreshProviders: createEnvironmentRpcCommand(runtime, {
label: "environment-data:server:refresh-providers",
tag: WS_METHODS.serverRefreshProviders,
Expand Down
18 changes: 18 additions & 0 deletions packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,9 +552,27 @@ export const ServerLifecycleStreamReadyEvent = Schema.Struct({
});
export type ServerLifecycleStreamReadyEvent = typeof ServerLifecycleStreamReadyEvent.Type;

export const ServerLifecycleWebVersionPayload = Schema.Struct({
// Identity of the served web bundle (a hash of index.html). Clients compare
// the value they booted with against later broadcasts to detect that the
// server's static assets were hot-swapped underneath them.
webVersion: TrimmedNonEmptyString,
});
export type ServerLifecycleWebVersionPayload = typeof ServerLifecycleWebVersionPayload.Type;

export const ServerLifecycleStreamWebVersionChangedEvent = Schema.Struct({
version: Schema.Literal(1),
sequence: NonNegativeInt,
type: Schema.Literal("webVersionChanged"),
payload: ServerLifecycleWebVersionPayload,
});
export type ServerLifecycleStreamWebVersionChangedEvent =
typeof ServerLifecycleStreamWebVersionChangedEvent.Type;

export const ServerLifecycleStreamEvent = Schema.Union([
ServerLifecycleStreamWelcomeEvent,
ServerLifecycleStreamReadyEvent,
ServerLifecycleStreamWebVersionChangedEvent,
]);
export type ServerLifecycleStreamEvent = typeof ServerLifecycleStreamEvent.Type;

Expand Down
Loading