import { useEffect, useRef, useState } from "react"; import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { serverEnvironment } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; import { Button } from "./ui/button"; import { Spinner } from "./ui/spinner"; import { toastManager } from "./ui/toast"; /** * The npm install on the server side is capped at 10 minutes; expire the * spinner a bit beyond that so a dead transport never strands a disabled * button, while a legitimately slow install is never cut off. */ const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000; function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } /** * The call-to-action for a version-skewed server, matched to the update path * it advertises: a one-click install-and-restart for servers that can update * themselves, an update-the-desktop-app hint for desktop-managed backends * (running `npx t3` there would start a second server, not update this one), * and copying the manual relaunch command for everything else — so the skew * warning always offers a way out. */ export function ServerUpdateAction({ environmentId, serverLabel, selfUpdate, targetVersion, }: { readonly environmentId: EnvironmentId; readonly serverLabel: string; readonly selfUpdate: ServerSelfUpdateCapability | null; readonly targetVersion: string; }) { const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false, }); const [pending, setPending] = useState(false); const inFlightRef = useRef(false); const attemptRef = useRef(0); const expiryRef = useRef | null>(null); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: "update command", onCopy: ({ command }) => { toastManager.add({ type: "success", title: "Update command copied", description: `Run \`${command}\` on ${serverLabel} to update it.`, }); }, onError: (error) => { toastManager.add({ type: "error", title: "Could not copy update command", description: error.message, }); }, }); useEffect( () => () => { if (expiryRef.current !== null) { clearTimeout(expiryRef.current); expiryRef.current = null; } attemptRef.current += 1; inFlightRef.current = false; }, [], ); const handleUpdate = () => { // Synchronous re-entry guard: setPending is async, so a rapid // double-click would otherwise dispatch two updates. if (inFlightRef.current) { return; } inFlightRef.current = true; const attempt = attemptRef.current + 1; attemptRef.current = attempt; const ownsAttempt = () => attemptRef.current === attempt; setPending(true); const armExpiry = () => { const expiry = setTimeout(() => { if (!ownsAttempt()) return; expiryRef.current = null; attemptRef.current += 1; inFlightRef.current = false; setPending(false); toastManager.add({ type: "error", title: "Server update timed out", description: "The update may still be running on the server — check again in a minute.", }); }, UPDATE_PENDING_EXPIRY_MS); expiryRef.current = expiry; return expiry; }; let expiry = armExpiry(); let restartAccepted = false; const keepPendingForRestart = () => { restartAccepted = true; if (expiryRef.current === expiry) { clearTimeout(expiry); expiry = armExpiry(); } }; void Promise.resolve() .then(() => updateServer({ environmentId, input: { targetVersion }, }), ) .then((result) => { if (!ownsAttempt()) return; if (result._tag === "Failure") { // An interrupt may be the expected boot-service disconnect, but it // can also be client-side cancellation before restart was accepted. // Release the action quietly; version sync will remove it when a // successful replacement reconnects. if (isAtomCommandInterrupted(result)) { return; } toastManager.add({ type: "error", title: "Server update failed", description: updateFailureMessage(squashAtomCommandFailure(result)), }); return; } keepPendingForRestart(); // Installation can legitimately consume most of the request window. // Give restart/reconnect a fresh full window after the server accepts // the handoff instead of expiring based on the original click time. toastManager.add({ type: "success", title: `Updating ${serverLabel}`, description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`, }); }) .catch((error: unknown) => { if (!ownsAttempt()) return; toastManager.add({ type: "error", title: "Server update failed", description: updateFailureMessage(error), }); }) .finally(() => { // A successful RPC only acknowledges that restart is scheduled. Keep // the action disabled until version sync unmounts it, or until the // safety expiry reports that reconnection never arrived. if (restartAccepted || !ownsAttempt() || expiryRef.current !== expiry) return; expiryRef.current = null; clearTimeout(expiry); attemptRef.current += 1; inFlightRef.current = false; setPending(false); }); }; if (selfUpdate === "desktop-managed") { return ( Update the desktop app on that machine to update this server. ); } if (selfUpdate === null) { const command = manualServerUpdateCommand(targetVersion); return ( ); } return pending ? ( ) : ( ); }