diff --git a/apps/docs/alchemy.run.ts b/apps/docs/alchemy.run.ts index f26e8389..7393c384 100644 --- a/apps/docs/alchemy.run.ts +++ b/apps/docs/alchemy.run.ts @@ -35,6 +35,9 @@ const hostnameFor = (stage: string): string => const program = Effect.gen(function* () { const stage = yield* Alchemy.Stage; + const incrementalCache = yield* Cloudflare.R2Bucket("DocsIncrementalCache", { + name: `${PROJECT}-${SERVICE}-${stage}-incremental-cache`, + }); // OpenNext-on-Cloudflare emits the worker entrypoint and assets directory. // The build is expected to have already run (`bun run build:worker`); this @@ -83,19 +86,18 @@ const program = Effect.gen(function* () { // trailing-slash handling for static MDX routes. assets: { directory: ".open-next/assets", - // TODO(stackpanel): re-enable the `.open-next/cache` overlay once - // `alchemy@2` natively supports `AssetsProps.sources` (or vendor a - // v2-compatible patch). The 0.12.x vendored overlay was removed during - // the alchemy-effect → alchemy@2 rename; see follow-up bd issue. - // Without it, OpenNext incremental cache misses for `cdn-cgi/_next_cache` - // paths fall back to ISR revalidation — degraded cache hit rate, not - // a hard breakage. config: { notFoundHandling: "none", htmlHandling: "auto-trailing-slash", runWorkerFirst: false, }, }, + bindings: { + NEXT_INC_CACHE_R2_BUCKET: incrementalCache, + }, + env: { + NEXT_INC_CACHE_R2_PREFIX: `${stage}/incremental-cache`, + }, compatibility: { // Must be >= 2026-03-17 — that's the date Cloudflare started providing // node:perf_hooks as a native module. OpenNext (via Next.js's edge diff --git a/apps/docs/open-next.config.ts b/apps/docs/open-next.config.ts index 7fe6c193..bb3cd4c9 100644 --- a/apps/docs/open-next.config.ts +++ b/apps/docs/open-next.config.ts @@ -1,10 +1,8 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare"; -// import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; -import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache"; +import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; export default defineCloudflareConfig({ // See https://opennext.js.org/cloudflare/caching for more details - // incrementalCache: r2IncrementalCache, - incrementalCache: staticAssetsIncrementalCache, + incrementalCache: r2IncrementalCache, enableCacheInterception: true, }); diff --git a/apps/docs/src/app/layout.tsx b/apps/docs/src/app/layout.tsx index ce9e332a..44abbe63 100644 --- a/apps/docs/src/app/layout.tsx +++ b/apps/docs/src/app/layout.tsx @@ -1,5 +1,6 @@ import { RootProvider } from "fumadocs-ui/provider/next"; import "./global.css"; +import type { Metadata } from "next"; import type { ReactNode } from "react"; import "@fontsource-variable/inter"; import "@fontsource/monaspace-neon/300.css"; @@ -7,6 +8,10 @@ import "@fontsource/monaspace-neon/400.css"; import "@fontsource/monaspace-neon/400-italic.css"; import "@fontsource/monaspace-neon/700.css"; +export const metadata: Metadata = { + metadataBase: new URL("https://docs.stackpanel.com"), +}; + export default function Layout({ children }: { children: ReactNode }) { return ( diff --git a/apps/web/alchemy.run.ts b/apps/web/alchemy.run.ts index 09866006..878296b5 100644 --- a/apps/web/alchemy.run.ts +++ b/apps/web/alchemy.run.ts @@ -30,6 +30,12 @@ const STACKPANEL_ZONE = "d34628a3ab639230ff1f6dc1eb640eec"; const program = Effect.gen(function* () { const stage = yield* Alchemy.Stage; const label = stage.replaceAll("_", "-"); + const appBaseUrl = + stage === "production" + ? "https://stackpanel.com" + : stage === "dev" + ? "http://localhost:3001" + : `https://local.${label}.stackpanel.com`; const db = yield* NeonProject("postgres", { name: `${PROJECT}-${stage}`, regionId: "aws-us-east-1", @@ -74,8 +80,13 @@ const program = Effect.gen(function* () { env: { DATABASE_URL: db.connectionUri, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? "", + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL ?? appBaseUrl, + CORS_ORIGIN: process.env.CORS_ORIGIN ?? appBaseUrl, + STACKPANEL_DEPLOY_ENV: stage, POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN ?? "", POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET ?? "", + POLAR_SUCCESS_URL: + process.env.POLAR_SUCCESS_URL ?? `${appBaseUrl}/dashboard/billing`, POLAR_PRO_PRODUCT_ID_PRODUCTION: process.env.POLAR_PRO_PRODUCT_ID_PRODUCTION ?? "", POLAR_FREE_PRODUCT_ID_PRODUCTION: diff --git a/apps/web/public/_headers b/apps/web/public/_headers index e6320ab1..8fd23585 100644 --- a/apps/web/public/_headers +++ b/apps/web/public/_headers @@ -1,2 +1,5 @@ /_next/static/* - Cache-Control: public,max-age=31536000,immutable \ No newline at end of file + Cache-Control: public,max-age=31536000,immutable + +/assets/* + Cache-Control: public,max-age=31536000,immutable diff --git a/apps/web/src/lib/production-performance-config.test.ts b/apps/web/src/lib/production-performance-config.test.ts new file mode 100644 index 00000000..c3e05a31 --- /dev/null +++ b/apps/web/src/lib/production-performance-config.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +const repoRoot = process.env.STACKPANEL_REPO_ROOT; + +if (!repoRoot) { + throw new Error("STACKPANEL_REPO_ROOT must be set for config tests"); +} + +const readRepoFile = (path: string) => readFileSync(join(repoRoot, path), "utf8"); + +describe("production performance configuration", () => { + test("API routes do not import auth or API routers at module scope", () => { + const authRoute = readRepoFile("apps/web/src/routes/api/auth.$.ts"); + const trpcRoute = readRepoFile("apps/web/src/routes/api/trpc.$.ts"); + + expect(authRoute).not.toMatch( + /^import\s+.*from\s+["']@stackpanel\/auth["']/m, + ); + expect(trpcRoute).not.toMatch( + /^import\s+.*from\s+["']@stackpanel\/auth["']/m, + ); + expect(trpcRoute).not.toMatch( + /^import\s+.*from\s+["']@stackpanel\/api["']/m, + ); + }); + + test("root route does not statically import devtools into production bundles", () => { + const rootRoute = readRepoFile("apps/web/src/routes/__root.tsx"); + + expect(rootRoute).not.toMatch( + /^import\s+.*from\s+["']@tanstack\/react-query-devtools["']/m, + ); + expect(rootRoute).not.toMatch( + /^import\s+.*from\s+["']@tanstack\/react-router-devtools["']/m, + ); + }); + + test("web worker deploy forwards production auth URL configuration", () => { + const deploy = readRepoFile("apps/web/alchemy.run.ts"); + + expect(deploy).toContain("BETTER_AUTH_URL"); + expect(deploy).toContain("CORS_ORIGIN"); + }); + + test("hashed Vite assets are immutable in browser caches", () => { + const headers = readRepoFile("apps/web/public/_headers"); + + expect(headers).toContain("/assets/*"); + expect(headers).toContain("max-age=31536000,immutable"); + }); + + test("docs app declares canonical metadata base for production", () => { + const layout = readRepoFile("apps/docs/src/app/layout.tsx"); + + expect(layout).toContain("metadataBase"); + expect(layout).toContain("https://docs.stackpanel.com"); + }); + + test("docs worker uses a writable R2 incremental cache", () => { + const deploy = readRepoFile("apps/docs/alchemy.run.ts"); + const openNextConfig = readRepoFile("apps/docs/open-next.config.ts"); + + expect(openNextConfig).toContain("r2-incremental-cache"); + expect(deploy).toContain("NEXT_INC_CACHE_R2_BUCKET"); + expect(deploy).toContain("bindings"); + }); +}); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8bf04377..2e49c28c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -2,7 +2,6 @@ import type { AppRouter } from "@stackpanel/api/routers/index"; import type { QueryClient } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { createRootRouteWithContext, HeadContent, @@ -10,9 +9,9 @@ import { Scripts, useRouterState, } from "@tanstack/react-router"; -import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import type { TRPCOptionsProxy } from "@trpc/tanstack-react-query"; import { Toaster } from "@ui/sonner"; +import { lazy, Suspense, type ReactNode } from "react"; import Header from "@/components/header"; import { WaitlistProvider } from "@/components/landing/waitlist-dialog"; import { ThemeProvider } from "@/components/theme-provider"; @@ -29,6 +28,22 @@ export interface RouterAppContext { queryClient: QueryClient; } +const TanStackRouterDevtools = import.meta.env.DEV + ? lazy(() => + import("@tanstack/react-router-devtools").then((mod) => ({ + default: mod.TanStackRouterDevtools, + })), + ) + : null; + +const ReactQueryDevtools = import.meta.env.DEV + ? lazy(() => + import("@tanstack/react-query-devtools").then((mod) => ({ + default: mod.ReactQueryDevtools, + })), + ) + : null; + export const Route = createRootRouteWithContext()({ component: RootComponent, head: () => ({ @@ -75,7 +90,7 @@ function RootComponent() { ); } -function RootDocument({ children }: { children: React.ReactNode }) { +function RootDocument({ children }: { children: ReactNode }) { const routerState = useRouterState(); const pathname = routerState.location.pathname; const isFullScreenPage = [/^\/$/, /^\/(demo|studio)\/?/].some((regex) => @@ -103,8 +118,15 @@ function RootDocument({ children }: { children: React.ReactNode }) { - - + {TanStackRouterDevtools && ReactQueryDevtools && ( + + + + + )} diff --git a/apps/web/src/routes/api/auth.$.ts b/apps/web/src/routes/api/auth.$.ts index d8b3bff0..d4113c4c 100644 --- a/apps/web/src/routes/api/auth.$.ts +++ b/apps/web/src/routes/api/auth.$.ts @@ -1,11 +1,15 @@ -import { auth } from "@stackpanel/auth"; import { createFileRoute } from "@tanstack/react-router"; +const handleAuthRequest = async (request: Request) => { + const { auth } = await import("@stackpanel/auth"); + return auth.handler(request); +}; + export const Route = createFileRoute("/api/auth/$")({ server: { handlers: { - GET: ({ request }) => auth.handler(request), - POST: ({ request }) => auth.handler(request), + GET: ({ request }) => handleAuthRequest(request), + POST: ({ request }) => handleAuthRequest(request), }, }, }); diff --git a/apps/web/src/routes/api/trpc.$.ts b/apps/web/src/routes/api/trpc.$.ts index b0852fea..ba13bb8e 100644 --- a/apps/web/src/routes/api/trpc.$.ts +++ b/apps/web/src/routes/api/trpc.$.ts @@ -1,10 +1,13 @@ -import { appRouter, createTRPCContext } from "@stackpanel/api"; -import { auth } from "@stackpanel/auth"; import { createFileRoute } from "@tanstack/react-router"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; -const handler = (req: Request) => - fetchRequestHandler({ +const handler = async (req: Request) => { + const [{ appRouter, createTRPCContext }, { auth }] = await Promise.all([ + import("@stackpanel/api"), + import("@stackpanel/auth"), + ]); + + return fetchRequestHandler({ endpoint: "/api/trpc", router: appRouter, req, @@ -17,6 +20,7 @@ const handler = (req: Request) => console.error(`>>> tRPC Error on '${path}'`, error); }, }); +}; export const Route = createFileRoute("/api/trpc/$")({ server: { diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 8d9b7edc..4fcf759e 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -29,6 +29,7 @@ const program = Effect.gen(function* () { const betterAuthSecret = yield* Config.option( Config.redacted("BETTER_AUTH_SECRET"), ); + const betterAuthUrl = yield* Config.option(Config.string("BETTER_AUTH_URL")); const polarAccessToken = yield* Config.option( Config.redacted("POLAR_ACCESS_TOKEN"), ); @@ -44,6 +45,7 @@ const program = Effect.gen(function* () { ); return { betterAuthSecret, + betterAuthUrl, polarAccessToken, polarWebhookSecret, polarSuccessUrl, @@ -62,6 +64,9 @@ const resolved = Effect.runSync(program); export const betterAuthSecret: Option.Option> = resolved.betterAuthSecret; +/** Better-Auth public base URL. */ +export const betterAuthUrl: Option.Option = resolved.betterAuthUrl; + /** Polar API access token. When `None`, the polar plugin is not mounted. */ export const polarAccessToken: Option.Option> = resolved.polarAccessToken; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index dbfaef51..625f0304 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -6,6 +6,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { organization } from "better-auth/plugins"; import { corsOrigin, + betterAuthUrl, polarSuccessUrl, polarWebhookSecret, presentRedacted, @@ -94,6 +95,7 @@ const crossSubDomainCookies = const corsOriginValue = presentString(corsOrigin); export const auth = betterAuth({ + baseURL: presentString(betterAuthUrl), database: drizzleAdapter(db, { provider: "pg", }),