diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts index 855d03490ef..ab3037080bd 100644 --- a/apps/server/src/serverLifecycleEvents.ts +++ b/apps/server/src/serverLifecycleEvents.ts @@ -8,7 +8,8 @@ import * as Stream from "effect/Stream"; type LifecycleEventInput = | Omit, "sequence"> - | Omit, "sequence">; + | Omit, "sequence"> + | Omit, "sequence">; interface SnapshotState { readonly sequence: number; @@ -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), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 859756d7dd5..907cc381490 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -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"; @@ -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; diff --git a/apps/server/src/webVersionWatcher.ts b/apps/server/src/webVersionWatcher.ts new file mode 100644 index 00000000000..ff29e248e13 --- /dev/null +++ b/apps/server/src/webVersionWatcher.ts @@ -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); + }); diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index d6d7434769e..dda0f6ffba8 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -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"; @@ -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); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index b1fd21f84fa..1913fd2e11b 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -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"; @@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { + ); } diff --git a/apps/web/src/components/WebUpdateBanner.test.ts b/apps/web/src/components/WebUpdateBanner.test.ts new file mode 100644 index 00000000000..7e30300a970 --- /dev/null +++ b/apps/web/src/components/WebUpdateBanner.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/components/WebUpdateBanner.tsx b/apps/web/src/components/WebUpdateBanner.tsx new file mode 100644 index 00000000000..9f42261d9d5 --- /dev/null +++ b/apps/web/src/components/WebUpdateBanner.tsx @@ -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(null); + if (latest !== null && bootVersionRef.current === null) { + bootVersionRef.current = latest; + } + + if (!isWebUpdateAvailable(bootVersionRef.current, latest)) { + return null; + } + + return ( +
+ A new version of T3 Code is available. + +
+ ); +} diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 3271eefd1e1..0386c931220 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -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 => get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 12925a99867..5f403e32935 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -29,6 +29,7 @@ import { applyServerConfigProjection, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, + projectServerWebVersion, projectServerWelcome, resolveServerConfigValue, resolveServerUpdateProgressResult, @@ -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) => ({ diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 06dce3cddae..874c28deaf3 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -347,7 +347,7 @@ export function serverConfigStateChanges(environmentId: EnvironmentId) { export function projectServerWelcome( current: Option.Option, event: { - readonly type: "welcome" | "ready"; + readonly type: "welcome" | "ready" | "webVersionChanged"; readonly payload: unknown; }, ): readonly [ @@ -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, + event: { + readonly type: "welcome" | "ready" | "webVersionChanged"; + readonly payload: unknown; + }, +): readonly [Option.Option, ReadonlyArray] { + 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, @@ -643,6 +662,12 @@ export function createServerEnvironmentAtoms( Stream.mapAccum(Option.none, projectServerWelcome), ), }), + webVersion: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:web-version", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => + stream.pipe(Stream.mapAccum(Option.none, projectServerWebVersion)), + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index cff26e0a3ba..fcefb1bbfcf 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -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;