Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03c91dfb0b | |||
| 83f792ca39 | |||
| 1e4925007d |
@@ -463,6 +463,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Open tabs pick up a new deploy on their own.** After the server is
|
||||||
|
redeployed while a tab is left open for hours, the tab now learns the new
|
||||||
|
build version over the existing WebSocket (announced per-connect, so a natural
|
||||||
|
reconnect delivers it) and shows a "A new version is available" banner with an
|
||||||
|
Update button. To avoid dropping a half-written comment or form, the tab is
|
||||||
|
not reloaded when you merely switch away from it; instead it auto-reloads at
|
||||||
|
the next safe point — the next in-app navigation (or immediately if you click
|
||||||
|
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
|
||||||
|
reload happens per browser session (shared with the existing chunk-load
|
||||||
|
recovery), so a permanent version skew degrades to the banner rather than a
|
||||||
|
reload loop. When the build carries no version info the feature stays inert.
|
||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
images as a row that wraps onto the next line on narrow screens. The row is
|
images as a row that wraps onto the next line on narrow screens. The row is
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"A new version is available": "A new version is available",
|
||||||
"Account": "Account",
|
"Account": "Account",
|
||||||
"Active": "Active",
|
"Active": "Active",
|
||||||
"Add": "Add",
|
"Add": "Add",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"A new version is available": "Доступна новая версия",
|
||||||
"Account": "Аккаунт",
|
"Account": "Аккаунт",
|
||||||
"Active": "Активный",
|
"Active": "Активный",
|
||||||
"Add": "Добавить",
|
"Add": "Добавить",
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
hasAutoReloaded,
|
||||||
|
markAutoReloaded,
|
||||||
|
recordReloadBreadcrumb,
|
||||||
|
} from "@/lib/reload-guard";
|
||||||
|
|
||||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
@@ -25,16 +28,15 @@ function handleError(error: unknown) {
|
|||||||
if (!isChunkLoadError(error)) return;
|
if (!isChunkLoadError(error)) return;
|
||||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||||
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
||||||
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
// (e.g. a genuinely missing chunk) with the shared one-shot session flag
|
||||||
// flag is already set we fall through to the manual recovery UI below.
|
// (see @/lib/reload-guard — shared with the proactive version-coherence
|
||||||
try {
|
// path). If it is already set, or the write fails (storage unavailable), we
|
||||||
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
// fall through to the manual recovery UI below rather than risk a loop.
|
||||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
if (hasAutoReloaded()) return;
|
||||||
} catch {
|
if (!markAutoReloaded()) return;
|
||||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
// Trace before the reload clears the console (same diagnostic breadcrumb the
|
||||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
// proactive version-coherence path writes, tagged with this path).
|
||||||
return;
|
recordReloadBreadcrumb({ path: "chunk-boundary" });
|
||||||
}
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, act, cleanup } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
// Mocks for the dirty shell's side-effecting collaborators.
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||||
|
vi.mock("@/lib/reload-guard", () => ({
|
||||||
|
hasAutoReloaded: vi.fn(() => false),
|
||||||
|
markAutoReloaded: vi.fn(() => true),
|
||||||
|
recordReloadBreadcrumb: vi.fn(),
|
||||||
|
takeReloadBreadcrumb: vi.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
|
||||||
|
import {
|
||||||
|
triggerGuardedReload,
|
||||||
|
useVersionReloadOnNavigation,
|
||||||
|
__resetGuardedReloadForTests,
|
||||||
|
} from "./guarded-reload";
|
||||||
|
|
||||||
|
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
let reload: ReturnType<typeof vi.fn>;
|
||||||
|
let visibility: DocumentVisibilityState;
|
||||||
|
|
||||||
|
// Test harness mounted inside a router: it installs the navigation hook and
|
||||||
|
// exposes `navigate` so a test can drive an in-app router navigation.
|
||||||
|
let doNavigate: (to: string) => void;
|
||||||
|
function Harness() {
|
||||||
|
useVersionReloadOnNavigation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
doNavigate = navigate;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountHarness() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/start"]}>
|
||||||
|
<Harness />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(path: string) {
|
||||||
|
act(() => {
|
||||||
|
doNavigate(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetGuardedReloadForTests();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockHasAutoReloaded.mockReturnValue(false);
|
||||||
|
mockMarkAutoReloaded.mockReturnValue(true);
|
||||||
|
|
||||||
|
vi.stubGlobal("APP_VERSION", "test-A");
|
||||||
|
|
||||||
|
reload = vi.fn();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: { reload },
|
||||||
|
});
|
||||||
|
|
||||||
|
visibility = "visible";
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => visibility,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("triggerGuardedReload (variant C)", () => {
|
||||||
|
it("noop when versions match: no banner, no reload", () => {
|
||||||
|
triggerGuardedReload("test-A");
|
||||||
|
expect(show).not.toHaveBeenCalled();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noop when the server version is empty (fail-safe)", () => {
|
||||||
|
triggerGuardedReload("");
|
||||||
|
triggerGuardedReload(undefined);
|
||||||
|
expect(show).not.toHaveBeenCalled();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("real mismatch shows the banner but does NOT reload immediately", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
expect(show.mock.calls[0][0]).toMatchObject({
|
||||||
|
id: "app-version-reload",
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
});
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A second navigation must NOT reload again (one-shot was consumed).
|
||||||
|
navigateTo("/again");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Another app-version mismatch arrives (reconnect): must not re-arm.
|
||||||
|
triggerGuardedReload("test-C");
|
||||||
|
navigateTo("/again");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reload merely from the tab going to the background", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
visibility = "hidden";
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
|
||||||
|
visibility = "hidden";
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the banner's Update button reloads immediately", () => {
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
const message = show.mock.calls[0][0].message as {
|
||||||
|
props: { onClick: () => void };
|
||||||
|
};
|
||||||
|
message.props.onClick();
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
|
||||||
|
mockHasAutoReloaded.mockReturnValue(true);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reload when the flag write fails; falls back to the banner", () => {
|
||||||
|
mockMarkAutoReloaded.mockReturnValue(false);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
// performAutoReload falls back to showing the banner (initial + fallback).
|
||||||
|
expect(show).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
triggerGuardedReload("test-C");
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { Button } from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import i18n from "@/i18n.ts";
|
||||||
|
import {
|
||||||
|
hasAutoReloaded,
|
||||||
|
markAutoReloaded,
|
||||||
|
recordReloadBreadcrumb,
|
||||||
|
takeReloadBreadcrumb,
|
||||||
|
} from "@/lib/reload-guard";
|
||||||
|
import { decideVersionAction } from "@/features/user/version-coherence";
|
||||||
|
|
||||||
|
// Dirty shell around the pure `decideVersionAction`: it reads globals
|
||||||
|
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
|
||||||
|
// Mantine notification, and arms the router-navigation reload hook. Kept
|
||||||
|
// separate from the pure module so the decision stays unit-testable without a
|
||||||
|
// DOM.
|
||||||
|
|
||||||
|
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
|
||||||
|
// single banner instead of stacking a new one each time.
|
||||||
|
const BANNER_ID = "app-version-reload";
|
||||||
|
|
||||||
|
// Module-level idempotency for the current tab-load: once a mismatch has been
|
||||||
|
// handled we don't re-arm the navigation reload or re-show the banner on
|
||||||
|
// subsequent app-version emits.
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
|
||||||
|
// the background (that would silently drop a half-written comment/form). Instead
|
||||||
|
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
|
||||||
|
// the user is already leaving the current page, so an in-app navigation would
|
||||||
|
// discard that unsaved component-state anyway and the reload adds no extra loss.
|
||||||
|
let pendingNavReload = false;
|
||||||
|
|
||||||
|
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
|
||||||
|
// the (already-visible) banner.
|
||||||
|
let lastServerVersion = "";
|
||||||
|
let lastClientVersion = "";
|
||||||
|
|
||||||
|
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
|
||||||
|
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
|
||||||
|
// where Vite's `define` did not run) — an unknown client version makes the
|
||||||
|
// pure decision no-op (fail-safe).
|
||||||
|
function readClientVersion(): string {
|
||||||
|
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the actual reload — but only after the shared one-shot flag is
|
||||||
|
// persisted. If the write fails (storage unavailable) we must NOT reload
|
||||||
|
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
|
||||||
|
// to the manual banner so the user can still recover.
|
||||||
|
function performAutoReload(): void {
|
||||||
|
if (!markAutoReloaded()) {
|
||||||
|
showReloadBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Trace right before the reload (which clears the console): a persistent
|
||||||
|
// breadcrumb + a log line so the auto-reload is observable in a field report.
|
||||||
|
recordReloadBreadcrumb({
|
||||||
|
path: "proactive",
|
||||||
|
serverVersion: lastServerVersion,
|
||||||
|
clientVersion: lastClientVersion,
|
||||||
|
});
|
||||||
|
console.warn(
|
||||||
|
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
|
||||||
|
);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReloadBanner(): void {
|
||||||
|
notifications.show({
|
||||||
|
id: BANNER_ID,
|
||||||
|
title: i18n.t("A new version is available"),
|
||||||
|
message: (
|
||||||
|
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
|
||||||
|
{i18n.t("Update")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a server `app-version` announcement: compare it to this bundle's
|
||||||
|
* version and, on a real mismatch, show the banner and arm a guarded reload for
|
||||||
|
* the next in-app navigation (variant C).
|
||||||
|
*
|
||||||
|
* - real mismatch (first this session) → banner + arm navigation reload. The
|
||||||
|
* banner's "Update" button reloads immediately (same one-shot guard). The tab
|
||||||
|
* is NOT reloaded on visibility change.
|
||||||
|
* - auto-reload already used / storage error → banner only (no arm), so there is
|
||||||
|
* at most one automatic reload per session (loop safety).
|
||||||
|
* - in sync / unknown version → noop (fail-safe).
|
||||||
|
*/
|
||||||
|
export function triggerGuardedReload(
|
||||||
|
rawServerVersion: string | undefined | null,
|
||||||
|
): void {
|
||||||
|
const serverVersion = (rawServerVersion ?? "").trim();
|
||||||
|
const clientVersion = readClientVersion();
|
||||||
|
|
||||||
|
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
|
||||||
|
// reloading (banner only).
|
||||||
|
const autoReloadUsed = hasAutoReloaded();
|
||||||
|
|
||||||
|
const action = decideVersionAction({
|
||||||
|
serverVersion,
|
||||||
|
clientVersion,
|
||||||
|
autoReloadUsed,
|
||||||
|
});
|
||||||
|
if (action === "noop") return;
|
||||||
|
|
||||||
|
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
|
||||||
|
// emits (reconnects) once we've already acted.
|
||||||
|
if (handled) return;
|
||||||
|
handled = true;
|
||||||
|
|
||||||
|
lastServerVersion = serverVersion;
|
||||||
|
lastClientVersion = clientVersion;
|
||||||
|
|
||||||
|
if (action === "banner") {
|
||||||
|
// Entered banner-only (permanent skew, node oscillation, or spent
|
||||||
|
// auto-reload). Log for diagnosability; show the manual banner.
|
||||||
|
console.warn(
|
||||||
|
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
|
||||||
|
"auto-reload already spent this session — showing manual banner",
|
||||||
|
);
|
||||||
|
showReloadBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// action === "reload" (variant C): show the banner and defer the auto-reload
|
||||||
|
// to the next in-app navigation instead of reloading now / on visibility.
|
||||||
|
showReloadBanner();
|
||||||
|
pendingNavReload = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume the armed one-shot navigation reload, if any. Called by
|
||||||
|
* `useVersionReloadOnNavigation` on each in-app router navigation.
|
||||||
|
*/
|
||||||
|
export function consumeNavigationReload(): void {
|
||||||
|
if (!pendingNavReload) return;
|
||||||
|
pendingNavReload = false;
|
||||||
|
performAutoReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
|
||||||
|
* NEXT in-app router navigation after a version mismatch. Skips the initial
|
||||||
|
* render so it only reacts to real navigations, not the first location.
|
||||||
|
*/
|
||||||
|
export function useVersionReloadOnNavigation(): void {
|
||||||
|
const location = useLocation();
|
||||||
|
const firstRender = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstRender.current) {
|
||||||
|
firstRender.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consumeNavigationReload();
|
||||||
|
}, [location.key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
|
||||||
|
* load — the reload cleared the console, so this makes a "tab reloaded itself"
|
||||||
|
* report diagnosable. Call once on app startup.
|
||||||
|
*/
|
||||||
|
export function surfacePreviousReloadBreadcrumb(): void {
|
||||||
|
const crumb = takeReloadBreadcrumb();
|
||||||
|
if (!crumb) return;
|
||||||
|
console.info(
|
||||||
|
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
|
||||||
|
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
|
||||||
|
`at=${new Date(crumb.at).toISOString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only: reset module-level latches between cases.
|
||||||
|
export function __resetGuardedReloadForTests(): void {
|
||||||
|
handled = false;
|
||||||
|
pendingNavReload = false;
|
||||||
|
lastServerVersion = "";
|
||||||
|
lastClientVersion = "";
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
|||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||||
|
import {
|
||||||
|
triggerGuardedReload,
|
||||||
|
useVersionReloadOnNavigation,
|
||||||
|
surfacePreviousReloadBreadcrumb,
|
||||||
|
} from "@/features/user/guarded-reload.tsx";
|
||||||
|
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
|
||||||
|
|
||||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
@@ -22,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
// fetch collab token on load
|
// fetch collab token on load
|
||||||
const { data: collab } = useCollabToken();
|
const { data: collab } = useCollabToken();
|
||||||
|
|
||||||
|
// version-coherence: fire the armed one-shot reload on the next in-app
|
||||||
|
// navigation (variant C — a safe point, not on tab backgrounding).
|
||||||
|
useVersionReloadOnNavigation();
|
||||||
|
|
||||||
|
// Surface any breadcrumb left by an auto-reload in the previous page load
|
||||||
|
// (the reload cleared the console) so a field report stays diagnosable.
|
||||||
|
useEffect(() => {
|
||||||
|
surfacePreviousReloadBreadcrumb();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isError) {
|
if (isLoading || isError) {
|
||||||
return;
|
return;
|
||||||
@@ -47,6 +63,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
handleConnect();
|
handleConnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
|
||||||
|
// connects: the server emits `app-version` immediately in handleConnection,
|
||||||
|
// so a listener attached after connect would miss it on a fast localhost
|
||||||
|
// connect. On a version mismatch the client shows a banner and defers the
|
||||||
|
// auto-reload to the next in-app navigation (variant C — avoids reloading a
|
||||||
|
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
|
||||||
|
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
|
||||||
|
triggerGuardedReload(payload?.version);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("ws disconnected");
|
console.log("ws disconnected");
|
||||||
newSocket.disconnect();
|
newSocket.disconnect();
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { decideVersionAction } from "./version-coherence";
|
||||||
|
|
||||||
|
describe("decideVersionAction", () => {
|
||||||
|
it("noop when the server version is empty (fail-safe)", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "",
|
||||||
|
clientVersion: "v1",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noop when the client version is empty (fail-safe)", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "v1",
|
||||||
|
clientVersion: "",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noop when versions are equal (in sync)", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "v1",
|
||||||
|
clientVersion: "v1",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reload on a real mismatch the first time this session", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("reload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("banner on a mismatch once the session auto-reload is spent", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
autoReloadUsed: true,
|
||||||
|
}),
|
||||||
|
).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("equal versions stay noop even if auto-reload was already used", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "v1",
|
||||||
|
clientVersion: "v1",
|
||||||
|
autoReloadUsed: true,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Payload of the per-connect `app-version` socket.io event announced by the
|
||||||
|
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
|
||||||
|
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
|
||||||
|
// `operation`), so it never touches use-query-subscription.
|
||||||
|
export type AppVersionSocketPayload = { version: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision for the version-coherence guard.
|
||||||
|
*
|
||||||
|
* All inputs are injected (no globals, no side effects) so it is unit-testable
|
||||||
|
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
|
||||||
|
*
|
||||||
|
* - `autoReloadUsed` = a session-wide automatic reload has already happened,
|
||||||
|
* so we must not auto-reload again (loop safety, shared with the reactive
|
||||||
|
* chunk-load boundary).
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - "noop" — do nothing (unknown version on either side, or already in sync).
|
||||||
|
* - "banner" — show the manual "update available" banner only (no auto-reload).
|
||||||
|
* - "reload" — real first-time mismatch: eligible for a guarded auto-reload.
|
||||||
|
*/
|
||||||
|
export function decideVersionAction(args: {
|
||||||
|
serverVersion: string;
|
||||||
|
clientVersion: string;
|
||||||
|
autoReloadUsed: boolean;
|
||||||
|
}): "reload" | "banner" | "noop" {
|
||||||
|
const { serverVersion, clientVersion, autoReloadUsed } = args;
|
||||||
|
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
|
||||||
|
if (serverVersion === clientVersion) return "noop"; // in sync
|
||||||
|
if (autoReloadUsed) return "banner"; // one auto-reload per session already spent
|
||||||
|
return "reload"; // real mismatch, first time this session
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
hasAutoReloaded,
|
||||||
|
markAutoReloaded,
|
||||||
|
recordReloadBreadcrumb,
|
||||||
|
takeReloadBreadcrumb,
|
||||||
|
} from "./reload-guard";
|
||||||
|
|
||||||
|
const FLAG = "chunk-reload-attempted";
|
||||||
|
|
||||||
|
describe("reload-guard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded is false before any reload, true after mark", () => {
|
||||||
|
expect(hasAutoReloaded()).toBe(false);
|
||||||
|
expect(markAutoReloaded()).toBe(true);
|
||||||
|
expect(hasAutoReloaded()).toBe(true);
|
||||||
|
// Uses the same key the reactive chunk-load boundary reads.
|
||||||
|
expect(sessionStorage.getItem(FLAG)).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(hasAutoReloaded()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("markAutoReloaded returns false when writing storage throws", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(markAutoReloaded()).toBe(false);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records and then takes a breadcrumb once (cleared on read)", () => {
|
||||||
|
recordReloadBreadcrumb({
|
||||||
|
path: "proactive",
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
});
|
||||||
|
const crumb = takeReloadBreadcrumb();
|
||||||
|
expect(crumb).toMatchObject({
|
||||||
|
path: "proactive",
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
});
|
||||||
|
expect(typeof crumb?.at).toBe("number");
|
||||||
|
// Cleared on read → a second take returns null.
|
||||||
|
expect(takeReloadBreadcrumb()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
|
||||||
|
expect(takeReloadBreadcrumb()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
removeItem: () => {},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(() =>
|
||||||
|
recordReloadBreadcrumb({ path: "chunk-boundary" }),
|
||||||
|
).not.toThrow();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Shared one-shot auto-reload guard.
|
||||||
|
//
|
||||||
|
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
|
||||||
|
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
|
||||||
|
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
|
||||||
|
// they share ONE session-scoped flag. Net guarantee: at most a single
|
||||||
|
// automatic reload per browser session across both paths. Once the flag is set
|
||||||
|
// (or sessionStorage is unavailable), every further mismatch degrades to a
|
||||||
|
// manual banner/UI — no reload loop under permanent skew, node oscillation, or
|
||||||
|
// a disabled storage.
|
||||||
|
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has an automatic reload already been performed (or attempted) this session?
|
||||||
|
*
|
||||||
|
* A storage read error (private mode / disabled) is reported as `true` so the
|
||||||
|
* caller fails toward NOT reloading — an unguarded loop is worse than a stale
|
||||||
|
* tab the user can reload manually.
|
||||||
|
*/
|
||||||
|
export function hasAutoReloaded(): boolean {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(RELOAD_FLAG) !== null;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that an automatic reload is being performed this session.
|
||||||
|
*
|
||||||
|
* Returns whether the write succeeded. A `false` return (storage unavailable)
|
||||||
|
* means the caller MUST NOT reload — otherwise the flag would never stick and
|
||||||
|
* the reload could loop.
|
||||||
|
*/
|
||||||
|
export function markAutoReloaded(): boolean {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic breadcrumb for an automatic reload. Written right before
|
||||||
|
// window.location.reload() (which clears the console) and read back on the next
|
||||||
|
// page load, so a "the tab reloaded itself / it's looping" field report is
|
||||||
|
// diagnosable: which path fired (proactive version-coherence vs the reactive
|
||||||
|
// chunk-load boundary) and which version pair triggered it. sessionStorage
|
||||||
|
// survives a same-tab reload, unlike the console.
|
||||||
|
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
|
||||||
|
|
||||||
|
export type ReloadBreadcrumb = {
|
||||||
|
path: "proactive" | "chunk-boundary";
|
||||||
|
serverVersion?: string;
|
||||||
|
clientVersion?: string;
|
||||||
|
at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a best-effort breadcrumb just before an automatic reload. Failures
|
||||||
|
* (storage unavailable) are swallowed — this is diagnostics only and must never
|
||||||
|
* block or alter the reload decision.
|
||||||
|
*/
|
||||||
|
export function recordReloadBreadcrumb(
|
||||||
|
entry: Omit<ReloadBreadcrumb, "at">,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
RELOAD_BREADCRUMB_KEY,
|
||||||
|
JSON.stringify({ ...entry, at: Date.now() }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// best-effort diagnostics only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and clear the breadcrumb left by an auto-reload in the previous page
|
||||||
|
* load. Cleared on read so it surfaces exactly once per reload.
|
||||||
|
*/
|
||||||
|
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
|
||||||
|
return JSON.parse(raw) as ReloadBreadcrumb;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { compression } from "vite-plugin-compression2";
|
import { compression } from "vite-plugin-compression2";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import * as fs from "node:fs";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
const envPath = path.resolve(process.cwd(), "..", "..");
|
const envPath = path.resolve(process.cwd(), "..", "..");
|
||||||
@@ -24,7 +25,32 @@ function resolveAppVersion(cwd: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
|
||||||
|
// the exact same build id the bundle was compiled with. The value is the SAME
|
||||||
|
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
|
||||||
|
// global are identical by construction — the single source of truth (no
|
||||||
|
// runtime-env second copy that could drift and cause a false version mismatch).
|
||||||
|
function versionJsonPlugin(version: string): Plugin {
|
||||||
|
let outDir = "dist";
|
||||||
|
return {
|
||||||
|
name: "emit-version-json",
|
||||||
|
apply: "build",
|
||||||
|
configResolved(config) {
|
||||||
|
outDir = config.build.outDir;
|
||||||
|
},
|
||||||
|
writeBundle() {
|
||||||
|
const root = path.resolve(process.cwd(), outDir);
|
||||||
|
fs.mkdirSync(root, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "version.json"),
|
||||||
|
JSON.stringify({ version }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
|
const appVersion = resolveAppVersion(envPath);
|
||||||
const {
|
const {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
@@ -52,10 +78,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
POSTHOG_HOST,
|
POSTHOG_HOST,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
APP_VERSION: JSON.stringify(appVersion),
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
versionJsonPlugin(appVersion),
|
||||||
// Emit .br and .gz next to every built asset so the server can serve the
|
// Emit .br and .gz next to every built asset so the server can serve the
|
||||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||||
compression({
|
compression({
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import { readClientBuildVersion } from './client-version';
|
||||||
|
|
||||||
|
describe('readClientBuildVersion', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeVersionJson = (content: string) =>
|
||||||
|
fs.writeFileSync(join(dir, 'version.json'), content);
|
||||||
|
|
||||||
|
it('returns the version from a valid version.json', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ version: 'test-A' }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('test-A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace in the version', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when version.json is missing', () => {
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" on malformed JSON', () => {
|
||||||
|
writeVersionJson('{ not json');
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when the version field is absent', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when the version field is not a string', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ version: 123 }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when the path does not exist at all', () => {
|
||||||
|
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the absolute path to the built client bundle directory
|
||||||
|
* (`apps/client/dist`) shipped into the runtime image.
|
||||||
|
*
|
||||||
|
* The `../` depth is anchored on THIS module's compiled location
|
||||||
|
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
|
||||||
|
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
|
||||||
|
* MUST share this single helper rather than duplicating the depth — a copy in a
|
||||||
|
* module at a different depth would silently resolve to the wrong directory.
|
||||||
|
*/
|
||||||
|
export function resolveClientDistPath(): string {
|
||||||
|
return join(__dirname, '..', '..', '..', '..', 'client/dist');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the build version the client bundle was compiled with, from
|
||||||
|
* `<clientDistPath>/version.json` (written by the Vite build — the single
|
||||||
|
* source of truth shared by the baked-in `APP_VERSION` global and this file).
|
||||||
|
*
|
||||||
|
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
|
||||||
|
* version) yields `''`. The caller treats an empty version as "unknown" and
|
||||||
|
* the whole version-coherence feature stays silently inert — existing deploys
|
||||||
|
* without the file keep working unchanged.
|
||||||
|
*/
|
||||||
|
export function readClientBuildVersion(clientDistPath: string): string {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
|
||||||
|
const version = (JSON.parse(raw) as { version?: unknown }).version;
|
||||||
|
return typeof version === 'string' ? version.trim() : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from './nanoid.utils';
|
|||||||
export * from './file.helper';
|
export * from './file.helper';
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
export * from './security-headers';
|
export * from './security-headers';
|
||||||
|
export * from './client-version';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
import { resolveClientDistPath } from '../../common/helpers/client-version';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the response headers for a statically served client asset.
|
* Resolve the response headers for a statically served client asset.
|
||||||
@@ -56,14 +57,7 @@ export class StaticModule implements OnModuleInit {
|
|||||||
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||||
const app = httpAdapter.getInstance();
|
const app = httpAdapter.getInstance();
|
||||||
|
|
||||||
const clientDistPath = join(
|
const clientDistPath = resolveClientDistPath();
|
||||||
__dirname,
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'client/dist',
|
|
||||||
);
|
|
||||||
|
|
||||||
const indexFilePath = join(clientDistPath, 'index.html');
|
const indexFilePath = join(clientDistPath, 'index.html');
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ import {
|
|||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { TokenService } from '../core/auth/services/token.service';
|
import { TokenService } from '../core/auth/services/token.service';
|
||||||
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
||||||
import { OnModuleDestroy } from '@nestjs/common';
|
import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { WsService } from './ws.service';
|
import { WsService } from './ws.service';
|
||||||
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
||||||
|
import {
|
||||||
|
readClientBuildVersion,
|
||||||
|
resolveClientDistPath,
|
||||||
|
} from '../common/helpers/client-version';
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
@@ -20,17 +24,40 @@ import * as cookie from 'cookie';
|
|||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
})
|
})
|
||||||
export class WsGateway
|
export class WsGateway
|
||||||
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
|
implements
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayInit,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy
|
||||||
{
|
{
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(WsGateway.name);
|
||||||
|
|
||||||
|
// The build version of the client bundle shipped in this image, read once at
|
||||||
|
// startup from client/dist/version.json (single source of truth, same value
|
||||||
|
// baked into the client's APP_VERSION). Empty string => version.json missing
|
||||||
|
// or empty => the proactive version-coherence reload feature stays inert.
|
||||||
|
private appVersion = '';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private tokenService: TokenService,
|
private tokenService: TokenService,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
private wsService: WsService,
|
private wsService: WsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
this.appVersion = readClientBuildVersion(resolveClientDistPath());
|
||||||
|
if (this.appVersion) {
|
||||||
|
this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`);
|
||||||
|
} else {
|
||||||
|
this.logger.log(
|
||||||
|
'app-version reload: DISABLED (version.json missing/empty)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterInit(server: Server): void {
|
afterInit(server: Server): void {
|
||||||
this.wsService.setServer(server);
|
this.wsService.setServer(server);
|
||||||
}
|
}
|
||||||
@@ -55,6 +82,14 @@ export class WsGateway
|
|||||||
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
|
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
|
||||||
|
|
||||||
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
||||||
|
|
||||||
|
// Announce this container's client build version to the freshly
|
||||||
|
// authenticated socket. On a redeploy the client reconnects to the new
|
||||||
|
// container and receives the new version here, letting it guard-reload
|
||||||
|
// before it hits a stale lazy chunk. Per-connect only (no broadcast):
|
||||||
|
// natural reconnect covers both single-container and cluster without a
|
||||||
|
// thundering-herd fleet reload.
|
||||||
|
client.emit('app-version', { version: this.appVersion });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
client.emit('Unauthorized');
|
client.emit('Unauthorized');
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
|
|||||||
Reference in New Issue
Block a user