Initial commit
This commit is contained in:
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";
|
||||
Reference in New Issue
Block a user