@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
377 lines (317 loc) • 10 kB
text/typescript
import { NextRequest, NextResponse } from "next/server";
import { getSiteByDomain, getSiteBySlug } from "../services/site-api";
// 테넌트 컨텍스트 인터페이스
export interface TenantContext {
siteId: string;
siteName: string;
siteSlug: string;
domain?: string | null;
subdomain?: string | null;
isPublished: boolean;
settings?: Record<string, unknown> | null;
}
// 멀티테넌트 라우팅 타입
type RoutingStrategy = "subdomain" | "domain" | "path";
// 멀티테넌트 설정
interface MultiTenantConfig {
strategy: RoutingStrategy;
mainDomain: string;
adminPath: string;
apiPath: string;
publicPath: string;
excludePaths: string[];
}
// 기본 설정
const defaultConfig: MultiTenantConfig = {
strategy: "subdomain",
mainDomain: process.env.NEXT_PUBLIC_APP_DOMAIN || "localhost:3000",
adminPath: "/admin",
apiPath: "/api",
publicPath: "/public",
excludePaths: [
"/_next",
"/favicon.ico",
"/robots.txt",
"/sitemap.xml",
"/api",
"/admin",
"/public",
"/login",
"/register",
"/reset-password",
"/verify-email",
],
};
export class MultiTenantManager {
private config: MultiTenantConfig;
constructor(config: Partial<MultiTenantConfig> = {}) {
this.config = { ...defaultConfig, ...config };
}
// 테넌트 컨텍스트 추출
async extractTenantContext(
request: NextRequest
): Promise<TenantContext | null> {
const { strategy } = this.config;
const url = new URL(request.url);
try {
switch (strategy) {
case "subdomain":
return await this.extractFromSubdomain(url);
case "domain":
return await this.extractFromDomain(url);
case "path":
return await this.extractFromPath(url);
default:
return null;
}
} catch (error) {
console.error("테넌트 컨텍스트 추출 실패:", error);
return null;
}
}
// 서브도메인에서 테넌트 추출
private async extractFromSubdomain(url: URL): Promise<TenantContext | null> {
const hostname = url.hostname;
const parts = hostname.split(".");
// 최소 3개 부분이 있어야 서브도메인 (subdomain.domain.tld)
if (parts.length < 3) {
return null;
}
const subdomain = parts[0];
// 예약된 서브도메인 제외
const reservedSubdomains = ["www", "admin", "api", "app", "mail", "ftp"];
if (reservedSubdomains.includes(subdomain)) {
return null;
}
// 서브도메인으로 사이트 조회
const site = await getSiteByDomain(subdomain);
if (!site) {
return null;
}
return {
siteId: site.id,
siteName: site.name,
siteSlug: site.slug,
domain: site.domain,
subdomain: site.subdomain,
isPublished: site.isPublished,
settings: site.settings,
};
}
// 커스텀 도메인에서 테넌트 추출
private async extractFromDomain(url: URL): Promise<TenantContext | null> {
const hostname = url.hostname;
// 메인 도메인이면 null 반환
if (hostname === this.config.mainDomain) {
return null;
}
// 커스텀 도메인으로 사이트 조회
const site = await getSiteByDomain(hostname);
if (!site) {
return null;
}
return {
siteId: site.id,
siteName: site.name,
siteSlug: site.slug,
domain: site.domain,
subdomain: site.subdomain,
isPublished: site.isPublished,
settings: site.settings,
};
}
// 경로에서 테넌트 추출
private async extractFromPath(url: URL): Promise<TenantContext | null> {
const pathSegments = url.pathname.split("/").filter(Boolean);
if (pathSegments.length === 0) {
return null;
}
const siteSlug = pathSegments[0];
// 예약된 경로 제외
if (
this.config.excludePaths.some((path) => url.pathname.startsWith(path))
) {
return null;
}
// 슬러그로 사이트 조회
const site = await getSiteBySlug(siteSlug);
if (!site) {
return null;
}
return {
siteId: site.id,
siteName: site.name,
siteSlug: site.slug,
domain: site.domain,
subdomain: site.subdomain,
isPublished: site.isPublished,
settings: site.settings,
};
}
// 요청이 테넌트 요청인지 확인
shouldProcessAsTenant(request: NextRequest): boolean {
const url = new URL(request.url);
// 제외 경로 확인
for (const excludePath of this.config.excludePaths) {
if (url.pathname.startsWith(excludePath)) {
return false;
}
}
// 관리자 경로 확인
if (url.pathname.startsWith(this.config.adminPath)) {
return false;
}
// API 경로 확인
if (url.pathname.startsWith(this.config.apiPath)) {
return false;
}
return true;
}
// 테넌트 라우팅 처리
async handleTenantRouting(
request: NextRequest,
tenantContext: TenantContext
): Promise<NextResponse> {
const url = new URL(request.url);
// 사이트가 발행되지 않은 경우
if (!tenantContext.isPublished) {
// 개발 모드나 소유자 액세스 확인 로직 추가 가능
return new NextResponse("사이트를 찾을 수 없습니다", { status: 404 });
}
// 테넌트 컨텍스트를 헤더에 추가
const headers = new Headers(request.headers);
headers.set("x-tenant-id", tenantContext.siteId);
headers.set("x-tenant-slug", tenantContext.siteSlug);
headers.set("x-tenant-name", tenantContext.siteName);
if (tenantContext.domain) {
headers.set("x-tenant-domain", tenantContext.domain);
}
if (tenantContext.subdomain) {
headers.set("x-tenant-subdomain", tenantContext.subdomain);
}
// 테넌트별 라우팅 전략에 따른 URL 재작성
let rewriteUrl: string;
switch (this.config.strategy) {
case "subdomain":
case "domain":
// 서브도메인/도메인 기반: 경로를 그대로 유지하고 테넌트 컨텍스트만 추가
rewriteUrl = url.pathname + url.search;
break;
case "path":
// 경로 기반: 첫 번째 세그먼트(사이트 슬러그) 제거
const pathSegments = url.pathname.split("/").filter(Boolean);
const newPath =
pathSegments.length > 1 ? "/" + pathSegments.slice(1).join("/") : "/";
rewriteUrl = newPath + url.search;
break;
default:
rewriteUrl = url.pathname + url.search;
}
// 테넌트 전용 페이지로 라우팅
const tenantPageUrl = new URL(`/tenant${rewriteUrl}`, request.url);
return NextResponse.rewrite(tenantPageUrl, {
headers,
});
}
// 관리자 대시보드 액세스 확인
isAdminAccess(request: NextRequest): boolean {
const url = new URL(request.url);
return url.pathname.startsWith(this.config.adminPath);
}
// API 요청인지 확인
isApiRequest(request: NextRequest): boolean {
const url = new URL(request.url);
return url.pathname.startsWith(this.config.apiPath);
}
// 정적 파일 요청인지 확인
isStaticRequest(request: NextRequest): boolean {
const url = new URL(request.url);
const staticExtensions = [
".js",
".css",
".png",
".jpg",
".jpeg",
".gif",
".svg",
".ico",
".woff",
".woff2",
".ttf",
];
return (
staticExtensions.some((ext) => url.pathname.endsWith(ext)) ||
url.pathname.startsWith("/_next/") ||
url.pathname.startsWith("/public/")
);
}
}
// 전역 멀티테넌트 매니저 인스턴스
export const multiTenantManager = new MultiTenantManager();
// 미들웨어 함수
export async function multiTenantMiddleware(
request: NextRequest
): Promise<NextResponse> {
try {
// 정적 파일이나 API 요청은 그대로 통과
if (
multiTenantManager.isStaticRequest(request) ||
multiTenantManager.isApiRequest(request) ||
multiTenantManager.isAdminAccess(request)
) {
return NextResponse.next();
}
// 테넌트 처리가 필요한지 확인
if (!multiTenantManager.shouldProcessAsTenant(request)) {
return NextResponse.next();
}
// 테넌트 컨텍스트 추출
const tenantContext = await multiTenantManager.extractTenantContext(
request
);
if (!tenantContext) {
// 테넌트를 찾을 수 없는 경우 메인 앱으로 진행
return NextResponse.next();
}
// 테넌트 라우팅 처리
return await multiTenantManager.handleTenantRouting(request, tenantContext);
} catch (error) {
console.error("멀티테넌트 미들웨어 오류:", error);
return NextResponse.next();
}
}
// 테넌트 컨텍스트 헬퍼 함수들
export function getTenantId(request: NextRequest | Request): string | null {
return request.headers.get("x-tenant-id");
}
export function getTenantSlug(request: NextRequest | Request): string | null {
return request.headers.get("x-tenant-slug");
}
export function getTenantName(request: NextRequest | Request): string | null {
return request.headers.get("x-tenant-name");
}
export function getTenantDomain(request: NextRequest | Request): string | null {
return request.headers.get("x-tenant-domain");
}
export function getTenantSubdomain(
request: NextRequest | Request
): string | null {
return request.headers.get("x-tenant-subdomain");
}
// React Server Component에서 사용할 테넌트 컨텍스트 추출
export function getTenantContext(headers: Headers): TenantContext | null {
const siteId = headers.get("x-tenant-id");
const siteSlug = headers.get("x-tenant-slug");
const siteName = headers.get("x-tenant-name");
if (!siteId || !siteSlug || !siteName) {
return null;
}
return {
siteId,
siteSlug,
siteName,
domain: headers.get("x-tenant-domain"),
subdomain: headers.get("x-tenant-subdomain"),
isPublished: true, // 미들웨어에서 이미 검증됨
};
}