Openstatus www.openstatus.dev

chore: stpg proxy (#1365)

* chore: status page form configuration

* wip:

* wip: link configuration

* fix: legacy web

* refactor: configuration name

* wip:

* fix: form types

* chore: rename

* chore: stpg proxy

* test: mock upstash

authored by

Maximilian Kaske and committed by
GitHub
577b11ec 0387ae05

+128 -9
+6
apps/status-page/next.config.ts
··· 28 28 }, 29 29 ], 30 30 missing: [ 31 + // Skip this rewrite when the request came via proxy from web app 32 + { 33 + type: "header", 34 + key: "x-proxy", 35 + value: "1", 36 + }, 31 37 { 32 38 type: "host", 33 39 value:
+11 -2
apps/status-page/src/middleware.ts
··· 8 8 const url = req.nextUrl.clone(); 9 9 const response = NextResponse.next(); 10 10 const cookies = req.cookies; 11 + const headers = req.headers; 12 + const host = headers.get("x-forwarded-host"); 11 13 12 14 let prefix = ""; 13 15 let type: "hostname" | "pathname"; 14 16 15 - const hostnames = url.host.split("."); 17 + const hostnames = host?.split(/[.:]/) ?? url.host.split(/[.:]/); 16 18 const pathnames = url.pathname.split("/"); 17 19 if ( 18 20 hostnames.length > 2 && ··· 33 35 const _page = await db.select().from(page).where(eq(page.slug, prefix)).get(); 34 36 35 37 if (!_page) { 36 - return NextResponse.redirect(new URL("https://stpg.dev")); 38 + // return NextResponse.redirect(new URL("https://stpg.dev")); 39 + // TODO: work on 404 page 40 + return response; 37 41 } 38 42 39 43 if (_page?.passwordProtected) { ··· 57 61 ), 58 62 ); 59 63 } 64 + } 65 + 66 + const proxy = req.headers.get("x-proxy"); 67 + if (proxy) { 68 + return NextResponse.rewrite(new URL(`/${prefix}${url.pathname}`, req.url)); 60 69 } 61 70 62 71 return response;
-1
apps/web/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - /// <reference types="next/navigation-types/compat/navigation" /> 4 3 5 4 // NOTE: This file should not be edited 6 5 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+22
apps/web/next.config.js
··· 65 65 ]; 66 66 }, 67 67 async rewrites() { 68 + const NEW_HOST = 69 + process.env.NODE_ENV === "development" ? "localhost:3001" : "stpg.dev"; 68 70 return { 69 71 beforeFiles: [ 72 + // Proxy app subdomain to /app 70 73 { 71 74 source: "/:path*", 72 75 has: [ ··· 76 79 }, 77 80 ], 78 81 destination: "/app/:path*", 82 + }, 83 + // New design: proxy Next.js assets from external host when cookie indicates "new" 84 + { 85 + source: "/_next/:path*", 86 + has: [ 87 + { type: "cookie", key: "sp_mode", value: "new" }, 88 + { type: "host", value: "(?<slug>[^.]+)\\.(stpg\\.dev|localhost)" }, 89 + ], 90 + destination: `http://${NEW_HOST}/_next/:path*`, 91 + }, 92 + // New design: proxy app routes to external host with slug prefix 93 + { 94 + source: "/:path((?!_next/).*)", 95 + has: [ 96 + { type: "cookie", key: "sp_mode", value: "new" }, 97 + { type: "host", value: "(?<slug>[^.]+)\\.(stpg\\.dev|localhost)" }, 98 + ], 99 + // NOTE: we don't need the slug `/:slug/:path*` here because it will already be applied in the rewrites in the status-page app as subdomain 100 + destination: `http://${NEW_HOST}/:path*`, 79 101 }, 80 102 ], 81 103 };
+40 -5
apps/web/src/middleware.ts
··· 6 6 7 7 import { auth } from "@/lib/auth"; 8 8 import { eq } from "@openstatus/db"; 9 + import { Redis } from "@openstatus/upstash"; 9 10 import { env } from "./env"; 11 + 12 + const MAX_AGE = 60 * 10; // 10 minutes 10 13 11 14 export const getValidSubdomain = (host?: string | null) => { 12 15 let subdomain: string | null = null; ··· 58 61 } 59 62 60 63 const host = req.headers.get("host"); 64 + const pathname = req.nextUrl.pathname; 61 65 const subdomain = getValidSubdomain(host); 62 66 63 - // Rewriting to status page! 67 + // Subdomain handling: set mode cookie (legacy/new) and let next.config rewrites proxy 64 68 if (subdomain) { 65 - url.pathname = `/status-page/${subdomain}${url.pathname}`; 66 - return NextResponse.rewrite(url); 67 - } 69 + const modeCookie = req.cookies.get("sp_mode")?.value; // "legacy" | "new" 70 + const cached = modeCookie === "legacy" || modeCookie === "new"; 71 + let mode: "legacy" | "new" | undefined = cached ? modeCookie : undefined; 72 + 73 + if (!mode) { 74 + try { 75 + const redis = Redis.fromEnv(); 76 + const cache = await redis.get(`page:${subdomain}`); 77 + // Determine legacy flag from cache 78 + mode = cache ? "new" : "legacy"; 79 + } catch { 80 + mode = "legacy"; 81 + } 82 + } 68 83 69 - const pathname = req.nextUrl.pathname; 84 + if (mode === "legacy") { 85 + url.pathname = `/status-page/${subdomain}${url.pathname}`; 86 + return NextResponse.rewrite(url); 87 + } 88 + 89 + const res = NextResponse.next(); 90 + // Mark that this request is being proxied so downstream can adapt 91 + res.headers.set("x-proxy", "1"); 92 + // Short-lived cookie so toggles apply relatively quickly 93 + res.cookies.set("sp_mode", "new", { path: "/", maxAge: MAX_AGE }); 94 + 95 + // If we just set the cookie, trigger one redirect so next.config.js 96 + // rewrites that depend on sp_mode can apply on the next request. 97 + if (!cached) { 98 + const redirect = NextResponse.redirect(url); 99 + redirect.cookies.set("sp_mode", "new", { path: "/", maxAge: MAX_AGE }); 100 + return redirect; 101 + } 102 + 103 + return res; 104 + } 70 105 71 106 const isPublicAppPath = publicAppPaths.some((path) => 72 107 pathname.startsWith(path),
+3 -1
packages/api/.env.test
··· 1 1 RESEND_API_KEY='test' 2 2 NEXT_PUBLIC_OPENPANEL_CLIENT_ID='test' 3 - OPENPANEL_CLIENT_SECRET='test' 3 + OPENPANEL_CLIENT_SECRET='test' 4 + UPSTASH_REDIS_REST_URL="test" 5 + UPSTASH_REDIS_REST_TOKEN="test"
+1
packages/api/package.json
··· 13 13 "@openstatus/emails": "workspace:*", 14 14 "@openstatus/error": "workspace:*", 15 15 "@openstatus/utils": "workspace:*", 16 + "@openstatus/upstash": "workspace:*", 16 17 "@openstatus/tinybird": "workspace:*", 17 18 "@t3-oss/env-core": "0.7.1", 18 19 "@trpc/client": "11.4.4",
+30
packages/api/src/router/page.ts
··· 30 30 } from "@openstatus/db/src/schema"; 31 31 32 32 import { Events } from "@openstatus/analytics"; 33 + import { Redis } from "@openstatus/upstash"; 33 34 import { env } from "../env"; 34 35 import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 36 + 37 + if (process.env.NODE_ENV === "test") { 38 + require("../test/preload"); 39 + } 40 + 41 + const redis = Redis.fromEnv(); 35 42 36 43 // Helper functions to reuse Vercel API logic 37 44 async function addDomainToVercel(domain: string) { ··· 775 782 eq(page.id, opts.input.id), 776 783 ]; 777 784 785 + const _page = await opts.ctx.db.query.page.findFirst({ 786 + where: and(...whereConditions), 787 + }); 788 + 789 + if (!_page) { 790 + throw new TRPCError({ 791 + code: "NOT_FOUND", 792 + message: "Page not found", 793 + }); 794 + } 795 + 778 796 await opts.ctx.db 779 797 .update(page) 780 798 .set({ ··· 786 804 }) 787 805 .where(and(...whereConditions)) 788 806 .run(); 807 + 808 + if (opts.input.legacyPage) { 809 + await redis.del(`page:${_page.slug}`); 810 + if (_page.customDomain) { 811 + await redis.del(`page:${_page.customDomain}`); 812 + } 813 + } else { 814 + await redis.set(`page:${_page.slug}`, 1); 815 + if (_page.customDomain) { 816 + await redis.set(`page:${_page.customDomain}`, 1); 817 + } 818 + } 789 819 }), 790 820 791 821 updateMonitors: protectedProcedure
+12
packages/api/src/test/preload.ts
··· 1 + import { mock } from "bun:test"; 2 + 3 + mock.module("@openstatus/upstash", () => ({ 4 + Redis: { 5 + fromEnv() { 6 + return { 7 + get: () => Promise.resolve(undefined), 8 + set: () => Promise.resolve([]), 9 + }; 10 + }, 11 + }, 12 + }));
+3
pnpm-lock.yaml
··· 1168 1168 '@openstatus/tinybird': 1169 1169 specifier: workspace:* 1170 1170 version: link:../tinybird 1171 + '@openstatus/upstash': 1172 + specifier: workspace:* 1173 + version: link:../upstash 1171 1174 '@openstatus/utils': 1172 1175 specifier: workspace:* 1173 1176 version: link:../utils