Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions apps/docs/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions apps/docs/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -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,
});
5 changes: 5 additions & 0 deletions apps/docs/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
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";
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 (
<html lang="en" suppressHydrationWarning style={{ fontFamily: "'Inter Variable', sans-serif" }}>
Expand Down
11 changes: 11 additions & 0 deletions apps/web/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion apps/web/public/_headers
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable
Cache-Control: public,max-age=31536000,immutable

/assets/*
Cache-Control: public,max-age=31536000,immutable
69 changes: 69 additions & 0 deletions apps/web/src/lib/production-performance-config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
32 changes: 27 additions & 5 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@

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,
Outlet,
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";
Expand All @@ -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<RouterAppContext>()({
component: RootComponent,
head: () => ({
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -103,8 +118,15 @@ function RootDocument({ children }: { children: React.ReactNode }) {
</WaitlistProvider>
</AgentEndpointProvider>
<Toaster richColors />
<TanStackRouterDevtools position="bottom-left" />
<ReactQueryDevtools buttonPosition="bottom-right" position="bottom" />
{TanStackRouterDevtools && ReactQueryDevtools && (
<Suspense fallback={null}>
<TanStackRouterDevtools position="bottom-left" />
<ReactQueryDevtools
buttonPosition="bottom-right"
position="bottom"
/>
</Suspense>
)}
</body>
</html>
</ThemeProvider>
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/routes/api/auth.$.ts
Original file line number Diff line number Diff line change
@@ -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),
},
},
});
12 changes: 8 additions & 4 deletions apps/web/src/routes/api/trpc.$.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,6 +20,7 @@ const handler = (req: Request) =>
console.error(`>>> tRPC Error on '${path}'`, error);
},
});
};

export const Route = createFileRoute("/api/trpc/$")({
server: {
Expand Down
5 changes: 5 additions & 0 deletions packages/auth/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
);
Expand All @@ -44,6 +45,7 @@ const program = Effect.gen(function* () {
);
return {
betterAuthSecret,
betterAuthUrl,
polarAccessToken,
polarWebhookSecret,
polarSuccessUrl,
Expand All @@ -62,6 +64,9 @@ const resolved = Effect.runSync(program);
export const betterAuthSecret: Option.Option<Redacted.Redacted<string>> =
resolved.betterAuthSecret;

/** Better-Auth public base URL. */
export const betterAuthUrl: Option.Option<string> = resolved.betterAuthUrl;

/** Polar API access token. When `None`, the polar plugin is not mounted. */
export const polarAccessToken: Option.Option<Redacted.Redacted<string>> =
resolved.polarAccessToken;
Expand Down
2 changes: 2 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { organization } from "better-auth/plugins";
import {
corsOrigin,
betterAuthUrl,
polarSuccessUrl,
polarWebhookSecret,
presentRedacted,
Expand Down Expand Up @@ -94,6 +95,7 @@ const crossSubDomainCookies =
const corsOriginValue = presentString(corsOrigin);

export const auth = betterAuth({
baseURL: presentString(betterAuthUrl),
database: drizzleAdapter(db, {
provider: "pg",
}),
Expand Down
Loading