Initial commit

This commit is contained in:
fraqtal
2026-07-12 08:15:46 +00:00
commit ee0fec0691
1397 changed files with 127242 additions and 0 deletions

111
apps/web-next/AGENTS.md Normal file
View File

@@ -0,0 +1,111 @@
# AGENTS.md — apps/web-next
Next.js 15 reference application using App Router. Demonstrates consuming feature packages via tRPC and importing UI components from `@repo/core-ui`. Both `@repo/core-trpc` and `@repo/core-ui` are optional packages — scaffold them with `pnpm turbo gen core-package trpc` / `ui` if needed.
## Purpose
Thin app showcasing how features work end-to-end. Business logic lives in feature packages (`@repo/auth`, `@repo/blog`, etc.); UI primitives live in `@repo/core-ui`; this app is mostly routes, layouts, and component composition.
## Port: 3000
```bash
pnpm dev --filter @repo/web-next # http://localhost:3000
```
Requires `@repo/cms` and PostgreSQL running to fetch live data:
```bash
docker compose up -d postgres # PostgreSQL on port 5432
pnpm dev --filter @repo/cms # Payload admin on port 3001
pnpm dev --filter @repo/web-next # Next.js on port 3000
```
## Key Files
| File | Purpose |
|---|---|
| `src/app/layout.tsx` | Root layout — wraps app with `<Providers>` |
| `src/app/providers.tsx` | Client component wrapper (add tRPC/React Query here after scaffolding `@repo/core-trpc`) |
| `src/app/page.tsx` | Home page — navigation + marketing content |
| `src/app/blog/[slug]/page.tsx` | Dynamic blog post route |
| `e2e/` | Playwright end-to-end tests |
## tRPC Setup (optional)
`@repo/core-trpc` is not installed by default. After scaffolding with `pnpm turbo gen core-package trpc`:
1. Create `src/app/api/trpc/[trpc]/route.ts`:
```typescript
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@repo/core-api";
import { bindAll } from "../../../../server/bind-production";
const handler = async (req: Request) => {
await bindAll();
return fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => ({}),
});
};
export { handler as GET, handler as POST };
```
2. Update `src/app/providers.tsx`:
```typescript
"use client";
import { NextTrpcProvider } from "@repo/core-trpc/next";
export function Providers({ children }: { children: React.ReactNode }) {
return <NextTrpcProvider trpcUrl="/api/trpc">{children}</NextTrpcProvider>;
}
```
## Dependencies
| Dependency | Purpose |
|---|---|
| `@repo/core-api` | `appRouter` for tRPC endpoint |
| `@repo/core-trpc/next` | Next.js tRPC client + provider (optional — scaffold first) |
| `@repo/core-ui` | Design system components (optional — scaffold first) |
| `@repo/auth`, `@repo/blog`, etc. | Feature packages (indirectly via core-api) |
| `next` | Next.js 15 framework |
| `@trpc/server` | tRPC server (fetch adapter) |
## Test conventions
- Unit tests colocated: `src/app/blog/article-list.test.tsx`
- Vitest environment: `jsdom`
- e2e tests in `e2e/` folder: `*.spec.ts`
- Run: `pnpm test --filter @repo/web-next` (units) or `pnpm test:e2e` (Playwright)
## E2E Test Setup
Playwright config in `e2e/playwright.config.ts`:
```typescript
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
webServer: {
command: "pnpm dev",
port: 3000,
reuseExistingServer: !process.env.CI,
},
use: { ...devices["Desktop Chrome"].use },
});
```
Run: `pnpm test:e2e` starts the dev server and runs all `.spec.ts` files.
## Cross-References
- **Feature packages:** `packages/{auth,blog,media,marketing-pages,navigation}/`
- **tRPC composition:** `packages/core-api/AGENTS.md`
- **tRPC client + provider (optional):** scaffold `@repo/core-trpc` first, then see `turbo/generators/templates/core-package/trpc/AGENTS.md.hbs`
- **UI components (optional):** scaffold with `pnpm turbo gen core-package ui`, then see `turbo/generators/templates/core-package/ui/AGENTS.md.hbs`

