Initial commit
This commit is contained in:
97
apps/cms/AGENTS.md
Normal file
97
apps/cms/AGENTS.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# AGENTS.md — apps/cms
|
||||
|
||||
**Thin shell** hosting the Payload CMS admin panel via Next.js. All CMS configuration (collections, globals, hooks, access control, `payload.config.ts`) lives in `@repo/core-cms`, which aggregates collections from feature packages.
|
||||
|
||||
## Purpose
|
||||
|
||||
This app exists solely to serve the Payload Admin UI. It contains no custom CMS code beyond Next.js routing boilerplate. All business knowledge lives in feature packages (`@repo/auth`, `@repo/blog`, etc.), which export their collections/globals via subpath exports (`.../cms`). `@repo/core-cms` composes them into a single Payload config.
|
||||
|
||||
## Port: 3001
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres # Start PostgreSQL on port 5432
|
||||
pnpm dev --filter @repo/cms # http://localhost:3001/admin
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `next.config.mjs` | Minimal config wrapped with `withPayload()` from `@payloadcms/next` |
|
||||
| `tsconfig.json` | TypeScript config with `@payload-config` path alias |
|
||||
| `src/app/(payload)/layout.tsx` | Auto-generated Payload root layout (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/admin/[[...segments]]/page.tsx` | Auto-generated catch-all admin page (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/importMap.js` | Auto-generated Payload import map (DO NOT MODIFY) |
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- **NEVER** add collections, globals, or hooks in this app — put them in feature packages
|
||||
- **NEVER** create custom CMS logic here — use `@repo/core-cms`
|
||||
- **NEVER** modify auto-generated files under `src/app/(payload)/`
|
||||
- All Payload config changes go in `packages/core-cms/src/payload.config.ts`
|
||||
|
||||
## @payload-config Alias
|
||||
|
||||
The `tsconfig.json` points to `@repo/core-cms`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@payload-config": ["../../packages/core-cms/src/payload.config.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When Payload imports `@payload-config`, it resolves to the composed config from `@repo/core-cms`, which in turn imports feature collections.
|
||||
|
||||
## Composition flow
|
||||
|
||||
```
|
||||
Feature 1 (@repo/blog)
|
||||
└─ src/integrations/cms/collections/articles.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
Feature 2 (@repo/auth)
|
||||
└─ src/integrations/cms/collections/users.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
Feature 3 (@repo/navigation)
|
||||
└─ src/integrations/cms/globals/header.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
Core CMS (@repo/core-cms)
|
||||
└─ src/payload.config.ts
|
||||
imports all feature /cms exports
|
||||
calls buildConfig({ collections, globals })
|
||||
|
||||
This app (@repo/cms)
|
||||
└─ src/app/(payload)/layout.tsx
|
||||
loads config from @payload-config
|
||||
Payload CLI auto-generates admin routes
|
||||
```
|
||||
|
||||
## Type Generation
|
||||
|
||||
After adding/modifying collections in any feature's `/cms` folder:
|
||||
|
||||
```bash
|
||||
cd apps/cms && pnpm generate:types
|
||||
# Regenerates packages/core-cms/src/generated-types.ts
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/core-cms` | Payload config + buildConfig |
|
||||
| `@payloadcms/next` | Next.js integration for Payload |
|
||||
| `payload` | Payload CMS core |
|
||||
| `next` | Next.js 15 framework |
|
||||
| `sharp` | Image processing |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Feature collections:** each feature's `src/integrations/cms/` folder
|
||||
- **CMS composition:** `packages/core-cms/AGENTS.md`
|
||||
3
apps/cms/eslint.config.js
Normal file
3
apps/cms/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
23
apps/cms/instrumentation.ts
Normal file
23
apps/cms/instrumentation.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// apps/cms/instrumentation.ts
|
||||
// CMS is server-only (Payload admin UI). No instrumentation-client.ts here —
|
||||
// Payload admin UI bundling is opinionated and the public DSN flow is
|
||||
// out-of-scope per spec §8.
|
||||
//
|
||||
// Initializes the OTel SDK here so PII scrub processors are active from the
|
||||
// very first request — before bindAll() fires (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["CMS_SENTRY_DSN"] ?? "",
|
||||
serviceName: "cms",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
});
|
||||
}
|
||||
}
|
||||
18
apps/cms/middleware.ts
Normal file
18
apps/cms/middleware.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { buildSecurityHeaders } from "@repo/core-shared/security";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(_request: NextRequest): NextResponse {
|
||||
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
|
||||
const secHeaders = buildSecurityHeaders({ mode });
|
||||
|
||||
const response = NextResponse.next();
|
||||
for (const [name, value] of Object.entries(secHeaders)) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
6
apps/cms/next-env.d.ts
vendored
Normal file
6
apps/cms/next-env.d.ts
vendored
Normal 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.
|
||||
14
apps/cms/next.config.mjs
Normal file
14
apps/cms/next.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import { withPayload } from "@payloadcms/next/withPayload";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default withSentryConfig(withPayload(nextConfig), {
|
||||
silent: process.env.CI !== "true",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT_CMS,
|
||||
hideSourceMaps: true,
|
||||
disableLogger: true,
|
||||
});
|
||||
37
apps/cms/package.json
Normal file
37
apps/cms/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@repo/cms",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'CMS build requires database — use docker compose or pnpm dev'",
|
||||
"dev": "next dev --port 3001",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"generate:types": "payload generate:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/next": "^3.14.0",
|
||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||
"@payloadcms/ui": "^3.14.0",
|
||||
"@repo/core-cms": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@sentry/nextjs": "^10.51.0",
|
||||
"next": "^15.3.0",
|
||||
"payload": "^3.14.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sass": "^1.99.0",
|
||||
"sharp": "^0.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, importMap, params, searchParams });
|
||||
|
||||
export default NotFound;
|
||||
23
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
23
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
52
apps/cms/src/app/(payload)/admin/importMap.js
Normal file
52
apps/cms/src/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type {Record<string, any>} */
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
19
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
19
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
6
apps/cms/src/app/(payload)/api/graphql/route.ts
Normal file
6
apps/cms/src/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import { GRAPHQL_POST } from "@payloadcms/next/routes";
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
1
apps/cms/src/app/(payload)/custom.scss
Normal file
1
apps/cms/src/app/(payload)/custom.scss
Normal file
@@ -0,0 +1 @@
|
||||
// Custom admin panel styles
|
||||
35
apps/cms/src/app/(payload)/layout.tsx
Normal file
35
apps/cms/src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import type { ServerFunctionClient } from "payload";
|
||||
import { handleServerFunctions, RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import { importMap } from "./admin/importMap.js";
|
||||
import "./custom.scss";
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
"use server";
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout
|
||||
config={config}
|
||||
importMap={importMap}
|
||||
serverFunction={serverFunction}
|
||||
>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
81
apps/cms/src/middleware.test.ts
Normal file
81
apps/cms/src/middleware.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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("cms 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("does not set a nonce header", () => {
|
||||
middleware(makeRequest());
|
||||
|
||||
expect(mock._store.has("x-nonce")).toBe(false);
|
||||
});
|
||||
|
||||
it("CSP is permissive in development mode", () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
|
||||
middleware(makeRequest());
|
||||
|
||||
const csp = mock._store.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it("CSP uses strict-dynamic in production mode", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
middleware(makeRequest());
|
||||
|
||||
const csp = mock._store.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'strict-dynamic'");
|
||||
});
|
||||
});
|
||||
20
apps/cms/src/payload.config.test.ts
Normal file
20
apps/cms/src/payload.config.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import config from "./payload.config";
|
||||
|
||||
describe("CMS app payload.config", () => {
|
||||
it("registers all feature collections", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
|
||||
expect(slugs).toEqual(
|
||||
expect.arrayContaining(["users", "articles", "pages", "media"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers all feature globals", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||
expect(slugs).toEqual(
|
||||
expect.arrayContaining(["site-settings", "header"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
3
apps/cms/src/payload.config.ts
Normal file
3
apps/cms/src/payload.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Re-export Payload config from @repo/core-cms.
|
||||
// This file exists so @payload-config resolves correctly in the CMS app.
|
||||
export { default } from "@repo/core-cms";
|
||||
24
apps/cms/tsconfig.json
Normal file
24
apps/cms/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"./src/payload.config.ts"
|
||||
]
|
||||
},
|
||||
"allowJs": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
1
apps/cms/tsconfig.tsbuildinfo
Normal file
1
apps/cms/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
4
apps/cms/turbo.json
Normal file
4
apps/cms/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app"]
|
||||
}
|
||||
7
apps/cms/vitest.config.ts
Normal file
7
apps/cms/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
|
||||
});
|
||||
2
apps/storybook/.eslintignore
Normal file
2
apps/storybook/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
storybook-static
|
||||
.storybook/storybook-static
|
||||
1
apps/storybook/.storybook/css.d.ts
vendored
Normal file
1
apps/storybook/.storybook/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
17
apps/storybook/.storybook/main.ts
Normal file
17
apps/storybook/.storybook/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: "@storybook/react-vite",
|
||||
stories: ["../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"],
|
||||
addons: ["@storybook/addon-essentials"],
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
async viteFinal(config) {
|
||||
const tailwindPlugin = await import("@tailwindcss/vite");
|
||||
config.plugins = [tailwindPlugin.default(), ...(config.plugins || [])];
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
apps/storybook/.storybook/preview.ts
Normal file
15
apps/storybook/.storybook/preview.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import "./storybook.css";
|
||||
import type { Preview } from "@storybook/react";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
4
apps/storybook/.storybook/storybook.css
Normal file
4
apps/storybook/.storybook/storybook.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../../packages/core-ui/src";
|
||||
|
||||
@import "../../../packages/core-ui/src/styles/theme.css";
|
||||
136
apps/storybook/AGENTS.md
Normal file
136
apps/storybook/AGENTS.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# AGENTS.md — apps/storybook
|
||||
|
||||
Centralized Storybook instance for visual component development, documentation, and MCP integration for AI agents. Currently ships with an empty stories list — scaffold `@repo/core-ui` first to populate it.
|
||||
|
||||
## Purpose
|
||||
|
||||
Visual testing and documentation hub for the design system. When `@repo/core-ui` is scaffolded, stories live colocated with their components there. Storybook serves as the single source of truth for component usage.
|
||||
|
||||
> **core-ui is optional.** Scaffold it with `pnpm turbo gen core-package ui`, then add the stories glob and CSS import (see next-steps printed by the generator).
|
||||
|
||||
## Port: 6006
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/storybook # http://localhost:6006
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### `.storybook/main.ts`
|
||||
|
||||
Stories are empty by default. After scaffolding `@repo/core-ui`, add the glob:
|
||||
|
||||
```typescript
|
||||
const config: StorybookConfig = {
|
||||
framework: "@storybook/react-vite",
|
||||
stories: ["../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"],
|
||||
addons: ["@storybook/addon-essentials"],
|
||||
docs: { autodocs: "tag" },
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig } = await import("vite");
|
||||
const tailwindPlugin = await import("@tailwindcss/vite");
|
||||
return mergeConfig(config, {
|
||||
plugins: [tailwindPlugin.default()],
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Key settings:
|
||||
- **`stories` glob** — empty by default; add `"../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"` after scaffolding core-ui
|
||||
- **`viteFinal`** — adds Tailwind v4 plugin so classes render in Storybook
|
||||
- **`autodocs: "tag"`** — auto-generates docs for tagged stories
|
||||
|
||||
### `.storybook/preview.ts`
|
||||
|
||||
After scaffolding `@repo/core-ui`, import global styles here:
|
||||
|
||||
```typescript
|
||||
import type { Preview } from "@storybook/react";
|
||||
import "@repo/core-ui/styles/globals.css";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Story Organization
|
||||
|
||||
Stories are organized by Atomic Design level via the `title` field:
|
||||
|
||||
| Level | Title format | Sidebar path |
|
||||
|---|---|---|
|
||||
| Atom | `"Atoms/{ComponentName}"` | Atoms > ComponentName |
|
||||
| Molecule | `"Molecules/{ComponentName}"` | Molecules > ComponentName |
|
||||
| Organism | `"Organisms/{ComponentName}"` | Organisms > ComponentName |
|
||||
| Template | `"Templates/{ComponentName}"` | Templates > ComponentName |
|
||||
|
||||
Example story file (after scaffolding core-ui at `packages/core-ui/src/atoms/button/button.stories.tsx`):
|
||||
|
||||
```typescript
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "./button";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: "Click me" },
|
||||
};
|
||||
|
||||
export const Variant: Story = {
|
||||
args: { children: "Secondary", variant: "secondary" },
|
||||
};
|
||||
```
|
||||
|
||||
## MCP Integration
|
||||
|
||||
When Storybook runs, the MCP endpoint is available at:
|
||||
|
||||
```
|
||||
http://localhost:6006/mcp
|
||||
```
|
||||
|
||||
### Available tools:
|
||||
|
||||
- **`list-all-documentation`** — Lists all component stories and their properties
|
||||
- **`get-documentation`** — Gets detailed component info (props, variants, usage examples)
|
||||
- **`run-story-tests`** — Validates story rendering
|
||||
|
||||
### Before building new components:
|
||||
|
||||
1. Query `list-all-documentation` to check if a similar component exists
|
||||
2. Query `get-documentation` to understand existing props and variants
|
||||
3. After creating: `run-story-tests` to validate
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/core-ui` | Component source + stories (optional — scaffold with `pnpm turbo gen core-package ui`) |
|
||||
| `@storybook/react-vite` | Storybook with Vite bundler |
|
||||
| `@storybook/addon-essentials` | Controls, Actions, Docs, Backgrounds |
|
||||
| `@tailwindcss/vite` | Vite plugin for Tailwind v4 |
|
||||
| `storybook` | Storybook CLI + dev server |
|
||||
| `tailwindcss` | Tailwind CSS v4 |
|
||||
| `vite` | Build tool |
|
||||
| `react` / `react-dom` | React 19 |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Component source (when scaffolded):** `packages/core-ui/AGENTS.md`
|
||||
- **Scaffold core-ui:** `pnpm turbo gen core-package ui`
|
||||
- **Storybook docs:** `.storybook/` folder
|
||||
3
apps/storybook/eslint.config.js
Normal file
3
apps/storybook/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
35
apps/storybook/package.json
Normal file
35
apps/storybook/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@repo/storybook",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'Storybook build — use pnpm dev for development'",
|
||||
"build:storybook": "storybook build",
|
||||
"build-storybook": "storybook build",
|
||||
"dev": "storybook dev -p 6006",
|
||||
"lint": "eslint .",
|
||||
"test-storybook": "test-storybook --url http://localhost:6006",
|
||||
"test:stories": "concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'pnpm exec http-server storybook-static --port 6006 --silent' 'pnpm exec wait-on tcp:6006 && pnpm test-storybook'"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@storybook/addon-essentials": "^8.6.0",
|
||||
"@storybook/react": "^8.6.0",
|
||||
"@storybook/react-vite": "^8.6.0",
|
||||
"@storybook/test-runner": "^0.19.1",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"http-server": "^14.1.0",
|
||||
"playwright": "^1.52.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"storybook": "^8.6.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"vite": "^6.3.0",
|
||||
"wait-on": "^8.0.0"
|
||||
}
|
||||
}
|
||||
13
apps/storybook/test-runner.config.ts
Normal file
13
apps/storybook/test-runner.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { TestRunnerConfig } from "@storybook/test-runner";
|
||||
|
||||
const config: TestRunnerConfig = {
|
||||
async preVisit(page) {
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
throw new Error(`Console error in story: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
51
apps/storybook/tests/visual.spec.ts
Normal file
51
apps/storybook/tests/visual.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Iterates every story registered in Storybook and takes a screenshot.
|
||||
*
|
||||
* Storybook exposes its story manifest at /index.json (Storybook 7+). For
|
||||
* each entry where `type === "story"`, we navigate to the iframe URL and
|
||||
* snapshot.
|
||||
*
|
||||
* Today the index is empty (no components in the repo). The harness still
|
||||
* runs — it just finds zero stories. The moment a story lands, the
|
||||
* baseline is captured on first run and subsequent runs diff against it.
|
||||
*/
|
||||
type StoryEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
name: string;
|
||||
type: "story" | "docs";
|
||||
};
|
||||
|
||||
async function fetchStoryIndex(baseURL: string): Promise<StoryEntry[]> {
|
||||
const res = await fetch(`${baseURL}/index.json`);
|
||||
if (!res.ok) return [];
|
||||
const json = (await res.json()) as {
|
||||
entries?: Record<string, StoryEntry>;
|
||||
};
|
||||
return Object.values(json.entries ?? {}).filter((e) => e.type === "story");
|
||||
}
|
||||
|
||||
test.describe("Storybook visual regression", () => {
|
||||
test("captures a screenshot for every registered story", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
const stories = await fetchStoryIndex(baseURL!);
|
||||
if (stories.length === 0) {
|
||||
test.skip(
|
||||
true,
|
||||
"No stories registered yet — visual regression harness is inactive until the first story lands.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const story of stories) {
|
||||
await test.step(`${story.title} — ${story.name}`, async () => {
|
||||
await page.goto(`/iframe.html?id=${story.id}&viewMode=story`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page).toHaveScreenshot(`${story.id}.png`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
16
apps/storybook/tsconfig.json
Normal file
16
apps/storybook/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/react-library.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".storybook/**/*.ts",
|
||||
"*.ts",
|
||||
"*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
4
apps/storybook/turbo.json
Normal file
4
apps/storybook/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app"]
|
||||
}
|
||||
111
apps/web-next/AGENTS.md
Normal file
111
apps/web-next/AGENTS.md
Normal 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`
|
||||
20
apps/web-next/e2e/blog-post.spec.ts
Normal file
20
apps/web-next/e2e/blog-post.spec.ts
Normal 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();
|
||||
});
|
||||
12
apps/web-next/e2e/home.spec.ts
Normal file
12
apps/web-next/e2e/home.spec.ts
Normal 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);
|
||||
});
|
||||
10
apps/web-next/e2e/marketing-page.spec.ts
Normal file
10
apps/web-next/e2e/marketing-page.spec.ts
Normal 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();
|
||||
});
|
||||
11
apps/web-next/eslint.config.js
Normal file
11
apps/web-next/eslint.config.js
Normal 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",
|
||||
},
|
||||
},
|
||||
];
|
||||
19
apps/web-next/instrumentation-client.ts
Normal file
19
apps/web-next/instrumentation-client.ts
Normal 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(),
|
||||
});
|
||||
22
apps/web-next/instrumentation.ts
Normal file
22
apps/web-next/instrumentation.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
10
apps/web-next/middleware.ts
Normal file
10
apps/web-next/middleware.ts
Normal 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
6
apps/web-next/next-env.d.ts
vendored
Normal 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.
|
||||
31
apps/web-next/next.config.mjs
Normal file
31
apps/web-next/next.config.mjs
Normal 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,
|
||||
});
|
||||
54
apps/web-next/package.json
Normal file
54
apps/web-next/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
26
apps/web-next/playwright.config.ts
Normal file
26
apps/web-next/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
5
apps/web-next/postcss.config.mjs
Normal file
5
apps/web-next/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
23
apps/web-next/server.ts
Normal file
23
apps/web-next/server.ts
Normal 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}`);
|
||||
});
|
||||
94
apps/web-next/src/__tests__/middleware.test.ts
Normal file
94
apps/web-next/src/__tests__/middleware.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
50
apps/web-next/src/__tests__/sign-up-welcome-email.test.ts
Normal file
50
apps/web-next/src/__tests__/sign-up-welcome-email.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
12
apps/web-next/src/app/about/page.tsx
Normal file
12
apps/web-next/src/app/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
apps/web-next/src/app/api/trpc/[trpc]/route.ts
Normal file
13
apps/web-next/src/app/api/trpc/[trpc]/route.ts
Normal 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 };
|
||||
17
apps/web-next/src/app/blog/[slug]/page.tsx
Normal file
17
apps/web-next/src/app/blog/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/web-next/src/app/layout.tsx
Normal file
31
apps/web-next/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/web-next/src/app/page.tsx
Normal file
15
apps/web-next/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/web-next/src/app/providers.test.tsx
Normal file
14
apps/web-next/src/app/providers.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
7
apps/web-next/src/app/providers.tsx
Normal file
7
apps/web-next/src/app/providers.tsx
Normal 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
1
apps/web-next/src/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
250
apps/web-next/src/server/bind-production.test.ts
Normal file
250
apps/web-next/src/server/bind-production.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
179
apps/web-next/src/server/bind-production.ts
Normal file
179
apps/web-next/src/server/bind-production.ts
Normal 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 };
|
||||
}
|
||||
10
apps/web-next/src/styles/app.css
Normal file
10
apps/web-next/src/styles/app.css
Normal 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";
|
||||
4
apps/web-next/test-results/.last-run.json
Normal file
4
apps/web-next/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
21
apps/web-next/tsconfig.json
Normal file
21
apps/web-next/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
apps/web-next/tsconfig.tsbuildinfo
Normal file
1
apps/web-next/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
4
apps/web-next/turbo.json
Normal file
4
apps/web-next/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app"]
|
||||
}
|
||||
8
apps/web-next/vitest.config.ts
Normal file
8
apps/web-next/vitest.config.ts
Normal 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") } },
|
||||
});
|
||||
114
apps/web-tanstack/AGENTS.md
Normal file
114
apps/web-tanstack/AGENTS.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# AGENTS.md — apps/web-tanstack
|
||||
|
||||
TanStack Start reference application using TanStack Router with file-based routing. Demonstrates that feature packages are framework-agnostic by consuming the same features as Next.js (via `@repo/core-api`) using TanStack's architecture instead.
|
||||
|
||||
## Purpose
|
||||
|
||||
Proof that features are framework-portable. This app consumes the exact same feature packages as `apps/web-next`, but through TanStack Start's server/client architecture instead of Next.js App Router. Once `@repo/core-trpc` is scaffolded, it can use the same tRPC routers via `TanstackTrpcProvider`.
|
||||
|
||||
## Port: 3002
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/web-tanstack # http://localhost:3002
|
||||
```
|
||||
|
||||
When `@repo/core-trpc` is installed, this app requires a tRPC endpoint (from `apps/web-next`):
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/web-next # Serves tRPC at http://localhost:3000/api/trpc
|
||||
pnpm dev --filter @repo/web-tanstack # http://localhost:3002
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/routes/__root.tsx` | Root layout — wraps all routes with `<Outlet />` (add `<TanstackTrpcProvider>` after scaffolding `@repo/core-trpc`) |
|
||||
| `src/routes/index.tsx` | Home page (`/`) |
|
||||
| `src/routes/blog/index.tsx` | Blog listing (`/blog`) |
|
||||
| `src/routes/blog/$slug.tsx` | Dynamic blog post (`/blog/:slug`) |
|
||||
| `e2e/` | Playwright end-to-end tests |
|
||||
|
||||
## File-Based Routing
|
||||
|
||||
TanStack Router uses file-based routing where file paths map directly to URL routes:
|
||||
|
||||
| File | URL |
|
||||
|---|---|
|
||||
| `src/routes/__root.tsx` | Root (all routes) |
|
||||
| `src/routes/index.tsx` | `/` |
|
||||
| `src/routes/blog/index.tsx` | `/blog` |
|
||||
| `src/routes/blog/$slug.tsx` | `/blog/:slug` |
|
||||
|
||||
Naming conventions:
|
||||
- `__root.tsx` — special root layout
|
||||
- `index.tsx` — index route for its directory
|
||||
- `$paramName.tsx` — dynamic segment
|
||||
|
||||
## tRPC Setup (optional)
|
||||
|
||||
`@repo/core-trpc` is not installed by default. After scaffolding with `pnpm turbo gen core-package trpc`:
|
||||
|
||||
1. Update `src/routes/__root.tsx`:
|
||||
|
||||
```typescript
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { TanstackTrpcProvider } from "@repo/core-trpc/tanstack";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<TanstackTrpcProvider trpcUrl="http://localhost:3000/api/trpc">
|
||||
<Outlet />
|
||||
</TanstackTrpcProvider>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
Note: `trpcUrl` must point to a running tRPC endpoint (e.g., from `apps/web-next`).
|
||||
|
||||
2. Fetch data in routes:
|
||||
|
||||
```typescript
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useTRPC } from "@repo/core-trpc";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const Route = createFileRoute("/blog/$slug")({
|
||||
component: BlogPostPage,
|
||||
});
|
||||
|
||||
function BlogPostPage() {
|
||||
const { slug } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { data, isLoading } = useQuery(trpc.blog.getBySlug.queryOptions({ slug }));
|
||||
if (isLoading) return <p>Loading...</p>;
|
||||
return <article>{data?.title}</article>;
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/core-api` | AppRouter type |
|
||||
| `@repo/core-trpc/tanstack` | TanStack tRPC client + provider (optional — scaffold first) |
|
||||
| `@repo/core-ui` | Design system components (optional — scaffold first) |
|
||||
| `@tanstack/react-router` | File-based routing |
|
||||
| `@tanstack/react-query` | Data fetching + caching |
|
||||
| `react` / `react-dom` | React 19 runtime |
|
||||
|
||||
## Test conventions
|
||||
|
||||
- e2e tests in `e2e/` folder: `*.spec.ts`
|
||||
- Playwright config in `e2e/playwright.config.ts`
|
||||
- Run: `pnpm test:e2e` (both Next.js and TanStack)
|
||||
|
||||
Parallel to `apps/web-next` e2e: validates that features work across frameworks.
|
||||
|
||||
## 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`
|
||||
- **Next.js app (serves tRPC):** `apps/web-next/AGENTS.md`
|
||||
37
apps/web-tanstack/app.config.ts
Normal file
37
apps/web-tanstack/app.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// apps/web-tanstack/app.config.ts
|
||||
// TanStack Start / Nitro server configuration.
|
||||
// Registers the core-shared security headers middleware so every response
|
||||
// emits the six security headers and a per-request CSP nonce.
|
||||
//
|
||||
// Wire-up pattern (Nitro/H3 server hook):
|
||||
// withSecurityHeaders() generates nonce + builds six headers.
|
||||
// setHeader calls forward them to the response.
|
||||
// req.headers["x-nonce"] is set so downstream loaders can call
|
||||
// getNonce(event.node.req) from @repo/core-shared/security/tanstack.
|
||||
|
||||
import { defineConfig } from "@tanstack/start/config";
|
||||
import { withSecurityHeaders } from "@repo/core-shared/security/tanstack";
|
||||
|
||||
interface H3SecurityEvent {
|
||||
node: {
|
||||
req: { headers: Record<string, string | string[] | undefined> };
|
||||
res: { setHeader: (name: string, value: string) => void };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitro/H3 server hook: emits six security headers on every response and
|
||||
* forwards the per-request nonce in req.headers["x-nonce"] for downstream
|
||||
* access via getNonce() from @repo/core-shared/security/tanstack.
|
||||
*/
|
||||
export function applySecurityHeaders(event: H3SecurityEvent): void {
|
||||
const { nonce, headers } = withSecurityHeaders();
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
event.node.res.setHeader(k, v);
|
||||
}
|
||||
event.node.req.headers["x-nonce"] = nonce;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
server: { hooks: { request: applySecurityHeaders } },
|
||||
});
|
||||
12
apps/web-tanstack/e2e/home.spec.ts
Normal file
12
apps/web-tanstack/e2e/home.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.skip(
|
||||
"TanStack home renders site name + nav (pending TanStack Start runtime)",
|
||||
async ({ page }) => {
|
||||
// Pending: web-tanstack has no dev server yet. When the TanStack Start
|
||||
// runtime is wired (future plan), update the playwright.config.ts
|
||||
// webServer to start it on port 3002 and remove this skip.
|
||||
await page.goto("http://localhost:3002");
|
||||
await expect(page.locator("h1").first()).toBeVisible();
|
||||
},
|
||||
);
|
||||
3
apps/web-tanstack/eslint.config.js
Normal file
3
apps/web-tanstack/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
44
apps/web-tanstack/package.json
Normal file
44
apps/web-tanstack/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@repo/web-tanstack",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'placeholder — TanStack Start build configured in later plan'",
|
||||
"dev": "echo 'placeholder'",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/blog": "workspace:*",
|
||||
"@repo/core-api": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@repo/marketing-pages": "workspace:*",
|
||||
"@repo/navigation": "workspace:*",
|
||||
"@sentry/node": "^10.52.0",
|
||||
"@sentry/react": "^10.52.0",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-router": "^1.120.0",
|
||||
"@tanstack/start": "^1.120.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"vinxi": "0.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@sentry/vite-plugin": "^5.2.1",
|
||||
"@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",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
21
apps/web-tanstack/playwright.config.ts
Normal file
21
apps/web-tanstack/playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
// No webServer: web-tanstack tests run against the shared web-next backend
|
||||
// (port 3000). When TanStack Start runtime is wired in a future plan, add
|
||||
// a webServer block here pointing at port 3002.
|
||||
});
|
||||
50
apps/web-tanstack/src/instrumentation-client.test.ts
Normal file
50
apps/web-tanstack/src/instrumentation-client.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// apps/web-tanstack/src/instrumentation-client.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Hoist the mock so it's active when instrumentation-client runs its
|
||||
// top-level initSentryClientReact call on import.
|
||||
const initSentryClientReactMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@repo/core-shared/instrumentation/sentry/init-client-react", () => ({
|
||||
initSentryClientReact: initSentryClientReactMock,
|
||||
}));
|
||||
|
||||
describe("instrumentation-client", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("passes nonce from csp-nonce meta tag to initSentryClientReact", async () => {
|
||||
const meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "csp-nonce");
|
||||
meta.setAttribute("content", "test-nonce-xyz");
|
||||
document.head.appendChild(meta);
|
||||
|
||||
try {
|
||||
await import("./instrumentation-client");
|
||||
} finally {
|
||||
document.head.removeChild(meta);
|
||||
}
|
||||
|
||||
expect(initSentryClientReactMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nonce: "test-nonce-xyz" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes empty string nonce when no csp-nonce meta tag is present", async () => {
|
||||
await import("./instrumentation-client");
|
||||
|
||||
expect(initSentryClientReactMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nonce: "" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes web-tanstack as the app tag", async () => {
|
||||
await import("./instrumentation-client");
|
||||
|
||||
expect(initSentryClientReactMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ app: "web-tanstack" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
18
apps/web-tanstack/src/instrumentation-client.ts
Normal file
18
apps/web-tanstack/src/instrumentation-client.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// apps/web-tanstack/src/instrumentation-client.ts
|
||||
// Browser-entry hook. Imported at the top of the client entry file.
|
||||
import { initSentryClientReact } from "@repo/core-shared/instrumentation/sentry/init-client-react";
|
||||
|
||||
function getNonce(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
return (
|
||||
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
initSentryClientReact({
|
||||
dsn: import.meta.env["VITE_WEB_TANSTACK_SENTRY_DSN"],
|
||||
app: "web-tanstack",
|
||||
release: import.meta.env["VITE_GIT_COMMIT_SHA"],
|
||||
nonce: getNonce(),
|
||||
});
|
||||
12
apps/web-tanstack/src/instrumentation.ts
Normal file
12
apps/web-tanstack/src/instrumentation.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// apps/web-tanstack/src/instrumentation.ts
|
||||
// Server-entry hook. Imported at the top of the server entry file before any
|
||||
// request handler runs. Initializes the OTel SDK here so PII scrub processors
|
||||
// are active from the very first request (C1 fix — closes the startup window
|
||||
// where Sentry auto-instrumentation could send unscrubbed errors).
|
||||
import { initOtelServerNode } from "@repo/core-shared/instrumentation/otel/init-server-node";
|
||||
|
||||
initOtelServerNode({
|
||||
dsn: process.env["WEB_TANSTACK_SENTRY_DSN"] ?? "",
|
||||
serviceName: "web-tanstack",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
});
|
||||
29
apps/web-tanstack/src/routes/__root.test.tsx
Normal file
29
apps/web-tanstack/src/routes/__root.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
// Mock @tanstack/react-router so we don't need a full router context.
|
||||
// useLoaderData is supplied so the component can read the nonce from loader data.
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createRootRoute: vi.fn((opts: { component: React.ComponentType }) => ({
|
||||
options: { component: opts.component },
|
||||
useLoaderData: () => ({ nonce: "test-nonce-abc" }),
|
||||
})),
|
||||
Outlet: () => <div data-testid="outlet" />,
|
||||
}));
|
||||
|
||||
describe("Root route", () => {
|
||||
it("renders csp-nonce meta tag and Outlet", async () => {
|
||||
const { Route } = await import("./__root");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const RootComponent = (Route as any).options
|
||||
.component as React.ComponentType;
|
||||
|
||||
render(<RootComponent />);
|
||||
|
||||
expect(screen.getByTestId("outlet")).toBeInTheDocument();
|
||||
const metaTag = document.querySelector('meta[name="csp-nonce"]');
|
||||
expect(metaTag).toBeInTheDocument();
|
||||
expect(metaTag?.getAttribute("content")).toBe("test-nonce-abc");
|
||||
});
|
||||
});
|
||||
25
apps/web-tanstack/src/routes/__root.tsx
Normal file
25
apps/web-tanstack/src/routes/__root.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { getNonce } from "@repo/core-shared/security/tanstack";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
loader: async () => {
|
||||
try {
|
||||
// Server-side during SSR: read nonce set by applySecurityHeaders middleware.
|
||||
// Fails gracefully on client-side navigation (nonce already in DOM from SSR).
|
||||
const { getEvent } = await import("vinxi/http");
|
||||
return { nonce: getNonce(getEvent().node.req) };
|
||||
} catch {
|
||||
return { nonce: "" };
|
||||
}
|
||||
},
|
||||
component: () => {
|
||||
const { nonce } = Route.useLoaderData();
|
||||
return (
|
||||
<>
|
||||
{/* nonce exposed to client so instrumentation-client.ts can read it */}
|
||||
<meta name="csp-nonce" content={nonce} />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
14
apps/web-tanstack/src/routes/index.tsx
Normal file
14
apps/web-tanstack/src/routes/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Route = createFileRoute("/" as any)({
|
||||
component: Home,
|
||||
});
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<main>
|
||||
<p>This page is rendered by TanStack Router and consumes the same feature packages as the Next.js app.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
apps/web-tanstack/src/vite-env.d.ts
vendored
Normal file
11
apps/web-tanstack/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
// Minimal Vite-style env typing for the instrumentation-client entry.
|
||||
// When the full TanStack Start / Vite build is wired in a later plan,
|
||||
// replace this with `/// <reference types="vite/client" />`.
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_WEB_TANSTACK_SENTRY_DSN?: string;
|
||||
readonly VITE_GIT_COMMIT_SHA?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
4
apps/web-tanstack/test-results/.last-run.json
Normal file
4
apps/web-tanstack/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
13
apps/web-tanstack/tsconfig.json
Normal file
13
apps/web-tanstack/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
4
apps/web-tanstack/turbo.json
Normal file
4
apps/web-tanstack/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app"]
|
||||
}
|
||||
8
apps/web-tanstack/vitest.config.ts
Normal file
8
apps/web-tanstack/vitest.config.ts
Normal 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") } },
|
||||
});
|
||||
Reference in New Issue
Block a user