diff --git a/backend/src/app.ts b/backend/src/app.ts index 6c85bd10c28a..2330295d77c0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,17 +8,20 @@ import { badAuthRateLimiterHandler, rootRateLimiter, } from "./middlewares/rate-limit"; +import { compatibilityCheckMiddleware } from "./middlewares/compatibilityCheck"; +import { COMPATIBILITY_CHECK_HEADER } from "@monkeytype/contracts"; function buildApp(): express.Application { const app = express(); app.use(urlencoded({ extended: true })); app.use(json()); - app.use(cors()); + app.use(cors({ exposedHeaders: [COMPATIBILITY_CHECK_HEADER] })); app.use(helmet()); app.set("trust proxy", 1); + app.use(compatibilityCheckMiddleware); app.use(contextMiddleware); app.use(badAuthRateLimiterHandler); diff --git a/backend/src/middlewares/compatibilityCheck.ts b/backend/src/middlewares/compatibilityCheck.ts new file mode 100644 index 000000000000..89c45049fc51 --- /dev/null +++ b/backend/src/middlewares/compatibilityCheck.ts @@ -0,0 +1,20 @@ +import { + COMPATIBILITY_CHECK, + COMPATIBILITY_CHECK_HEADER, +} from "@monkeytype/contracts"; +import type { Response, NextFunction, Request } from "express"; + +/** + * Add the COMPATIBILITY_CHECK_HEADER to each response + * @param _req + * @param res + * @param next + */ +export async function compatibilityCheckMiddleware( + _req: Request, + res: Response, + next: NextFunction +): Promise { + res.setHeader(COMPATIBILITY_CHECK_HEADER, COMPATIBILITY_CHECK); + next(); +} diff --git a/frontend/src/ts/ape/adapters/ts-rest-adapter.ts b/frontend/src/ts/ape/adapters/ts-rest-adapter.ts index 5ca100350cba..cc1f744b507c 100644 --- a/frontend/src/ts/ape/adapters/ts-rest-adapter.ts +++ b/frontend/src/ts/ape/adapters/ts-rest-adapter.ts @@ -7,6 +7,13 @@ import { import { getIdToken } from "firebase/auth"; import { envConfig } from "../../constants/env-config"; import { getAuthenticatedUser, isAuthenticated } from "../../firebase"; +import { + COMPATIBILITY_CHECK, + COMPATIBILITY_CHECK_HEADER, +} from "@monkeytype/contracts"; +import * as Notifications from "../../elements/notifications"; + +let bannerActive = false; function timeoutSignal(ms: number): AbortSignal { const ctrl = new AbortController(); @@ -35,7 +42,6 @@ function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{ : AbortSignal.timeout(timeout), }; const response = await tsRestFetchApi(request); - if (response.status >= 400) { console.error(`${request.method} ${request.path} failed`, { status: response.status, @@ -43,6 +49,28 @@ function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{ }); } + const compatibilityCheck = response.headers.get( + COMPATIBILITY_CHECK_HEADER + ); + if (compatibilityCheck !== null && !bannerActive) { + const backendCheck = parseInt(compatibilityCheck); + if (backendCheck !== COMPATIBILITY_CHECK) { + const message = + backendCheck > COMPATIBILITY_CHECK + ? `Looks like the client and server versions are mismatched (backend is newer). Please refresh the page.` + : `Looks like our monkeys didn't deploy the new server version correctly. If this message persists contact support.`; + Notifications.addBanner( + message, + 1, + undefined, + false, + () => (bannerActive = false), + true + ); + bannerActive = true; + } + } + return response; } catch (e: Error | unknown) { let message = "Unknown error"; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 75aa486635c3..779c5402abb9 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -30,3 +30,10 @@ export const contract = c.router({ quotes: quotesContract, webhooks: webhooksContract, }); + +/** + * Whenever there is a breaking change with old frontend clients increase this number. + * This will inform the frontend to refresh. + */ +export const COMPATIBILITY_CHECK = 0; +export const COMPATIBILITY_CHECK_HEADER = "X-Compatibility-Check";