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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,68 @@ rm -rf node_modules package-lock.json
pnpm install
```

## University Web Revalidation

대학 지원 정보가 변경된 뒤 배포 없이 `university-web`의 정적 캐시를 무효화하려면 `POST /api/revalidate`를 호출한다.

### Environment

Vercel의 `solid-connect-university-web` 프로젝트에 다음 환경 변수가 필요하다.

```bash
REVALIDATE_SECRET=your-secret
```

### Authentication

다음 두 방식 중 하나로 secret을 전달할 수 있다.

```bash
Authorization: Bearer ${REVALIDATE_SECRET}
```

```bash
x-revalidate-secret: ${REVALIDATE_SECRET}
```

### Revalidate All University Pages

```bash
curl -X POST "https://www.solid-connection.com/api/revalidate" \
-H "Authorization: Bearer ${REVALIDATE_SECRET}" \
-H "Content-Type: application/json" \
--data '{"scope":"university"}'
```

### Revalidate Home University Pages

```bash
curl -X POST "https://www.solid-connection.com/api/revalidate" \
-H "Authorization: Bearer ${REVALIDATE_SECRET}" \
-H "Content-Type: application/json" \
--data '{"scope":"home-university","homeUniversity":"kyunghee"}'
```

### Revalidate One Path

```bash
curl -X POST "https://www.solid-connection.com/api/revalidate" \
-H "Authorization: Bearer ${REVALIDATE_SECRET}" \
-H "Content-Type: application/json" \
--data '{"scope":"path","path":"/university/kyunghee"}'
```

### Response

```json
{
"revalidated": true,
"paths": ["/university/kyunghee", "/university/kyunghee/search"]
}
```

`revalidatePath`는 캐시를 무효화한다. 새 HTML은 API 호출 시점이 아니라 해당 페이지에 다음 요청이 들어올 때 다시 생성된다.

## AI Inspector Workflow

Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출됩니다.
Expand Down
135 changes: 135 additions & 0 deletions apps/university-web/src/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";

import { getSearchUniversitiesAllRegions } from "@/apis/universities/server";
import { getHomeUniversityBySlug, HOME_UNIVERSITY_LIST, HOME_UNIVERSITY_SLUGS } from "@/constants/university";
import type { HomeUniversitySlug } from "@/types/university";

type RevalidateScope = "university" | "home-university" | "path";

type RevalidateRequestBody = {
scope?: RevalidateScope;
homeUniversity?: HomeUniversitySlug;
path?: string;
includeDetails?: boolean;
};

const getBearerToken = (authorizationHeader: string | null) => {
if (!authorizationHeader?.startsWith("Bearer ")) {
return null;
}

return authorizationHeader.slice("Bearer ".length).trim();
};

const isAuthorized = (request: Request) => {
const secret = process.env.REVALIDATE_SECRET;

if (!secret) {
return false;
}

const requestSecret =
getBearerToken(request.headers.get("authorization")) ?? request.headers.get("x-revalidate-secret");

return requestSecret === secret;
};

const isAllowedUniversityPath = (path: string) => {
if (!path.startsWith("/university")) {
return false;
}

if (path.includes("://") || path.includes("..")) {
return false;
}

return (
path === "/university" || path === "/university/search" || /^\/university\/[a-z0-9-]+(\/search|\/\d+)?$/.test(path)
);
};

const revalidateUniversityPath = (path: string, revalidatedPaths: Set<string>) => {
revalidatePath(path);
revalidatedPaths.add(path);
};

const revalidateHomeUniversityPaths = async (
homeUniversity: HomeUniversitySlug,
includeDetails: boolean,
revalidatedPaths: Set<string>,
) => {
const homeUniversityInfo = getHomeUniversityBySlug(homeUniversity);

if (!homeUniversityInfo) {
throw new Error(`Invalid homeUniversity: ${homeUniversity}`);
}

revalidateUniversityPath(`/university/${homeUniversity}`, revalidatedPaths);
revalidateUniversityPath(`/university/${homeUniversity}/search`, revalidatedPaths);

if (!includeDetails) {
return;
}

const universities = await getSearchUniversitiesAllRegions({
homeUniversityId: homeUniversityInfo.homeUniversityId,
});

for (const university of universities) {
revalidateUniversityPath(`/university/${homeUniversity}/${university.id}`, revalidatedPaths);
}
};

const parseRequestBody = async (request: Request): Promise<RevalidateRequestBody> => {
try {
return (await request.json()) as RevalidateRequestBody;
} catch {
return {};
}
};

export async function POST(request: Request) {
if (!isAuthorized(request)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}

const body = await parseRequestBody(request);
const scope = body.scope ?? "university";
const includeDetails = body.includeDetails ?? true;
const revalidatedPaths = new Set<string>();

try {
if (scope === "path") {
if (!body.path || !isAllowedUniversityPath(body.path)) {
return NextResponse.json({ message: "Invalid revalidation path" }, { status: 400 });
}

revalidateUniversityPath(body.path, revalidatedPaths);
} else if (scope === "home-university") {
if (!body.homeUniversity || !HOME_UNIVERSITY_SLUGS.includes(body.homeUniversity)) {
return NextResponse.json({ message: "Invalid homeUniversity" }, { status: 400 });
}

await revalidateHomeUniversityPaths(body.homeUniversity, includeDetails, revalidatedPaths);
} else if (scope === "university") {
revalidateUniversityPath("/university", revalidatedPaths);
revalidateUniversityPath("/university/search", revalidatedPaths);

for (const homeUniversity of HOME_UNIVERSITY_LIST) {
await revalidateHomeUniversityPaths(homeUniversity.slug, includeDetails, revalidatedPaths);
}
} else {
return NextResponse.json({ message: "Invalid revalidation scope" }, { status: 400 });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";

return NextResponse.json({ message: "Failed to revalidate", error: errorMessage }, { status: 500 });
}

return NextResponse.json({
revalidated: true,
paths: Array.from(revalidatedPaths),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { createUrl, NO_INDEX_ROBOTS } from "@/utils/seo";
import UniversityDetail from "./_ui/UniversityDetail";
import UniversityDetailPreparingFallback from "./_ui/UniversityDetailPreparingFallback";

export const revalidate = false; // 완전 정적 생성
export const revalidate = false;
export const dynamicParams = false;

// 모든 homeUniversity + id 조합에 대해 정적 경로 생성
Expand Down
Loading