View File

@@ -0,0 +1,20 @@
import { test, expect } from "@playwright/test";
test("/blog/[slug] returns 404 for non-existent slug", async ({ page }) => {
const response = await page.goto("/blog/this-slug-does-not-exist", {
waitUntil: "domcontentloaded",
});
expect(response?.status()).toBe(404);
});
test("/blog/[slug] for a real slug renders the article", async ({ page }) => {
// The mock blog repository is empty by default — so this test currently
// expects 404. When seeded data exists in Payload, replace 404 with 200
// and check for article.title in the page body.
test.skip(
true,
"Pending: seed a published article in Payload before enabling this test",
);
await page.goto("/blog/example-slug");
await expect(page.locator("h1").first()).toBeVisible();
});

View File

@@ -0,0 +1,12 @@
import { test, expect } from "@playwright/test";
test("home page renders site name + nav + article list", async ({ page }) => {
await page.goto("/");
// Page renders and shows site name
await expect(page.locator("h1").first()).toBeVisible();
// Site name from siteSettings (mock seed: "My App")
await expect(page.locator("body")).toContainText(/My App/i);
// Nav element is present on the page
const nav = page.locator("nav");
await expect(nav).toHaveCount(1);
});

View File

@@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
test("/about renders the about marketing page", async ({ page }) => {
await page.goto("/about");
// Either renders the seeded page (h1 = "About us") or "not yet published" message
// — both are HTTP 200, so the test only checks it doesn't 500.
const status = (await page.context().request.get("/about")).status();
expect(status).toBe(200);
await expect(page.locator("body")).toBeVisible();
});

View File

@@ -0,0 +1,11 @@
import baseConfig from "@repo/core-eslint/base";
export default [
...baseConfig,
{
files: ["next-env.d.ts"],
rules: {
"@typescript-eslint/triple-slash-reference": "off",
},
},
];

View File

@@ -0,0 +1,19 @@
// apps/web-next/instrumentation-client.ts
// Next.js 15+ browser hook: runs in the client bundle on app start.
import { initSentryClient } from "@repo/core-shared/instrumentation/sentry/init-client";
function getNonce(): string {
if (typeof document === "undefined") return "";
return (
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
""
);
}
initSentryClient({
dsn: process.env["NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN"],
app: "web-next",
release: process.env["NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA"],
nonce: getNonce(),
});

View File

@@ -0,0 +1,22 @@
// apps/web-next/instrumentation.ts
// Next.js convention: this module runs once on server boot (before any request handler).
// Initializes the OTel SDK here so PII scrub processors are active from the very first
// request — before bindAll() fires. Calling initOtelServerNode here (not inside bindAll)
// closes the startup window where @sentry/nextjs auto-instrumentation could send
// unscrubbed errors (C1 fix).
export async function register() {
if (
process.env["NEXT_RUNTIME"] === "nodejs" ||
process.env["NEXT_RUNTIME"] === "edge"
) {
const { initOtelServerNode } = await import(
"@repo/core-shared/instrumentation/otel/init-server-node"
);
initOtelServerNode({
dsn: process.env["WEB_NEXT_SENTRY_DSN"] ?? "",
serviceName: "web-next",
environment: process.env["NODE_ENV"] ?? "development",
});
}
}

View File

@@ -0,0 +1,10 @@
import { withSecurityHeaders } from "@repo/core-shared/security/next";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
return withSecurityHeaders(request);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

