From d1ab930a970a1c0591024b897957e8b796e81a12 Mon Sep 17 00:00:00 2001 From: Sam Vendittelli Date: Tue, 14 Nov 2023 15:48:53 +0000 Subject: [PATCH 1/2] chore: configure vercel cli --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c8ffc91..95d9117 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ /.next/ /out/ +# vercel +.vercel + # production /build From 32c3b9d1ad9af8fa8bf8700f41fb91aa6a058956 Mon Sep 17 00:00:00 2001 From: Sam Vendittelli Date: Tue, 14 Nov 2023 17:47:42 +0000 Subject: [PATCH 2/2] feat(maintenance): add middleware to disable site for maintenance Use a feature flag stored in Vercel Edge Config to disable the site for maintenance. --- .github/workflows/playwright.yml | 4 ++- .vscode/settings.json | 2 +- app/api/flags/route.ts | 30 ++++++++++++++++++++ app/lib/flags.ts | 4 +++ app/maintenance/page.test.tsx | 37 +++++++++++++++++++++++++ app/maintenance/page.tsx | 21 ++++++++++++++ e2e/api/flags/route.spec.ts | 12 ++++++++ env.mjs | 6 ++++ middleware.ts | 47 ++++++++++++++++++++++++++++++++ package-lock.json | 17 ++++++++++++ package.json | 5 +++- playwright.config.ts | 2 +- 12 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 app/api/flags/route.ts create mode 100644 app/lib/flags.ts create mode 100644 app/maintenance/page.test.tsx create mode 100644 app/maintenance/page.tsx create mode 100644 e2e/api/flags/route.spec.ts create mode 100644 middleware.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index f07c563..5776bd5 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -28,9 +28,11 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} max_timeout: 300 - name: Run Playwright tests - run: npx playwright test + run: npm run test:e2e env: BASE_URL: ${{ steps.waitForDeploy.outputs.url }} + EDGE_CONFIG: ${{ secrets.EDGE_CONFIG }} + VERCEL_ENV: preview - uses: actions/upload-artifact@v3 if: always() with: diff --git a/.vscode/settings.json b/.vscode/settings.json index b9f1a5d..2d759cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "conventionalCommits.scopes": ["next.js", "home"], + "conventionalCommits.scopes": ["next.js", "home", "maintenance"], "files.associations": { "*.css": "tailwindcss" }, diff --git a/app/api/flags/route.ts b/app/api/flags/route.ts new file mode 100644 index 0000000..2a5be52 --- /dev/null +++ b/app/api/flags/route.ts @@ -0,0 +1,30 @@ +import { FeatureFlag } from "@/app/lib/flags"; +import { env } from "@/env.mjs"; +import { get } from "@vercel/edge-config"; +import { NextResponse } from "next/server"; + +export const runtime = "edge"; + +export async function GET() { + if (!env.EDGE_CONFIG || !env.VERCEL_ENV) { + console.error( + "Missing environment variables for feature flags, both should be defined:", + { + EDGE_CONFIG: !!env.EDGE_CONFIG, + VERCEL_ENV: !!env.VERCEL_ENV, + }, + ); + } + + const flags = await get(env.VERCEL_ENV); + + if (!flags) { + console.error("Missing feature flag configuration"); + return NextResponse.json( + { message: "Missing feature flag configuration" }, + { status: 500 }, + ); + } + + return NextResponse.json(flags); +} diff --git a/app/lib/flags.ts b/app/lib/flags.ts new file mode 100644 index 0000000..1a0ef6b --- /dev/null +++ b/app/lib/flags.ts @@ -0,0 +1,4 @@ +export type FeatureFlag = { + beta: boolean; + maintenance: boolean; +}; diff --git a/app/maintenance/page.test.tsx b/app/maintenance/page.test.tsx new file mode 100644 index 0000000..59ceffa --- /dev/null +++ b/app/maintenance/page.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import Page from "./page"; + +const refresh = jest.fn(); +jest.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh, + }), +})); + +describe("Maintenance", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders page title 'Under Maintenance'", () => { + render(); + const messageElement = screen.getByText(/Under Maintenance/); + expect(messageElement).toBeInTheDocument(); + }); + + it("renders a message", () => { + render(); + const messageElement = screen.getByText( + /You can try to refresh the page to see if the issue is resolved./, + ); + expect(messageElement).toBeInTheDocument(); + }); + + it("renders a 'Refresh' button", () => { + render(); + const button = screen.getByText("Refresh"); + expect(button).toBeInTheDocument(); + button.click(); + expect(refresh).toHaveBeenCalled(); + }); +}); diff --git a/app/maintenance/page.tsx b/app/maintenance/page.tsx new file mode 100644 index 0000000..304e4f7 --- /dev/null +++ b/app/maintenance/page.tsx @@ -0,0 +1,21 @@ +"use client"; +import { useRouter } from "next/navigation"; + +export default function Page() { + const { refresh } = useRouter(); + + return ( +
+

Under Maintenance

+

+ You can try to refresh the page to see if the issue is resolved. +

+ +
+ ); +} diff --git a/e2e/api/flags/route.spec.ts b/e2e/api/flags/route.spec.ts new file mode 100644 index 0000000..d508935 --- /dev/null +++ b/e2e/api/flags/route.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from "@playwright/test"; + +test("should return the feature flags successfully", async ({ request }) => { + const flags = await request.get("/api/flags"); + expect(flags.ok()).toBeTruthy(); + expect(flags.status()).toBe(200); + // Note, playwright tests are run against the preview environment. Tests will fail if the preview feature flags are altered. + expect(await flags.json()).toEqual({ + beta: true, + maintenance: false, + }); +}); diff --git a/env.mjs b/env.mjs index 31f7e0c..099111f 100644 --- a/env.mjs +++ b/env.mjs @@ -12,13 +12,19 @@ export const env = createEnv({ .string() .optional() .transform((value) => !!value && value !== "false" && value !== "0"), + EDGE_CONFIG: z.string().optional(), PORT: z.coerce.number().default(3000), + VERCEL_ENV: z + .enum(["development", "preview", "production"]) + .default("development"), }, client: {}, runtimeEnv: { ANALYZE: process.env.ANALYZE, BASE_URL: process.env.BASE_URL, CI: process.env.CI, + EDGE_CONFIG: process.env.EDGE_CONFIG, + VERCEL_ENV: process.env.VERCEL_ENV, PORT: process.env.PORT, }, }); diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..f150cc8 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,47 @@ +import { FeatureFlag } from "@/app/lib/flags"; +import { env } from "@/env.mjs"; +import { get } from "@vercel/edge-config"; +import { NextRequest, NextResponse } from "next/server"; + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - maintenance (maintenance page) + */ + "/((?!api|_next/static|_next/image|favicon.ico|maintenance).*)", + ], +}; + +export async function middleware(req: NextRequest) { + if (!env.EDGE_CONFIG || !env.VERCEL_ENV) { + // If the environment variables are not defined, log an error and continue + console.error( + "Missing environment variables for feature flags, both should be defined:", + { + EDGE_CONFIG: !!env.EDGE_CONFIG, + VERCEL_ENV: !!env.VERCEL_ENV, + }, + ); + return NextResponse.next(); + } + + try { + // Check whether the maintenance page should be shown + const flags = await get(env.VERCEL_ENV); + + // If is in maintenance mode, point the url pathname to the maintenance page + if (flags?.maintenance) { + req.nextUrl.pathname = `/maintenance`; + + // Rewrite to the url + return NextResponse.rewrite(req.nextUrl); + } + } catch (error) { + console.error(error); + } +} diff --git a/package-lock.json b/package-lock.json index e0898a3..f56c123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@t3-oss/env-nextjs": "0.7.1", + "@vercel/edge-config": "0.4.1", "clsx": "2.0.0", "next": "14.0.2", "react": "18.2.0", @@ -2267,6 +2268,22 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@vercel/edge-config": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@vercel/edge-config/-/edge-config-0.4.1.tgz", + "integrity": "sha512-4Mc3H7lE+x4RrL17nY8CWeEorvJHbkNbQTy9p8H1tO7y11WeKj5xeZSr07wNgfWInKXDUwj5FZ3qd/jIzjPxug==", + "dependencies": { + "@vercel/edge-config-fs": "0.1.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/@vercel/edge-config-fs": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@vercel/edge-config-fs/-/edge-config-fs-0.1.0.tgz", + "integrity": "sha512-NRIBwfcS0bUoUbRWlNGetqjvLSwgYH/BqKqDN7vK1g32p7dN96k0712COgaz6VFizAm9b0g6IG6hR6+hc0KCPg==" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", diff --git a/package.json b/package.json index 4714011..a21e55e 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "analyze": "cross-env ANALYZE=true npm run build", "build": "next build", "dev": "next dev", + "dev:test": "cross-env VERCEL=preview npm run dev", "format": "prettier --write .", "preinstall": "npx npm-only-allow@latest --PM npm", "lint": "next lint", @@ -13,13 +14,15 @@ "start": "next start", "test": "jest --watch", "test:ci": "jest --ci", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" }, "dependencies": { "@t3-oss/env-nextjs": "0.7.1", + "@vercel/edge-config": "0.4.1", "clsx": "2.0.0", "next": "14.0.2", "react": "18.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index 3fd44d5..5785442 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -67,7 +67,7 @@ export default defineConfig({ webServer: env.CI ? undefined : { - command: "npm run dev", + command: "npm run dev:test", url: baseURL, timeout: 120 * 1000, reuseExistingServer: true,