6
apps/web-next/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,31 @@
import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: [
"@repo/auth",
"@repo/blog",
"@repo/core-analytics",
"@repo/core-api",
"@repo/core-audit",
"@repo/core-cms",
"@repo/core-consent",
"@repo/core-dsr",
"@repo/core-shared",
"@repo/core-ui",
"@repo/marketing-pages",
"@repo/media",
"@repo/navigation",
"@repo/core-trpc",
],
};
export default withSentryConfig(nextConfig, {
// Token is build-time only; CI sets SENTRY_AUTH_TOKEN.
silent: process.env.CI !== "true",
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT_WEB_NEXT,
hideSourceMaps: true,
disableLogger: true,
});

View File

@@ -0,0 +1,54 @@
{
"name": "@repo/web-next",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "echo 'Next.js build requires full environment — use pnpm dev or docker'",
"dev": "TSX_TSCONFIG_PATH=../../tsconfig.json tsx server.ts",
"start": "node --import tsx server.ts",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install --with-deps chromium",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/auth": "workspace:*",
"@repo/blog": "workspace:*",
"@repo/core-api": "workspace:*",
"@repo/core-cms": "workspace:*",
"@repo/core-shared": "workspace:*",
"@repo/core-trpc": "workspace:^",
"@repo/marketing-pages": "workspace:*",
"@repo/media": "workspace:*",
"@repo/navigation": "workspace:*",
"@sentry/nextjs": "^10.51.0",
"@tailwindcss/postcss": "^4.3.0",
"@tanstack/react-query": "^5.96.2",
"@trpc/server": "^11.17.0",
"inversify": "^6.2.0",
"next": "^15.3.0",
"payload": "^3.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"reflect-metadata": "^0.2.2",
"superjson": "^2.2.1",
"tailwindcss": "^4.1.0"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^25.0.0",
"tsx": "^4.0.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "list",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

23
apps/web-next/server.ts Normal file
View File

@@ -0,0 +1,23 @@
// apps/web-next/server.ts
// SERVER-ONLY entry. Custom Next.js server for local development.
// When @repo/core-realtime is scaffolded, this file is extended to boot
// Socket.IO alongside Next (see pnpm turbo gen core-package realtime).
import "reflect-metadata";
import { createServer } from "node:http";
import next from "next";
import { bindAll } from "./src/server/bind-production.js";
const dev = process.env.NODE_ENV !== "production";
const port = Number(process.env.PORT ?? 3000);
const app = next({ dev });
const handle = app.getRequestHandler();
await app.prepare();
await bindAll();
const httpServer = createServer((req, res) => handle(req, res));
httpServer.listen(port, () => {
console.log(`> Ready on http://localhost:${port}`);
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const responseMock = vi.hoisted(() => {
function makeResponseMock() {
const store = new Map<string, string>();
return {
_store: store,
headers: {
set: vi.fn((k: string, v: string) => store.set(k, v)),
get: vi.fn((k: string) => store.get(k) ?? null),
},
};
}
return { makeResponseMock };
});
vi.mock("next/server", () => ({
NextResponse: {
next: vi.fn(),
},
}));
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { middleware } from "../../middleware";
const ALL_SIX_HEADERS = [
"Strict-Transport-Security",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Permissions-Policy",
"Content-Security-Policy",
] as const;
function makeRequest(): NextRequest {
return { headers: new Headers() } as unknown as NextRequest;
}
describe("web-next middleware", () => {
let mock: ReturnType<typeof responseMock.makeResponseMock>;
beforeEach(() => {
mock = responseMock.makeResponseMock();
vi.mocked(NextResponse.next).mockReturnValue(
mock as unknown as ReturnType<typeof NextResponse.next>,
);
});
it("sets all six security headers on the response", () => {
middleware(makeRequest());
for (const header of ALL_SIX_HEADERS) {
expect(mock._store.has(header)).toBe(true);
}
});
it("sets x-nonce header on the response", () => {
middleware(makeRequest());
const nonce = mock._store.get("x-nonce");
expect(nonce).toBeDefined();
expect(typeof nonce).toBe("string");
expect((nonce as string).length).toBeGreaterThan(0);
});
it("CSP contains nonce in production mode", () => {
vi.stubEnv("NODE_ENV", "production");
middleware(makeRequest());
const nonce = mock._store.get("x-nonce");
const csp = mock._store.get("Content-Security-Policy");
expect(csp).toContain(`'nonce-${nonce}'`);
});
it("CSP is permissive (unsafe-inline) in development mode", () => {
vi.stubEnv("NODE_ENV", "development");
middleware(makeRequest());
const csp = mock._store.get("Content-Security-Policy");
expect(csp).toContain("'unsafe-inline'");
});
it("x-nonce is forwarded in request headers passed to NextResponse.next", () => {
middleware(makeRequest());
const call = vi.mocked(NextResponse.next).mock.calls[0] as [
{ request?: { headers?: Headers } } | undefined,
];
expect(call[0]?.request?.headers?.get("x-nonce")).toBeTruthy();
});
});

View File

@@ -0,0 +1,50 @@
// Cross-feature event bus proof-of-life (DISABLED — @repo/core-events removed).
//
// @repo/core-events is now optional. When absent, ctx.bus is undefined, and
// bus?.subscribe(...) / bus?.publish(...) calls are no-ops. Cross-feature event
// fanout does not occur until core-events is scaffolded via:
//
// pnpm turbo gen core-package events
//
// After scaffolding, restore this test and re-wire the bus in bind-production.ts
// (see the comment in bindAll()). Until then, signing up does NOT trigger a
// welcome email — the mailer queue stays empty.
//
// Replaced with a reduced test that asserts the no-bus behavior.
import "reflect-metadata";
import { describe, it, expect, beforeEach } from "vitest";
import { bindAllDevSeed, __resetBindStateForTests } from "@/server/bind-production";
import { authContainer } from "@repo/auth/di/container";
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
import type { ISignUpController } from "@repo/auth";
import { marketingPagesContainer } from "@repo/marketing-pages/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@repo/marketing-pages/di/symbols";
import { RecordingMailerService } from "@repo/marketing-pages/services/recording-mailer";
describe("e2e: sign-up with no event bus (core-events not scaffolded)", () => {
beforeEach(() => {
__resetBindStateForTests();
});
it("sign-up succeeds and mailer stays empty (no cross-feature fanout without bus)", async () => {
await bindAllDevSeed();
const mailer = marketingPagesContainer.get<RecordingMailerService>(
MARKETING_PAGES_SYMBOLS.IMailerService,
);
const signUp = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
await signUp({
username: "testuser",
password: "secret_password",
confirmPassword: "secret_password",
});
// Without a bus, bus?.subscribe() is a no-op so the event handler never
// fires and the mailer receives nothing.
await new Promise((r) => setImmediate(r));
expect(mailer.sent).toEqual([]);
});
});

View File

@@ -0,0 +1,12 @@
import { PageContent } from "@repo/marketing-pages/ui";
import { bindAll } from "../../server/bind-production";
export default async function AboutPage() {
await bindAll();
return (
<main className="px-6 py-8">
<PageContent slug="about" />
</main>
);
}

View File

@@ -0,0 +1,13 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@repo/core-api";
const handler = async (req: Request) => {
return fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => ({}),
});
};
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,17 @@
import { ArticleDetail } from "@repo/blog/ui";
import { bindAll } from "../../../server/bind-production";
type PageProps = {
params: Promise<{ slug: string }>;
};
export default async function BlogPostPage({ params }: PageProps) {
await bindAll();
const { slug } = await params;
return (
<main className="px-6 py-8">
<ArticleDetail slug={slug} />
</main>
);
}

View File

@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import "../styles/app.css";
import { getNonce } from "@repo/core-shared/security/next";
import { bindAll } from "../server/bind-production";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "Template — Next.js",
description: "Clean Architecture Monorepo Template",
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
await bindAll();
const nonce = await getNonce();
return (
<html lang="en">
<head>
{/* nonce exposed to client so instrumentation-client.ts can read it */}
<meta name="csp-nonce" content={nonce} />
</head>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,15 @@
import { ArticleList } from "@repo/blog/ui";
import { bindAll } from "../server/bind-production";
export default async function Home() {
await bindAll();
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h2 className="mb-4 text-2xl font-bold text-foreground">
Latest articles
</h2>
<ArticleList />
</main>
);
}

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Providers } from "./providers";
describe("Providers", () => {
it("renders children", () => {
render(
<Providers>
<div data-testid="child">hi</div>
</Providers>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,7 @@
"use client";
import { NextTrpcProvider } from "@repo/core-trpc/next";
export function Providers({ children }: { children: React.ReactNode }) {
return <NextTrpcProvider>{children}</NextTrpcProvider>;
}

1
apps/web-next/src/css.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
vi.mock("payload", () => ({
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
}));
vi.mock("@repo/blog/di/bind-production", () => ({
bindProductionBlog: vi.fn(),
}));
vi.mock("@repo/auth/di/bind-production", () => ({
bindProductionAuth: vi.fn(),
}));
vi.mock("@repo/marketing-pages/di/bind-production", () => ({
bindProductionMarketingPages: vi.fn(),
}));
vi.mock("@repo/navigation/di/bind-production", () => ({
bindProductionNavigation: vi.fn(),
}));
vi.mock("@repo/media/di/bind-production", () => ({
bindProductionMedia: vi.fn(),
}));
vi.mock("@repo/blog/di/bind-dev-seed", () => ({ bindDevSeedBlog: vi.fn() }));
vi.mock("@repo/auth/di/bind-dev-seed", () => ({ bindDevSeedAuth: vi.fn() }));
vi.mock("@repo/marketing-pages/di/bind-dev-seed", () => ({
bindDevSeedMarketingPages: vi.fn(),
}));
vi.mock("@repo/navigation/di/bind-dev-seed", () => ({
bindDevSeedNavigation: vi.fn(),
}));
vi.mock("@repo/media/di/bind-dev-seed", () => ({ bindDevSeedMedia: vi.fn() }));
vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@repo/core-shared/instrumentation")>();
const mockedOtel = vi.fn(actual.bindOtelInstrumentation);
return {
...actual,
bindOtelInstrumentation: mockedOtel,
// Deprecated alias — points to same spy so existing assertions still work.
bindSentryInstrumentation: mockedOtel,
bindNoopInstrumentation: vi.fn(actual.bindNoopInstrumentation),
};
});
describe("bindAllProduction", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it("binds all five feature production repos", async () => {
const { bindAllProduction } = await import("./bind-production");
const { bindProductionBlog } =
await import("@repo/blog/di/bind-production");
const { bindProductionAuth } =
await import("@repo/auth/di/bind-production");
const { bindProductionMarketingPages } =
await import("@repo/marketing-pages/di/bind-production");
const { bindProductionNavigation } =
await import("@repo/navigation/di/bind-production");
const { bindProductionMedia } =
await import("@repo/media/di/bind-production");
await bindAllProduction();
expect(bindProductionBlog).toHaveBeenCalledOnce();
expect(bindProductionAuth).toHaveBeenCalledOnce();
expect(bindProductionMarketingPages).toHaveBeenCalledOnce();
expect(bindProductionNavigation).toHaveBeenCalledOnce();
expect(bindProductionMedia).toHaveBeenCalledOnce();
});
it("is idempotent via bindAll — second call does not re-bind", async () => {
vi.stubEnv("NODE_ENV", "production");
const { bindAll } = await import("./bind-production");
const { bindProductionBlog } =
await import("@repo/blog/di/bind-production");
await bindAll();
await bindAll();
expect(bindProductionBlog).toHaveBeenCalledOnce();
});
it("passes a Payload-backed queue to each per-feature binder", async () => {
const { bindAllProduction } = await import("./bind-production");
const { bindProductionAuth } =
await import("@repo/auth/di/bind-production");
const { PayloadJobQueue } = await import("@repo/core-shared/jobs");
await bindAllProduction();
const ctx = vi.mocked(bindProductionAuth).mock.calls[0]![0];
expect(ctx.bus).toBeUndefined();
expect(ctx.queue).toBeInstanceOf(PayloadJobQueue);
});
});
describe("bindAllDevSeed", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it("passes an in-memory queue (no bus) to each per-feature dev-seed binder", async () => {
const { bindAllDevSeed } = await import("./bind-production");
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
const { InMemoryJobQueue } = await import("@repo/core-shared/jobs");
await bindAllDevSeed();
const ctx = vi.mocked(bindDevSeedAuth).mock.calls[0]![0];
expect(ctx.bus).toBeUndefined();
expect(ctx.queue).toBeInstanceOf(InMemoryJobQueue);
});
});
describe("bindAll dispatcher", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.unstubAllEnvs();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("USE_DEV_SEED='true' wins → dispatches to bindAllDevSeed", async () => {
vi.stubEnv("USE_DEV_SEED", "true");
vi.stubEnv("NODE_ENV", "production"); // even in production, dev seed wins
const { bindAll } = await import("./bind-production");
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
const { bindProductionBlog } =
await import("@repo/blog/di/bind-production");
await bindAll();
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
expect(bindProductionBlog).not.toHaveBeenCalled();
});
it("NODE_ENV='production' (no override) → dispatches to bindAllProduction", async () => {
vi.stubEnv("NODE_ENV", "production");
const { bindAll } = await import("./bind-production");
const { bindProductionBlog } =
await import("@repo/blog/di/bind-production");
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
await bindAll();
expect(bindProductionBlog).toHaveBeenCalledOnce();
expect(bindDevSeedBlog).not.toHaveBeenCalled();
});
it("NODE_ENV='development' → dispatches to bindAllDevSeed (developer default)", async () => {
vi.stubEnv("NODE_ENV", "development");
const { bindAll } = await import("./bind-production");
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
const { bindProductionBlog } =
await import("@repo/blog/di/bind-production");
await bindAll();
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
expect(bindProductionBlog).not.toHaveBeenCalled();
});
it("USE_DEV_SEED='false' is treated as not-set (only 'true' triggers dev seed)", async () => {
vi.stubEnv("USE_DEV_SEED", "false");
vi.stubEnv("NODE_ENV", "production");
const { bindAll } = await import("./bind-production");
const { bindProductionBlog } =
await import("@repo/blog/di/bind-production");
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
await bindAll();
expect(bindProductionBlog).toHaveBeenCalledOnce();
expect(bindDevSeedBlog).not.toHaveBeenCalled();
});
});
describe("bindAll instrumentation orthogonality", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.unstubAllEnvs();
});
afterEach(() => {
vi.unstubAllEnvs();
});
// In the mock setup above, bindSentryInstrumentation is an alias that points
// to the same spy as bindOtelInstrumentation. Assertions against either name
// verify the same call, which also validates the deprecation alias is wired.
it("DSN absent → bindNoopInstrumentation regardless of NODE_ENV", async () => {
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "");
vi.stubEnv("NODE_ENV", "production");
const { bindAll } = await import("./bind-production");
const { bindNoopInstrumentation, bindOtelInstrumentation } =
await import("@repo/core-shared/instrumentation");
await bindAll();
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
expect(bindOtelInstrumentation).not.toHaveBeenCalled();
});
it("DSN set → bindOtelInstrumentation regardless of NODE_ENV", async () => {
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1");
vi.stubEnv("NODE_ENV", "development");
const { bindAll } = await import("./bind-production");
const { bindNoopInstrumentation, bindOtelInstrumentation } =
await import("@repo/core-shared/instrumentation");
await bindAll();
expect(bindOtelInstrumentation).toHaveBeenCalledOnce();
expect(bindNoopInstrumentation).not.toHaveBeenCalled();
});
it("OTel instrumentation works alongside dev seed (USE_DEV_SEED=true)", async () => {
vi.stubEnv("USE_DEV_SEED", "true");
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1");
const { bindAll } = await import("./bind-production");
const { bindOtelInstrumentation } =
await import("@repo/core-shared/instrumentation");
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
await bindAll();
expect(bindOtelInstrumentation).toHaveBeenCalledOnce();
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
});
it("Noop instrumentation works alongside production binding (DSN unset, NODE_ENV=production)", async () => {
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "");
vi.stubEnv("NODE_ENV", "production");
const { bindAll } = await import("./bind-production");
const { bindNoopInstrumentation } =
await import("@repo/core-shared/instrumentation");
const { bindProductionBlog } =
await import("@repo/blog/di/bind-production");
await bindAll();
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
expect(bindProductionBlog).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,179 @@
// apps/web-next/src/server/bind-production.ts
// SERVER-ONLY: this module imports Payload config and must never be bundled into the browser.
import "reflect-metadata";
import { Container } from "inversify";
import { getPayload } from "payload";
import config from "@repo/core-cms";
import {
bindNoopInstrumentation,
bindOtelInstrumentation,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindProductionContext, BindContext } from "@repo/core-shared/di";
import {
InMemoryJobQueue,
PayloadJobQueue,
type IJobQueue,
} from "@repo/core-shared/jobs";
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { bindProductionAuth } from "@repo/auth/di/bind-production";
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
import { bindProductionNavigation } from "@repo/navigation/di/bind-production";
import { bindProductionMedia } from "@repo/media/di/bind-production";
import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed";
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed";
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
let bindPromise: Promise<void> | null = null;
// Shared container holds TRACER + LOGGER bindings; per-feature containers
// receive references via parameter passing. This separates the instrumentation
// container (one) from feature containers (per-feature, ADR-008).
const sharedContainer = new Container();
let resolvedTracer: ITracer | null = null;
let resolvedLogger: ILogger | null = null;
let resolvedQueue: IJobQueue | null = null;
/** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */
function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
if (resolvedTracer && resolvedLogger) {
return { tracer: resolvedTracer, logger: resolvedLogger };
}
const dsn = process.env.WEB_NEXT_SENTRY_DSN;
const result = dsn
? bindOtelInstrumentation(sharedContainer, { dsn, app: "web-next" })
: bindNoopInstrumentation(sharedContainer);
resolvedTracer = result.tracer;
resolvedLogger = result.logger;
return result;
}
/**
* Production-mode job queue: backed by Payload's job system so ad-hoc jobs go
* through `PayloadJobQueue.enqueue`. Cached after first resolution.
*
* Note: @repo/core-events (IEventBus) is optional — scaffold via
* `pnpm turbo gen core-package events` to re-enable cross-feature event fanout.
*/
async function resolveJobsProduction(): Promise<{ queue: IJobQueue }> {
if (resolvedQueue) return { queue: resolvedQueue };
const resolvedConfig = await config;
const payload = await getPayload({ config: resolvedConfig });
const queue = new PayloadJobQueue(payload);
resolvedQueue = queue;
return { queue };
}
/**
* Dev-seed mode: in-process job queue. Per-feature binders register their job
* handlers via `queue.register(slug, handler)` at bind time so dev/test
* exercises the enqueue path without booting Payload.
*/
function resolveJobsDevSeed(): { queue: IJobQueue } {
if (resolvedQueue) return { queue: resolvedQueue };
const queue = new InMemoryJobQueue();
resolvedQueue = queue;
return { queue };
}
/**
* Production path: swap each feature's mock repository binding for the real
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
* feature via `bindProductionX` exports.
*/
export async function bindAllProduction(): Promise<void> {
const { tracer, logger } = resolveInstrumentation(); // Rule 0
const { queue } = await resolveJobsProduction();
const resolvedConfig = await config;
const ctx: BindProductionContext = {
config: resolvedConfig,
tracer,
logger,
queue,
rateLimit: new NoopRateLimit(),
};
bindProductionAuth(ctx);
bindProductionBlog(ctx);
bindProductionMarketingPages(ctx);
bindProductionNavigation(ctx);
bindProductionMedia(ctx);
}
/**
* Dev-seed path: keep each feature's MockXRepository in place but populate it
* with realistic seed data so the running app shows non-empty UI without
* Payload booted. Mutually exclusive with `bindAllProduction()`.
*/
export async function bindAllDevSeed(): Promise<void> {
const { tracer, logger } = resolveInstrumentation(); // Rule 0
const { queue } = resolveJobsDevSeed();
const ctx: BindContext = {
tracer,
logger,
queue,
rateLimit: new NoopRateLimit(),
};
await bindDevSeedAuth(ctx);
await bindDevSeedBlog(ctx);
await bindDevSeedMarketingPages(ctx);
await bindDevSeedNavigation(ctx);
await bindDevSeedMedia(ctx);
}
/**
* Boot dispatcher: pick the binder based on the environment.
*
* Resolution order (first match wins):
*
* Rule 0 (always): instrumentation (Noop vs Sentry) from WEB_NEXT_SENTRY_DSN
* presence — runs inside both bindAllProduction and
* bindAllDevSeed via resolveInstrumentation().
* Rule 1: USE_DEV_SEED === "true" → dev seed (explicit override)
* Rule 2: NODE_ENV === "production" → real Payload via bindAllProduction
* Rule 3: otherwise → dev seed (developer-friendly default)
*
* When @repo/core-events is scaffolded via `pnpm turbo gen core-package events`,
* extend to construct IEventBus and pass it via ctx.bus to per-feature binders.
* When @repo/core-realtime is scaffolded, extend to accept realtime deps
* (IRealtimeBroadcaster, IRealtimeHandlerRegistry) and pass them through.
*/
export function bindAll(): Promise<void> {
if (bindPromise) return bindPromise;
if (process.env.USE_DEV_SEED === "false") {
bindPromise = bindAllProduction();
} else if (process.env.USE_DEV_SEED === "true") {
bindPromise = bindAllDevSeed();
} else if (process.env.NODE_ENV === "production") {
bindPromise = bindAllProduction();
} else {
bindPromise = bindAllDevSeed();
}
return bindPromise;
}
/** Test-only resets — not exported via package. Used by bind-production.test.ts. */
export function __resetBindStateForTests(): void {
bindPromise = null;
resolvedTracer = null;
resolvedLogger = null;
resolvedQueue = null;
}
/** Test-only accessor for resolved instrumentation. */
export function __getInstrumentationForTests(): {
tracer: ITracer | null;
logger: ILogger | null;
} {
return { tracer: resolvedTracer, logger: resolvedLogger };
}

View File

@@ -0,0 +1,10 @@
@import "tailwindcss";
@source "../../../../packages/core-ui/src";
@source "../../../../packages/navigation/src";
@source "../../../../packages/blog/src";
@source "../../../../packages/marketing-pages/src";
@source "../../../../packages/media/src";
@source "../../../../packages/auth/src";
@source "../";
@import "../../../../packages/core-ui/src/styles/theme.css";

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -0,0 +1,21 @@
{
"extends": "@repo/core-typescript/nextjs.json",
"compilerOptions": {
"paths": {
"@/*": [
"./src/*"
]
},
"allowJs": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": [
"next-env.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because one or more lines are too long

4
apps/web-next/turbo.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["app"]
}

View File

@@ -0,0 +1,8 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
export default mergeConfig(jsdomVitestConfig, {
esbuild: { jsx: "automatic" },
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});