diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ef781f --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# Blog Platform + +**๐ŸŒ Live Service** [Visit the Blog](https://blog.minjae-dev.com) + +Monorepo-based Web / BO / API Architecture +with Contract-First Design and Runtime Boundary Separation + +## 1. Project Overview + +์ด ํ”„๋กœ์ ํŠธ๋Š” ๋‹จ์ˆœํ•œ ๋ธ”๋กœ๊ทธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ์•„๋‹ˆ๋ผ, +**Web / Back Office / API๋ฅผ ๋ถ„๋ฆฌํ•œ Monorepo ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„ ํ”„๋กœ์ ํŠธ**์ž…๋‹ˆ๋‹ค. + +์ฃผ์š” ์„ค๊ณ„ ๋ชฉํ‘œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค: + +- SEO ์ค‘์‹ฌ Web ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜๊ณผ ๊ด€๋ฆฌ์ž ์‹œ์Šคํ…œ์˜ ๋ฌผ๋ฆฌ์  ๋ถ„๋ฆฌ +- API ์š”์ฒญ/์‘๋‹ต ๊ณ„์•ฝ์˜ ๋‹จ์ผ ๊ธฐ์ค€(Single Source of Truth) ํ™•๋ฆฝ +- ๋Ÿฐํƒ€์ž„ ๊ฒฝ๊ณ„๋ฅผ ์œ ์ง€ํ•œ ์„ ํƒ์  ์บ์‹œ ๋ฌดํšจํ™” ์ „๋žต ์„ค๊ณ„ +- URL ๊ธฐ๋ฐ˜ ์ƒํƒœ ๊ด€๋ฆฌ ๊ตฌ์กฐ ์žฌ์„ค๊ณ„ + +## 2. Architecture Overview + +### ๐Ÿ“ฆ Monorepo Structure + +```bash +apps/ + web/ # SEO ์ค‘์‹ฌ ์‚ฌ์šฉ์ž ๋ธ”๋กœ๊ทธ + bo/ # ๊ด€๋ฆฌ์ž Back Office + api/ # NestJS ๊ธฐ๋ฐ˜ API ์„œ๋ฒ„ +packages/ + contracts/ # Zod ๊ธฐ๋ฐ˜ API Contract (SSOT) + ui/ # shadcn-ui ๊ธฐ๋ฐ˜ ui ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ +``` + +### ๐Ÿ“ apps/web + +SEO ์ค‘์‹ฌ ์‚ฌ์šฉ์ž ๋ธ”๋กœ๊ทธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ (Next.js App Router ๊ธฐ๋ฐ˜) +โ†’ [๐Ÿ“‘ apps/web README](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web) + +- SSR + Tag ๊ธฐ๋ฐ˜ ์บ์‹œ ์ „๋žต +- ์„ ํƒ์  ์บ์‹œ ๋ฌดํšจํ™” ๊ตฌ์กฐ ์ ์šฉ +- SEO ์„ฑ๋Šฅ์„ ์œ ์ง€ํ•˜๋Š” ๋ฐ์ดํ„ฐ ํŒจ์นญ ์ „๋žต ์„ค๊ณ„ + +### ๐Ÿ“ apps/bo + +๊ด€๋ฆฌ์ž ์ „์šฉ Back Office ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ +โ†’ [๐Ÿ“‘ apps/bo README](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo) + +- ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ/์ˆ˜์ • ๊ด€๋ฆฌ +- Write ์š”์ฒญ ํ›„ Web ์บ์‹œ ๋ฌดํšจํ™” ํŠธ๋ฆฌ๊ฑฐ ์—ญํ•  ์ˆ˜ํ–‰ +- ์‚ฌ์šฉ์ž ํŠธ๋ž˜ํ”ฝ๊ณผ ๋ฌผ๋ฆฌ์ ์œผ๋กœ ๋ถ„๋ฆฌ๋œ ๋ฐฐํฌ ๋‹จ์œ„ + +### ๐Ÿ“ apps/api + +NestJS ๊ธฐ๋ฐ˜ API ์„œ๋ฒ„ +โ†’ [๐Ÿ“‘ apps/api README](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api) + +- REST API ์ œ๊ณต +- contracts ํŒจํ‚ค์ง€ ๊ธฐ๋ฐ˜ ๋Ÿฐํƒ€์ž„ validation ์ˆ˜ํ–‰ +- ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ํ›„ Web ๋ฌดํšจํ™” ํŒŒ์ดํ”„๋ผ์ธ ํŠธ๋ฆฌ๊ฑฐ + +### ๐Ÿ“ packages/contracts + +Zod ๊ธฐ๋ฐ˜ API Contract **Single Source of Truth** +โ†’ [๐Ÿ“‘ packages/contracts README](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts) + +- ์š”์ฒญ/์‘๋‹ต Schema ์ค‘์•™ ๊ด€๋ฆฌ +- z.infer ๊ธฐ๋ฐ˜ ์ปดํŒŒ์ผ ํƒ€์ž„ ํƒ€์ž… ๊ณต์œ  +- schema.parse() ๊ธฐ๋ฐ˜ ๋Ÿฐํƒ€์ž„ ๊ฒ€์ฆ +- CJS / ESM dual build ๊ตฌ์กฐ + +## 3. Why This Architecture? + +### โ‘  Web / BO / API ๋ถ„๋ฆฌ ์ด์œ  + +- SEO ํŠธ๋ž˜ํ”ฝ๊ณผ ๊ด€๋ฆฌ์ž ํŠธ๋ž˜ํ”ฝ์˜ ์ฑ…์ž„ ๋ถ„๋ฆฌ +- ๋ฐฐํฌ ๋‹จ์œ„ ๋ฐ ๋Ÿฐํƒ€์ž„ ๊ฒฝ๊ณ„ ๋ถ„๋ฆฌ +- ๊ด€๋ฆฌ์ž ๊ธฐ๋Šฅ ์™ธ๋ถ€ ๋…ธ์ถœ ์ตœ์†Œํ™” + +### โ‘ก Monorepo ์œ ์ง€ ์ด์œ  + +- ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์€ ๋ฌผ๋ฆฌ์ ์œผ๋กœ ๋ถ„๋ฆฌํ•˜๋˜, +- ๋ฐ์ดํ„ฐ ๊ณ„์•ฝ์€ ๋‹จ์ผ ๊ธฐ์ค€์œผ๋กœ ์œ ์ง€ ํ•„์š” + +๐Ÿ‘‰ ๋ฌผ๋ฆฌ์  ๋ถ„๋ฆฌ์™€ ๋…ผ๋ฆฌ์  ์ผ๊ด€์„ฑ์„ ๋™์‹œ์— ํ™•๋ณด + +## 4. Core Design Decisions + +### โ‘  Contract Centralization (Schema Layer) + +- Zod ๊ธฐ๋ฐ˜ Schema Layer๋ฅผ ๊ณ„์•ฝ ๊ณ„์ธต์œผ๋กœ ์ •์˜ +- Web / BO / API๊ฐ€ ๋™์ผํ•œ Contract๋ฅผ ๊ณต์œ  +- ํƒ€์ž… ์ •์˜ ์ง€์  3 โ†’ 1๋กœ ์ˆ˜๋ ด + +### โ‘ก Cache Invalidation Pipeline + +- Web ๋Ÿฐํƒ€์ž„์ด ์บ์‹œ ์†Œ์œ ๊ถŒ์„ ๋ช…ํ™•ํžˆ ๊ฐ€์ง +- Tag ๊ธฐ๋ฐ˜ ์„ ํƒ์  ๋ฌดํšจํ™” ์ „๋žต ์„ค๊ณ„ +- API ๋ณ€๊ฒฝ โ†’ Web /api/revalidate ํ˜ธ์ถœ ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์„ฑ +- no-store ์—†์ด ์ตœ์‹ ์„ฑ๊ณผ ์„ฑ๋Šฅ ๋™์‹œ ํ™•๋ณด + +### โ‘ข Routing Boundary Redesign + +- Search ์ƒํƒœ๋ฅผ URL ๊ธฐ๋ฐ˜ Single Source๋กœ ์žฌ์ •์˜ +- TanStack Router + validateSearch ๊ธฐ๋ฐ˜ ๊ฒ€์ฆ ํŒŒ์ดํ”„๋ผ์ธ +- ๊ณตํ†ต ํ›… ์ถ”์ƒํ™”๋ฅผ ํ†ตํ•œ ์ค‘๋ณต ์ฝ”๋“œ ์ œ๊ฑฐ + +## 5. Architecture Trouble Shooting + +**๐Ÿšจ BO ์ˆ˜์ • ํ›„ Web์— ์ฆ‰์‹œ ๋ฐ˜์˜๋˜์ง€ ์•Š๋Š” ๋ฌธ์ œ** + +#### ๋ฌธ์ œ + +- BO์—์„œ ๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ • ํ›„ DB์—๋Š” ๋ฐ˜์˜๋˜์ง€๋งŒ Web์—๋Š” ์ฆ‰์‹œ ๋ฐ˜์˜๋˜์ง€ ์•Š์Œ +- Next.js App Router ๊ธฐ๋ณธ force-cache ์ „๋žต์œผ๋กœ ์บ์‹œ ์œ ์ง€ + +#### ์ œ์•ฝ + +- revalidateTag / revalidatePath๋Š” Web ์„œ๋ฒ„ ๋‚ด๋ถ€์—์„œ๋งŒ ์œ ํšจ +- ์ „์ฒด no-store ์ „๋žต์€ ๋น„์šฉ ๋ฐ ์„ฑ๋Šฅ ๋ฌธ์ œ ๋ฐœ์ƒ + +#### ํ•ด๊ฒฐ + +- ์บ์‹œ ์†Œ์œ ๊ถŒ์„ Web์œผ๋กœ ๋ช…ํ™•ํžˆ ์ •์˜ +- Tag ๊ธฐ๋ฐ˜ ์„ ํƒ์  ๋ฌดํšจํ™” ํŒŒ์ดํ”„๋ผ์ธ ์„ค๊ณ„ +- API โ†’ Web revalidate ์—”๋“œํฌ์ธํŠธ ํ˜ธ์ถœ ๊ตฌ์กฐ ๊ตฌ์„ฑ + +## 6. Tech Stack + +### ๐Ÿงฉ Core Architecture + +- TypeScript +- pnpm Monorepo +- Turborepo (Build Orchestration) +- Zod (Contract / Schema Layer) + +### ๐ŸŒ apps/web + +- Next.js (App Router) +- SSR + Tag-based Cache Strategy + +### ๐Ÿ›  apps/bo + +- React +- TanStack Router +- TanStack Query + +### ๐Ÿ”Œ apps/api + +- NestJS +- Prisma (ORM) +- MariaDB (MySQL-compatible) + +## Repository Navigation + +- ๐Ÿ—‚๏ธ **Root**: [Blog Platform โ†—](https://github.com/Blog-Archive-Ian/blog-platform) +- ๐Ÿ—‚๏ธ **Web**: [apps/web โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web) +- ๐Ÿ—‚๏ธ **Back Office**: [apps/bo โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo) +- ๐Ÿ—‚๏ธ **API Server**: [apps/api โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api) +- ๐Ÿ—‚๏ธ **Contracts**: [packages/contracts โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts) diff --git a/apps/api/README.md b/apps/api/README.md index d30c946..0f81f02 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -1,98 +1,89 @@ -

- Nest Logo -

- -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest - -

A progressive Node.js framework for building efficient and scalable server-side applications.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- - -## Description - -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. - -## Project setup - -```bash -$ pnpm install -``` +# Blog API -## Compile and run the project +NestJS ๊ธฐ๋ฐ˜ REST API ์„œ๋ฒ„์ž…๋‹ˆ๋‹ค. +DB ์ ‘๊ทผ, Contract ๊ธฐ๋ฐ˜ ์‘๋‹ต ๋ณ€ํ™˜, Web ์บ์‹œ ๋ฌดํšจํ™” ํŠธ๋ฆฌ๊ฑฐ ์—ญํ• ์„ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค. -```bash -# development -$ pnpm run start +## 1. Role in Architecture -# watch mode -$ pnpm run start:dev +API๋Š” ๋‹ค์Œ ์ฑ…์ž„์„ ๊ฐ€์ง‘๋‹ˆ๋‹ค: -# production mode -$ pnpm run start:prod -``` +- ๋ฐ์ดํ„ฐ ์˜์†์„ฑ ์ฒ˜๋ฆฌ (Prisma + MariaDB) +- Contract ๊ธฐ๋ฐ˜ ์‘๋‹ต ๊ตฌ์กฐ ๋ณด์žฅ +- Write ์š”์ฒญ ํ›„ Web ์บ์‹œ ๋ฌดํšจํ™” ํŠธ๋ฆฌ๊ฑฐ -## Run tests +Web๊ณผ BO ์‚ฌ์ด์—์„œ ๋ฐ์ดํ„ฐ ๊ฒฝ๊ณ„๋ฅผ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌํ•˜๋Š” ์—ญํ• ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. -```bash -# unit tests -$ pnpm run test +## 2. Database Layer -# e2e tests -$ pnpm run test:e2e +- Prisma ORM ์‚ฌ์šฉ +- MariaDB ์—ฐ๊ฒฐ (PrismaMariaDb adapter) +- NestJS lifecycle ๊ธฐ๋ฐ˜ ์—ฐ๊ฒฐ ๊ด€๋ฆฌ -# test coverage -$ pnpm run test:cov +```ts +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy ``` -## Deployment +- onModuleInit โ†’ $connect() +- onModuleDestroy โ†’ $disconnect() + +๐Ÿ‘‰ DB ์—ฐ๊ฒฐ ์ƒ๋ช…์ฃผ๊ธฐ๋ฅผ NestJS ๋ชจ๋“ˆ๊ณผ ์ •๋ ฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +## 3. Contract-Based Response Mapping -When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. +DB ๋ชจ๋ธ์„ ์ง์ ‘ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š๊ณ , +`@blog/contracts`์— ์ •์˜๋œ ํƒ€์ž…๊ณผ ์Šคํ‚ค๋งˆ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์‘๋‹ต์„ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค. -If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: +```ts +import type { PostType } from '@blog/contracts'; +``` -```bash -$ pnpm install -g @nestjs/mau -$ mau deploy +```ts +toContract(row: any, tags: string[]): PostType ``` -With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. +### ์„ค๊ณ„ ์˜๋„ + +- DB ๊ตฌ์กฐ์™€ API ์‘๋‹ต ๊ตฌ์กฐ ๋ถ„๋ฆฌ +- snake_case โ†’ camelCase ์ •๊ทœํ™” +- null / boolean / Date ์ •๊ทœํ™” ์ฒ˜๋ฆฌ +- ์™ธ๋ถ€ ๊ณ„์•ฝ(Contract)๊ณผ ๋‚ด๋ถ€ ๋ชจ๋ธ ๋ถ„๋ฆฌ +- zod schema ๊ธฐ๋ฐ˜ ์š”์ฒญ validation ์ˆ˜ํ–‰ (contracts ๊ณต์œ ) -## Resources +๐Ÿ‘‰ Contract ๊ณ„์ธต์ด ์ปดํŒŒ์ผ ํƒ€์ž„ ํƒ€์ž… ๊ณต์œ  + ๋Ÿฐํƒ€์ž„ ๊ฒ€์ฆ์„ ๋™์‹œ์— ๋‹ด๋‹นํ•˜๋„๋ก ์„ค๊ณ„ํ–ˆ์Šต๋‹ˆ๋‹ค. -Check out a few resources that may come in handy when working with NestJS: +## 4. Cache Invalidation Trigger -- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. -- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). -- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). -- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. -- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). -- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). -- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). -- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). +๊ฒŒ์‹œ๊ธ€ create / update / delete ์‹œ +Web์˜ /api/revalidate ์—”๋“œํฌ์ธํŠธ๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค. + +```ts +await fetch(`${FRONT_URL}/api/revalidate`, { + method: 'POST', + headers: { + 'x-revalidate-secret': REVALIDATE_SECRET, + }, + body: JSON.stringify({ tags }), +}); +``` -## Support +### ์„ค๊ณ„ ํฌ์ธํŠธ -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). +- ์บ์‹œ ์†Œ์œ ๊ถŒ์€ Web์— ์žˆ์Œ +- API๋Š” ๋ฌดํšจํ™” ํŠธ๋ฆฌ๊ฑฐ ์—ญํ• ๋งŒ ์ˆ˜ํ–‰ +- revalidate ์‹คํŒจํ•ด๋„ Write๋Š” ์„ฑ๊ณตํ•ด์•ผ ํ•จ +- ๋Ÿฐํƒ€์ž„ ๊ฒฝ๊ณ„๋ฅผ ์นจ๋ฒ”ํ•˜์ง€ ์•Š๋Š” ๊ตฌ์กฐ -## Stay in touch +## 5. Transaction (Planned) -- Author - [Kamil Myล›liwiec](https://twitter.com/kammysliwiec) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) +- ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ/์ˆ˜์ • ์‹œ ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ๋ณ€๊ฒฝ +- ํ–ฅํ›„ Prisma $transaction ์ ์šฉ ์˜ˆ์ • -## License +## Repository Navigation -Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). +- ๐Ÿ—‚๏ธ **Root**: [Blog Platform โ†—](https://github.com/Blog-Archive-Ian/blog-platform) +- ๐Ÿ—‚๏ธ **Web**: [apps/web โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web) +- ๐Ÿ—‚๏ธ **Back Office**: [apps/bo โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo) +- ๐Ÿ—‚๏ธ **API Server**: [apps/api โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api) +- ๐Ÿ—‚๏ธ **Contracts**: [packages/contracts โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts) diff --git a/apps/api/src/common/revalidate.service.ts b/apps/api/src/common/revalidate.service.ts index 40be9b0..6af6161 100644 --- a/apps/api/src/common/revalidate.service.ts +++ b/apps/api/src/common/revalidate.service.ts @@ -4,19 +4,25 @@ import { Injectable, Logger } from '@nestjs/common'; export class RevalidateService { private readonly logger = new Logger(RevalidateService.name); - private readonly webUrl = process.env.WEB_URL; + private readonly webUrl = process.env.FRONT_URL; private readonly secret = process.env.REVALIDATE_SECRET; async revalidateTags(tags: string[]) { + if (!tags.length) return; + if (!this.webUrl || !this.secret) { this.logger.warn( - 'Revalidate skipped: WEB_URL or REVALIDATE_SECRET missing', + 'Revalidate skipped: FRONT_URL or REVALIDATE_SECRET missing', ); return; } + const url = `${this.webUrl}/api/revalidate`; + try { - const res = await fetch(`${this.webUrl}/api/revalidate`, { + this.logger.log(`[revalidate] POST ${url} tags=${JSON.stringify(tags)}`); + + const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', @@ -25,15 +31,19 @@ export class RevalidateService { body: JSON.stringify({ tags }), }); + const body = await res.text().catch(() => ''); + if (!res.ok) { - const text = await res.text().catch(() => ''); - this.logger.warn(`Revalidate failed: ${res.status} ${text}`); + this.logger.warn( + `[revalidate] FAILED status=${res.status} body=${body}`, + ); + return; } - this.logger.log(`Revalidated tags: ${tags.join(', ')}`); + this.logger.log(`[revalidate] OK status=${res.status} body=${body}`); } catch (err) { // revalidate ์‹คํŒจํ•ด๋„ ๊ธ€ ์ˆ˜์ •์€ ์„ฑ๊ณตํ•ด์•ผ ํ•จ - this.logger.error('Revalidate failed', err); + this.logger.error('[revalidate] ERROR', err as any); } } } diff --git a/apps/api/src/post/post.controller.ts b/apps/api/src/post/post.controller.ts index b8c330c..07cc37f 100644 --- a/apps/api/src/post/post.controller.ts +++ b/apps/api/src/post/post.controller.ts @@ -6,6 +6,7 @@ import type { GetMonthPostListResponse, GetPopularPostListResponse, GetPostDetailResponse, + IncreasePostViewResponse, PinPostResponse, UnArchivePostResponse, UnPinPostResponse, @@ -19,6 +20,7 @@ import { GetMonthPostList, GetPopularPostList, GetPostDetail, + IncreasePostView, PinPost, UnArchivePost, UnPinPost, @@ -34,8 +36,11 @@ import { Post, Put, Query, + Req, + Res, UseGuards, } from '@nestjs/common'; +import type { Request, Response } from 'express'; import { JwtAuthGuard } from 'src/auth/jwt-auth.guard'; import { User } from 'src/auth/user.decorator'; @@ -134,6 +139,55 @@ export class PostController { }; } + // ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€ (์ฟ ํ‚ค ๊ธฐ๋ฐ˜ 1์‹œ๊ฐ„ ์ค‘๋ณต ๋ฐฉ์ง€) + @Post(IncreasePostView.path(':postSeq')) + async increasePostView( + @Param() rawParams: Record, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise { + const parsed = IncreasePostView.Params.safeParse({ + postSeq: Number(rawParams.postSeq), + }); + + if (!parsed.success) { + throw new BadRequestException({ + message: '์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + status: 500, + data: null, + }); + } + + const postSeq = parsed.data.postSeq; + const cookieKey = `post_view_${postSeq}`; + const hasViewCookie = Boolean( + (req as Request & { cookies?: Record }).cookies?.[ + cookieKey + ], + ); + + // ์ด๋ฏธ 1์‹œ๊ฐ„ ์ด๋‚ด์— ์กฐํšŒํ•œ ๊ฒฝ์šฐ: DB ์กฐํšŒ์ˆ˜๋Š” ์ฆ๊ฐ€์‹œํ‚ค์ง€ ์•Š๊ณ  ํ˜„์žฌ ๊ฐ’๋งŒ ๋ฐ˜ํ™˜ + const views = hasViewCookie + ? await this.postService.getPostViews(parsed.data) + : await this.postService.increasePostView(parsed.data); + + if (!hasViewCookie) { + res.cookie(cookieKey, '1', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + path: '/', + maxAge: 1000 * 60 * 60, // 1์‹œ๊ฐ„ + }); + } + + return { + status: 200, + message: '์กฐํšŒ์ˆ˜๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + data: { views }, + }; + } + // ๊ธ€ ๋ณด๊ด€ @UseGuards(JwtAuthGuard) @Post(ArchivePost.path(':postSeq')) diff --git a/apps/api/src/post/post.service.ts b/apps/api/src/post/post.service.ts index a032e62..9d697b9 100644 --- a/apps/api/src/post/post.service.ts +++ b/apps/api/src/post/post.service.ts @@ -12,6 +12,8 @@ import { type GetPopularPostListData, type GetPostDetailData, type GetPostDetailParams, + IncreasePostView, + type IncreasePostViewParams, PinPost, type PinPostParams, UnArchivePost, @@ -363,4 +365,53 @@ export class PostService { return Array.from(daySet).sort((a, b) => a - b); } + + // ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€ + async increasePostView(params: IncreasePostViewParams): Promise { + const postSeq = BigInt(params.postSeq); + + const updated = await this.prisma.post.updateMany({ + where: { post_seq: postSeq }, + data: { + views: { + increment: 1, + }, + }, + }); + + if (updated.count === 0) { + throw new NotFoundException('๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + const row = await this.prisma.post.findUnique({ + where: { post_seq: postSeq }, + select: { views: true }, + }); + + if (!row) { + throw new NotFoundException('๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + await this.revalidate.revalidateTags( + IncreasePostView.revalidate(params.postSeq), + ); + + return row.views; + } + + // ํ˜„์žฌ ์กฐํšŒ์ˆ˜ ์กฐํšŒ + async getPostViews(params: IncreasePostViewParams): Promise { + const postSeq = BigInt(params.postSeq); + + const row = await this.prisma.post.findUnique({ + where: { post_seq: postSeq }, + select: { views: true }, + }); + + if (!row) { + throw new NotFoundException('๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + return row.views; + } } diff --git a/apps/bo/README.md b/apps/bo/README.md new file mode 100644 index 0000000..de245ca --- /dev/null +++ b/apps/bo/README.md @@ -0,0 +1,114 @@ +# Blog Back Office + +๊ด€๋ฆฌ์ž ์ „์šฉ ์ฝ˜ํ…์ธ  ๊ด€๋ฆฌ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค. +React + TanStack Router ๊ธฐ๋ฐ˜์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ์œผ๋ฉฐ, +URL ๊ธฐ๋ฐ˜ ์ƒํƒœ ๊ด€๋ฆฌ์™€ Runtime Guard ๊ตฌ์กฐ๋ฅผ ์ค‘์‹ฌ์œผ๋กœ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +## 1. Project Overview + +Back Office๋Š” ๋‹จ์ˆœํ•œ CRUD ๊ด€๋ฆฌ์ž ํ™”๋ฉด์ด ์•„๋‹ˆ๋ผ, +์ฝ˜ํ…์ธ  ๊ด€๋ฆฌ ๊ณผ์ •์—์„œ ๋ฐœ์ƒํ•˜๋Š” ์ƒํƒœ ๊ด€๋ฆฌ, URL ๋™๊ธฐํ™”, ์ดํƒˆ ๋ฐฉ์ง€, ์บ์‹œ ํŠธ๋ฆฌ๊ฑฐ๊นŒ์ง€ ๊ณ ๋ คํ•œ ์šด์˜์šฉ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค. + +Web(๊ณต๊ฐœ ์„œ๋น„์Šค)๊ณผ๋Š” ๋ฌผ๋ฆฌ์ ์œผ๋กœ ๋ถ„๋ฆฌ๋œ ๋ฐฐํฌ ๋‹จ์œ„๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์œผ๋ฉฐ, +Write ์ค‘์‹ฌ ๋Ÿฐํƒ€์ž„์„ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค. + +### ๐ŸŽฏ ์„ค๊ณ„ ๋ชฉํ‘œ + +- URL ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰ ์ƒํƒœ ๊ด€๋ฆฌ (Single Source of Truth) +- TanStack Router ๊ธฐ๋ฐ˜ ํƒ€์ž… ์•ˆ์ „ ๋ผ์šฐํŒ… +- ๊ณตํ†ต Guard๋ฅผ ํ†ตํ•œ ์ดํƒˆ ๋ฐฉ์ง€ ์ •์ฑ… ์ค‘์•™ํ™” +- Contract ๊ธฐ๋ฐ˜ ์š”์ฒญ/์‘๋‹ต ํƒ€์ž… ๊ณต์œ  +- Web ์บ์‹œ ๋ฌดํšจํ™” ํŠธ๋ฆฌ๊ฑฐ ์—ญํ•  ์ˆ˜ํ–‰ + +## 2. Core Features + +- ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ / ์ˆ˜์ • / ์‚ญ์ œ +- ๊ฒŒ์‹œ๊ธ€ ๊ณ ์ •(Pin) / ํ•ด์ œ +- ๊ฒŒ์‹œ๊ธ€ ๋ณด๊ด€(Archive) / ๋ณต์› +- ์นดํ…Œ๊ณ ๋ฆฌ / ํƒœ๊ทธ ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ ๊ฒ€์ƒ‰ +- URL ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง€๋„ค์ด์…˜ +- ๊ด€๋ฆฌ์ž ํ”„๋กœํ•„ ์กฐํšŒ / ์ˆ˜์ • +- ๋Œ€์‹œ๋ณด๋“œ (์ถ”ํ›„ ๊ธฐ๋Šฅ) + +## 3. URL-Driven State Management + +BO๋Š” ๊ฒ€์ƒ‰ ์ƒํƒœ๋ฅผ `useState` ๋‚ด๋ถ€์—๋งŒ ๋‘์ง€ ์•Š๊ณ , +URL์„ ์ƒํƒœ์˜ Single Source of Truth๋กœ ์žฌ์ •์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. + +์ด๋ฅผ ์œ„ํ•ด `useSearchParams` ์ปค์Šคํ…€ ํ›…์„ ์„ค๊ณ„ํ–ˆ์Šต๋‹ˆ๋‹ค. + +### ๐Ÿ“Œ ์„ค๊ณ„ ํฌ์ธํŠธ + +- search ๊ฐ’์€ URL ์ง๋ ฌํ™” ๊ฐ€๋Šฅํ•œ ํƒ€์ž…๋งŒ ํ—ˆ์šฉ +- ๊ฐ์ฒด/Date/Map ๋“ฑ์€ ํƒ€์ž… ๋‹จ๊ณ„์—์„œ ์ฐจ๋‹จ +- undefined๋Š” ๋ฌด์‹œ, null/''๋Š” ์ œ๊ฑฐ ์˜๋„๋กœ ์ฒ˜๋ฆฌ +- ๋ถ€๋ถ„ ์—…๋ฐ์ดํŠธ(`applySearch`)์™€ ์ „์ฒด ๋ฆฌ์…‹(`resetSearch`) ๋ถ„๋ฆฌ + +```ts +const { search, applySearch, resetSearch } = useSearchParams({ + defaultSearch, + Route, +}) +``` + +### ์™œ URL ๊ธฐ๋ฐ˜์œผ๋กœ ์„ค๊ณ„ํ–ˆ๋Š”๊ฐ€? + +- ์ƒˆ๋กœ๊ณ ์นจ ์‹œ ์ƒํƒœ ์œ ์ง€ +- ๋’ค๋กœ๊ฐ€๊ธฐ / ๋งํฌ ๊ณต์œ  ๊ฐ€๋Šฅ +- ํ•„ํ„ฐ ์ƒํƒœ์˜ ์˜ˆ์ธก ๊ฐ€๋Šฅ์„ฑ ํ™•๋ณด +- ํŽ˜์ด์ง€๋ณ„ search ๋กœ์ง ์ค‘๋ณต ์ œ๊ฑฐ + +๐Ÿ‘‰ ๊ฒฐ๊ณผ์ ์œผ๋กœ ๊ฒ€์ƒ‰ ์ƒํƒœ ๊ด€๋ฆฌ ๋กœ์ง์„ ์ค‘์•™ํ™”ํ•˜๊ณ , +ํŽ˜์ด์ง€ ๋‹จ ์ค‘๋ณต ์ฝ”๋“œ๋ฅผ ๊ตฌ์กฐ์ ์œผ๋กœ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค. + +## 4. Type-Safe Search Constraint + +SearchLike ์ œ๋„ค๋ฆญ์„ ํ†ตํ•ด +URL์— ๋“ค์–ด๊ฐˆ ๊ฐ’์€ ๋ฐ˜๋“œ์‹œ ์ง๋ ฌํ™” ๊ฐ€๋Šฅํ•œ Primitive ํƒ€์ž…๋งŒ ํ—ˆ์šฉํ•˜๋„๋ก ์ œํ•œํ–ˆ์Šต๋‹ˆ๋‹ค. + +### ์ œ์•ฝ + +- string | number | boolean | null | undefined +- ๋˜๋Š” ํ•ด๋‹น ํƒ€์ž…์˜ ๋ฐฐ์—ด +- ๊ฐ์ฒด/ํ•จ์ˆ˜/Date๋Š” ํƒ€์ž… ๋‹จ๊ณ„์—์„œ ์ฐจ๋‹จ + +```ts +export type SearchLike = { + [K in keyof T]: PrimitiveOrArray +} +``` + +๐Ÿ‘‰ URL ์ง๋ ฌํ™” ์•ˆ์ „์„ฑ์„ ํƒ€์ž… ๋ ˆ๋ฒจ์—์„œ ๊ฐ•์ œํ–ˆ์Šต๋‹ˆ๋‹ค. + +## 5. Contract-Driven API Integration + +BO๋Š” @blog/contracts ํŒจํ‚ค์ง€๋ฅผ ํ†ตํ•ด +API ์š”์ฒญ/์‘๋‹ต ํƒ€์ž…๊ณผ path ์ •์˜๋ฅผ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค. + +- API path ๊ณต์œ  +- Request / Response ํƒ€์ž… ๊ณต์œ  +- ๋Ÿฐํƒ€์ž„ validation์€ API์—์„œ ์ˆ˜ํ–‰ +- ์ปดํŒŒ์ผ ํƒ€์ž„ ์•ˆ์ •์„ฑ์€ BO์—์„œ ํ™•๋ณด + +๐Ÿ‘‰ ํƒ€์ž… ์ค‘๋ณต ์„ ์–ธ ์—†์ด Web / BO / API ๊ฐ„ ๊ณ„์•ฝ ์ผ๊ด€์„ฑ ์œ ์ง€ + +## 6. Runtime Boundary Role + +BO๋Š” Write ์š”์ฒญ ๋ฐœ์ƒ ์‹œ +API ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด Web ์บ์‹œ ๋ฌดํšจํ™” ํŒŒ์ดํ”„๋ผ์ธ์„ ํŠธ๋ฆฌ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. + +### ์—ญํ•  ์ •๋ฆฌ + +- Web: ์บ์‹œ ์†Œ์œ  +- API: ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ + ๋ฌดํšจํ™” ํŠธ๋ฆฌ๊ฑฐ +- BO: Write ์š”์ฒญ ๋ฐœ์ƒ ์ง€์  + +๐Ÿ‘‰ ๋Ÿฐํƒ€์ž„ ์ฑ…์ž„์ด ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌ๋œ ๊ตฌ์กฐ + +## Repository Navigation + +- ๐Ÿ—‚๏ธ **Root**: [Blog Platform โ†—](https://github.com/Blog-Archive-Ian/blog-platform) +- ๐Ÿ—‚๏ธ **Web**: [apps/web โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web) +- ๐Ÿ—‚๏ธ **Back Office**: [apps/bo โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo) +- ๐Ÿ—‚๏ธ **API Server**: [apps/api โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api) +- ๐Ÿ—‚๏ธ **Contracts**: [packages/contracts โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts) diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..99d7215 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,148 @@ +# Blog Web + +**๐ŸŒ Live Service** [Visit the Blog](https://blog.minjae-dev.com) + +Next.js App Router ๊ธฐ๋ฐ˜ SEO ์ค‘์‹ฌ ๋ธ”๋กœ๊ทธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค. +SSR + Tag ๊ธฐ๋ฐ˜ ์บ์‹œ ์ „๋žต + Contract ๊ณต์œ  ๊ตฌ์กฐ๋ฅผ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +## 1. Project Overview + +์ด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์€ ๋‹จ์ˆœํ•œ ๋ธ”๋กœ๊ทธ UI ๊ตฌํ˜„์ด ์•„๋‹ˆ๋ผ, +SEO ์ค‘์‹ฌ์˜ SSR ๊ธฐ๋ฐ˜ ์›น ์•„ํ‚คํ…์ฒ˜๋ฅผ ์„ค๊ณ„ํ•˜๊ณ  ์šด์˜ ์ „๋žต๊นŒ์ง€ ๊ณ ๋ คํ•œ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค. + +Web์€ ์‚ฌ์šฉ์ž ํŠธ๋ž˜ํ”ฝ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ณต๊ฐœ ์„œ๋น„์Šค์ด๋ฉฐ, +Back Office์™€ API์™€๋Š” ๋ฌผ๋ฆฌ์ ์œผ๋กœ ๋ถ„๋ฆฌ๋œ ๋ฐฐํฌ ๋‹จ์œ„๋กœ ์šด์˜๋ฉ๋‹ˆ๋‹ค. + +### ๐ŸŽฏ ์„ค๊ณ„ ๋ชฉํ‘œ + +- SEO ์ค‘์‹ฌ SSR ์ „๋žต ์ ์šฉ (๋ชจ๋“  ํŽ˜์ด์ง€ Server Rendering) +- ๊ด€๋ฆฌ์ž ์‹œ์Šคํ…œ๊ณผ ์‚ฌ์šฉ์ž ์„œ๋น„์Šค์˜ ๋Ÿฐํƒ€์ž„ ๋ถ„๋ฆฌ +- Tag ๊ธฐ๋ฐ˜ ์„ ํƒ์  ์บ์‹œ ๋ฌดํšจํ™” ์„ค๊ณ„ +- Contract ๊ธฐ๋ฐ˜ ํƒ€์ž… ์ผ๊ด€์„ฑ ์œ ์ง€ +- ์„œ๋ฒ„ ๋น„์šฉ์„ ๊ณ ๋ คํ•œ Next.js Cache ์ „๋žต ์„ค๊ณ„ + +## 2. Core Features + +- ์ตœ์‹  ๊ธ€ ๋ชฉ๋ก +- ์ธ๊ธฐ ๊ธ€ ๋ชฉ๋ก +- ๊ณ ์ •(Pinned) ๊ธ€ +- ์นดํ…Œ๊ณ ๋ฆฌ / ํƒœ๊ทธ ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ ์กฐํšŒ +- ์›”๋ณ„ ๊ฒŒ์‹œ๊ธ€ ์•„์นด์ด๋ธŒ ์กฐํšŒ +- ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ธ ์กฐํšŒ +- ๋Œ“๊ธ€ ์‹œ์Šคํ…œ (Utterances ๊ธฐ๋ฐ˜ GitHub ์—ฐ๋™) + +๋ชจ๋“  ํŽ˜์ด์ง€๋Š” **Server Component ๊ธฐ๋ฐ˜ SSR ๊ตฌ์กฐ**๋กœ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +## 3. Performance & SEO + +์ดˆ๊ธฐ ์ง„์ž… ํŽ˜์ด์ง€ Lighthouse ๊ธฐ์ค€ + +- **Performance: 100** +- **Accessibility: 96** +- **Best Practices: 100** +- **SEO: 100** + +### ๐Ÿš€ SEO ์ „๋žต + +- App Router ๊ธฐ๋ฐ˜ SSR ์ ์šฉ +- ์ •์  ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ +- ๊ฒ€์ƒ‰์—”์ง„ ์นœํ™”์  URL ๊ตฌ์กฐ +- no-store ๋Œ€์‹  Tag ๊ธฐ๋ฐ˜ Cache ์ „๋žต ์‚ฌ์šฉ + +## 4. Rendering & Cache Strategy + +Next.js App Router์˜ `fetch cache` ๊ธฐ๋Šฅ์„ ํ™œ์šฉํ•˜์—ฌ + +- `revalidate` + `tags` ๊ธฐ๋ฐ˜ ์บ์‹ฑ +- ๋„๋ฉ”์ธ ๋‹จ์œ„ + ID ๋‹จ์œ„ Tag ์„ค๊ณ„ +- ์ „์ฒด no-store ์ „๋žต ๋Œ€์‹  ์„ ํƒ์  ๋ฌดํšจํ™” ์ ์šฉ + +```ts +next: { + revalidate: 5 * 60, + tags: [CacheTags.Post.detail, CacheTags.Post.byId(params.postSeq)], +} +``` + +### ๐Ÿ’ก ์™œ no-store๋ฅผ ์“ฐ์ง€ ์•Š์•˜๋Š”๊ฐ€? + +- ๋ชจ๋“  ์š”์ฒญ์„ ์„œ๋ฒ„์—์„œ ์ฒ˜๋ฆฌํ•˜๋ฉด ๋น„์šฉ ์ฆ๊ฐ€ +- ์บ์‹œ๋ฅผ ์œ ์ง€ํ•˜๋ฉด์„œ ์ตœ์‹ ์„ฑ๋งŒ ๋ถ€๋ถ„ ๊ฐฑ์‹ ํ•˜๋Š” ์ „๋žต์ด ํ•„์š” +- SEO ์„ฑ๋Šฅ๊ณผ ์„œ๋ฒ„ ๋น„์šฉ์„ ๋™์‹œ์— ๊ณ ๋ ค + +๐Ÿ‘‰ ๊ฒฐ๊ณผ์ ์œผ๋กœ ์„œ๋ฒ„ ๋ถ€ํ•˜๋ฅผ ์ค„์ด๋ฉด์„œ๋„ ์ฝ˜ํ…์ธ  ์ˆ˜์ • ์‹œ ์ฆ‰์‹œ ๋ฐ˜์˜๋˜๋Š” ๊ตฌ์กฐ๋ฅผ ์„ค๊ณ„ํ–ˆ์Šต๋‹ˆ๋‹ค. + +## 4.1 Cache Invalidation Pipeline + +BO์—์„œ ๋ฐ์ดํ„ฐ ์ˆ˜์ •์ด ๋ฐœ์ƒํ•˜๋ฉด +API ์„œ๋ฒ„๊ฐ€ Web์˜ `/api/revalidate` ์—”๋“œํฌ์ธํŠธ๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค. + +Web ๋Ÿฐํƒ€์ž„ ๋‚ด๋ถ€์—์„œ๋งŒ `revalidateTag` / `revalidatePath`๊ฐ€ ์œ ํšจํ•˜๋ฏ€๋กœ, +์บ์‹œ ์†Œ์œ ๊ถŒ์€ Web์ด ๋ช…ํ™•ํžˆ ๋ณด์œ ํ•ฉ๋‹ˆ๋‹ค. + +```ts +for (const tag of tags) revalidateTag(tag, 'max') +for (const path of paths) revalidatePath(path) +``` + +### ์„ค๊ณ„ ํฌ์ธํŠธ + +- ์บ์‹œ ์†Œ์œ ๊ถŒ์„ Web ๋Ÿฐํƒ€์ž„์œผ๋กœ ํ•œ์ • +- ์ „์ฒด no-store ์ „๋žต ๋Œ€์‹  ์„ ํƒ์  ๊ฐฑ์‹  +- Tag ๊ทœ์น™์€ contracts ๊ณ„์ธต์—์„œ ๊ณต์œ  + +๐Ÿ‘‰ ๋Ÿฐํƒ€์ž„ ๊ฒฝ๊ณ„๋ฅผ ์œ ์ง€ํ•˜๋ฉด์„œ ์ตœ์‹ ์„ฑ๊ณผ ์„ฑ๋Šฅ์„ ๋™์‹œ์— ํ™•๋ณดํ–ˆ์Šต๋‹ˆ๋‹ค. + +## 5. Contract-Driven Data Layer + +Web์€ `@blog/contracts` ํŒจํ‚ค์ง€๋ฅผ ํ†ตํ•ด +API ์š”์ฒญ/์‘๋‹ต ํƒ€์ž…๊ณผ ๊ฒฝ๋กœ ์ •์˜๋ฅผ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค. + +์ด๋Š” ๋‹จ์ˆœํ•œ ํƒ€์ž… ์žฌ์‚ฌ์šฉ์ด ์•„๋‹ˆ๋ผ, +Web / BO / API ๊ฐ„ **Single Source of Truth ๊ธฐ๋ฐ˜ ๊ณ„์•ฝ ๊ตฌ์กฐ**๋ฅผ ์˜๋ฏธํ•ฉ๋‹ˆ๋‹ค. + +### ๐Ÿ“ฆ Contract ๊ณต์œ  ๊ตฌ์กฐ + +- API path ์ •์˜ ๊ณต์œ  +- Request / Response ํƒ€์ž… ๊ณต์œ  +- Cache Tag ๊ทœ์น™ ๊ณต์œ  +- z.infer ๊ธฐ๋ฐ˜ ์ปดํŒŒ์ผ ํƒ€์ž„ ํƒ€์ž… ์•ˆ์ •์„ฑ ํ™•๋ณด + +์˜ˆ์‹œ: + +```ts +import { + GetPostDetail, + type GetPostDetailParams, + type GetPostDetailResponse, +} from '@blog/contracts' + +export async function getPostDetail(params: GetPostDetailParams) { + const res = await API.get(GetPostDetail.path(params.postSeq), { + next: { + revalidate: 5 * 60, + tags: [CacheTags.Post.detail, CacheTags.Post.byId(params.postSeq)], + }, + }) + + if (res.status !== 200) throw new Error(res.message) + return res.data +} +``` + +### ๐ŸŽฏ ์™œ ์ค‘์š”ํ•œ๊ฐ€? + +- Web์—์„œ ํƒ€์ž…์„ ๋ณ„๋„๋กœ ์„ ์–ธํ•˜์ง€ ์•Š์Œ +- API ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ ์‹œ ์ปดํŒŒ์ผ ๋‹จ๊ณ„์—์„œ ์ฆ‰์‹œ ๊ฐ์ง€ +- ๋Ÿฐํƒ€์ž„๊ณผ ์ปดํŒŒ์ผ ํƒ€์ž„ ์•ˆ์ •์„ฑ์„ ๋™์‹œ์— ํ™•๋ณด +- Cache Tag ๊ทœ์น™ ๋˜ํ•œ ๊ณ„์•ฝ ๊ณ„์ธต์—์„œ ๊ณต์œ  + +๐Ÿ‘‰ ๊ฒฐ๊ณผ์ ์œผ๋กœ ๋ฐ์ดํ„ฐ ๊ณ„์ธต์˜ ์ผ๊ด€์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜์„ฑ์„ ๊ตฌ์กฐ์ ์œผ๋กœ ํ™•๋ณดํ–ˆ์Šต๋‹ˆ๋‹ค. + +## Repository Navigation + +- ๐Ÿ—‚๏ธ **Root**: [Blog Platform โ†—](https://github.com/Blog-Archive-Ian/blog-platform) +- ๐Ÿ—‚๏ธ **Web**: [apps/web โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web) +- ๐Ÿ—‚๏ธ **Back Office**: [apps/bo โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo) +- ๐Ÿ—‚๏ธ **API Server**: [apps/api โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api) +- ๐Ÿ—‚๏ธ **Contracts**: [packages/contracts โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts) diff --git a/apps/web/app/(main)/post/[postSeq]/page.tsx b/apps/web/app/(main)/post/[postSeq]/page.tsx index b6f66ca..b675594 100644 --- a/apps/web/app/(main)/post/[postSeq]/page.tsx +++ b/apps/web/app/(main)/post/[postSeq]/page.tsx @@ -1,6 +1,7 @@ import { Comments } from '@/section/post/post-detail/comments' import { PostContent } from '@/section/post/post-detail/post-content' import { ScrollToBottomButton } from '@/section/post/post-detail/scroll-to-bottom-button' +import { PostViewTracker } from '@/section/post/post-detail/view-tracker' import { TableOfContents } from '@/section/post/post-detail/toc' import { getPostDetail } from '@/shared/api/post.api' import { formatKoreanDate, stripMarkdown } from '@/shared/lib/format' @@ -114,6 +115,9 @@ export default async function PostPage({ params }: { params: Promise
+ {/* ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€ ํŠธ๋ž˜์ปค (ํด๋ผ์ด์–ธํŠธ์—์„œ ์ฟ ํ‚ค ๊ธฐ๋ฐ˜ 1์‹œ๊ฐ„ ์ค‘๋ณต ๋ฐฉ์ง€) */} + + {/* ์ค‘์•™ ์ฝ˜ํ…์ธ  */}
diff --git a/apps/web/section/post/post-detail/view-tracker.tsx b/apps/web/section/post/post-detail/view-tracker.tsx new file mode 100644 index 0000000..fcaa7e2 --- /dev/null +++ b/apps/web/section/post/post-detail/view-tracker.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useEffect } from 'react' + +import { increasePostView } from '@/shared/api/post.api' + +type PostViewTrackerProps = { + // ์กฐํšŒ์ˆ˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚ฌ ๊ฒŒ์‹œ๊ธ€ ๋ฒˆํ˜ธ + postSeq: number +} + +export function PostViewTracker({ postSeq }: PostViewTrackerProps) { + // ํด๋ผ์ด์–ธํŠธ์—์„œ๋งŒ ํ•œ ๋ฒˆ ํ˜ธ์ถœํ•ด์„œ ์ฟ ํ‚ค ๊ธฐ๋ฐ˜ ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€ ์ฒ˜๋ฆฌ + useEffect(() => { + ;(async () => { + try { + await increasePostView({ postSeq }) + } catch { + // ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€ ์‹คํŒจ๋Š” ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์— ํฐ ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์œผ๋ฏ€๋กœ ์กฐ์šฉํžˆ ๋ฌด์‹œ + } + })() + }, [postSeq]) + + return null +} + diff --git a/apps/web/shared/api/post.api.ts b/apps/web/shared/api/post.api.ts index 9919890..b38d3ce 100644 --- a/apps/web/shared/api/post.api.ts +++ b/apps/web/shared/api/post.api.ts @@ -20,6 +20,9 @@ import { type GetPostDetailData, type GetPostDetailParams, type GetPostDetailResponse, + IncreasePostView, + type IncreasePostViewParams, + type IncreasePostViewResponse, } from '@blog/contracts' // ๊ธ€ ๋ชฉ๋ก ์กฐํšŒ @@ -83,3 +86,18 @@ export async function getMonthPostList( if (res.status !== 200) throw new Error(res.message) return res.data } + +// ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€ (์ฟ ํ‚ค ๊ธฐ๋ฐ˜ 1์‹œ๊ฐ„ ์ค‘๋ณต ๋ฐฉ์ง€) +export async function increasePostView( + params: IncreasePostViewParams, +): Promise { + const res = await API.post( + IncreasePostView.path(params.postSeq), + ) + + if (res.status !== 200) { + throw new Error(res.message) + } + + return res.data.views +} diff --git a/package.json b/package.json index cc57097..9ff035f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "husky": "^9.1.7", "lint-staged": "^16.2.7", "prettier": "^3.8.0", - "turbo": "^2.8.3", + "turbo": "^2.8.10", "typescript": "^5" }, "lint-staged": { diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 0000000..75bbb82 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,174 @@ +# @blog/contracts + +**Zod ๊ธฐ๋ฐ˜ API Contract Single Source of Truth ํŒจํ‚ค์ง€**์ž…๋‹ˆ๋‹ค. +Web / BO / API๊ฐ€ ๊ณต์œ ํ•˜๋Š” Schema Layer์ด์ž API Spec Registry ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค. + +์ด ํŒจํ‚ค์ง€๋Š” ๋‹จ์ˆœ ํƒ€์ž… ์ •์˜๊ฐ€ ์•„๋‹ˆ๋ผ, + +- API ์š”์ฒญ/์‘๋‹ต ์Šคํ‚ค๋งˆ ์ •์˜ +- ๋Ÿฐํƒ€์ž„ validation ๊ธฐ์ค€ ์ œ๊ณต +- ์ปดํŒŒ์ผ ํƒ€์ž„ ํƒ€์ž… ์ถ”๋ก  +- Cache Tag ๊ทœ์น™ ์ •์˜ +- Revalidate ์ •์ฑ… ์ •์˜ + +๋ฅผ ํ•˜๋‚˜์˜ ๊ณ„์ธต์œผ๋กœ ํ†ตํ•ฉ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +## 1. Why Contracts Layer? + +Web / BO / API๋Š” ๋ฌผ๋ฆฌ์ ์œผ๋กœ ๋ถ„๋ฆฌ๋˜์–ด ์žˆ์ง€๋งŒ, +๋ฐ์ดํ„ฐ ๊ณ„์•ฝ์€ ๋‹จ์ผ ๊ธฐ์ค€์œผ๋กœ ์œ ์ง€๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + +์ด๋ฅผ ์œ„ํ•ด + +- ํƒ€์ž… ์ •์˜ ์ค‘๋ณต ์ œ๊ฑฐ +- ๋Ÿฐํƒ€์ž„ ๊ฒ€์ฆ ๊ธฐ์ค€ ํ†ต์ผ +- ์บ์‹œ ๋ฌดํšจํ™” ๊ทœ์น™ ์ค‘์•™ํ™” + +๋ฅผ ๋ชฉํ‘œ๋กœ ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +๐Ÿ‘‰ ๋ฌผ๋ฆฌ์  ๋ถ„๋ฆฌ์™€ ๋…ผ๋ฆฌ์  ์ผ๊ด€์„ฑ์„ ๋™์‹œ์— ํ™•๋ณดํ•ฉ๋‹ˆ๋‹ค. + +## 2. What This Package Contains + +### โ‘  API Spec Definition + +๊ฐ API๋Š” ๋‹ค์Œ ์ •๋ณด๋ฅผ ๋ชจ๋‘ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค: + +```ts +export const GetPostDetail = { + method: 'GET', + path: (postSeq: number | string) => `/post/${postSeq}`, + Params: z.object({...}), + Response: ApiResponseStrict(PostSchema), +} +``` + +ํฌํ•จ ์š”์†Œ: + +- HTTP method +- path ์ •์˜ +- Params / Query / Body schema +- Response schema +- (Write API์˜ ๊ฒฝ์šฐ) revalidate ์ •์ฑ… + +๐Ÿ‘‰ API ๋ฌธ์„œ, ํƒ€์ž…, ๊ฒ€์ฆ ๊ธฐ์ค€์ด ํ•˜๋‚˜์˜ ๊ฐ์ฒด์— ํ†ตํ•ฉ๋ฉ๋‹ˆ๋‹ค. + +### โ‘ก Zod ๊ธฐ๋ฐ˜ Runtime Validation + +```ts +export const CreatePostSchema = z.object({ + title: z.string().min(1).max(100), + content: z.string().min(1), + tags: z.array(z.string()).max(10), + category: z.string().min(1), +}) +``` + +- API ์„œ๋ฒ„์—์„œ runtime validation ์ˆ˜ํ–‰ +- Web / BO์—์„œ๋Š” ํƒ€์ž… ์ถ”๋ก  ๊ธฐ๋ฐ˜ ์ปดํŒŒ์ผ ํƒ€์ž„ ์•ˆ์ „์„ฑ ํ™•๋ณด + +๐Ÿ‘‰ ํ•˜๋‚˜์˜ ์Šคํ‚ค๋งˆ๊ฐ€ ๋Ÿฐํƒ€์ž„๊ณผ ์ปดํŒŒ์ผ ํƒ€์ž„์„ ๋™์‹œ์— ๊ด€ํ†ตํ•ฉ๋‹ˆ๋‹ค. + +### โ‘ข Type Inference (Single Source of Truth) + +```ts +export type CreatePostBody = z.infer +export type GetPostDetailResponse = z.infer +``` + +- API์™€ Web์—์„œ ๋™์ผ ํƒ€์ž… ์‚ฌ์šฉ +- ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ ์‹œ ์ปดํŒŒ์ผ ๋‹จ๊ณ„์—์„œ ์ฆ‰์‹œ ๊ฐ์ง€ + +๐Ÿ‘‰ ํƒ€์ž… ์ •์˜ ์ง€์  3 โ†’ 1๋กœ ์ˆ˜๋ ด + +### โ‘ฃ Cache Tag Registry + +```ts +export const CacheTags = { + Post: { + list: 'post:list', + byId: (postSeq) => `post:${postSeq}`, + calendar: (year, month) => `post:calendar:${year}-${month}`, + }, +} +``` + +- Tag ๋ฌธ์ž์—ด ํ•˜๋“œ์ฝ”๋”ฉ ์ œ๊ฑฐ +- Web / API์—์„œ ๋™์ผ ๊ทœ์น™ ๊ณต์œ  +- Tag ์ •์ฑ… ๋ณ€๊ฒฝ ์‹œ ์˜ํ–ฅ ๋ฒ”์œ„ ์ถ”์  ๊ฐ€๋Šฅ + +### โ‘ค Revalidation Policy Registry + +Write API๋Š” revalidate ๊ทœ์น™์„ ํ•จ๊ป˜ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. + +```ts +export const UpdatePost = { + ... + revalidate: (postSeq) => [ + CacheTags.Post.byId(postSeq), + CacheTags.Post.list, + CacheTags.Post.popular, + ], +} +``` + +๐Ÿ‘‰ ๋ฌดํšจํ™” ์ •์ฑ…์„ API ๊ตฌํ˜„์ด ์•„๋‹ˆ๋ผ ๊ณ„์•ฝ ๊ณ„์ธต์—์„œ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +- ์บ์‹œ ์ •์ฑ…์ด ์ฝ”๋“œ์— ๋ถ„์‚ฐ๋˜์ง€ ์•Š์Œ +- ๋ณ€๊ฒฝ ์˜ํ–ฅ ๋ฒ”์œ„๊ฐ€ ๋ช…ํ™•ํ•ด์ง + +## 3. Response Wrapper Abstraction + +๊ณตํ†ต ์‘๋‹ต ๊ตฌ์กฐ๋„ ์ค‘์•™ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. + +```ts +export const ApiResponseStrict = (schema: T) => + z.object({ + status: z.number(), + message: z.string(), + data: schema, + }) +``` + +- ์‘๋‹ต ํฌ๋งท ์ผ๊ด€์„ฑ ํ™•๋ณด +- API ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์‹œ ์ค‘์•™ ์ˆ˜์ • ๊ฐ€๋Šฅ + +## 4. ESM / CJS Dual Build + +์ด ํŒจํ‚ค์ง€๋Š” ESM, CJS๋ฅผ ๋ชจ๋‘ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. + +- ESM (Next.js / modern bundler) +- CJS (NestJS runtime) + +```json +"exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" + } +} +``` + +๐Ÿ‘‰ ๋Ÿฐํƒ€์ž„ ํ™˜๊ฒฝ ์ฐจ์ด๋ฅผ ํก์ˆ˜ํ•˜๋Š” ๊ณต์œ  ํŒจํ‚ค์ง€ ๊ตฌ์กฐ + +## 5. Architectural Role + +์ด ํŒจํ‚ค์ง€๋Š” ์•„๋ž˜์™€ ๊ฐ™์€ ์—ญํ• ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +- ๐Ÿ“˜ API ๋ช…์„ธ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ +- ๐Ÿง  Schema Layer +- ๐Ÿ” Validation ๊ธฐ์ค€ +- ๐Ÿ” Cache Policy Registry +- ๐Ÿ”— Web / BO / API ๊ฒฝ๊ณ„ ํ†ตํ•ฉ ๊ณ„์ธต + +๐Ÿ‘‰ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์€ ๋ถ„๋ฆฌ๋˜์–ด ์žˆ์ง€๋งŒ, +๋ฐ์ดํ„ฐ ๊ณ„์•ฝ์€ ์ด ๊ณ„์ธต์—์„œ ์ผ๊ด€๋˜๊ฒŒ ์œ ์ง€๋ฉ๋‹ˆ๋‹ค. + +## Repository Navigation + +- ๐Ÿ—‚๏ธ **Root**: [Blog Platform โ†—](https://github.com/Blog-Archive-Ian/blog-platform) +- ๐Ÿ—‚๏ธ **Web**: [apps/web โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web) +- ๐Ÿ—‚๏ธ **Back Office**: [apps/bo โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo) +- ๐Ÿ—‚๏ธ **API Server**: [apps/api โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api) +- ๐Ÿ—‚๏ธ **Contracts**: [packages/contracts โ†—](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts) diff --git a/packages/contracts/src/post/post.api.ts b/packages/contracts/src/post/post.api.ts index e54af83..478e602 100644 --- a/packages/contracts/src/post/post.api.ts +++ b/packages/contracts/src/post/post.api.ts @@ -212,3 +212,23 @@ export const UnArchivePost = { } as const export type UnArchivePostResponse = z.infer export type UnArchivePostParams = z.infer + +// ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€ (์ฟ ํ‚ค ๊ธฐ๋ฐ˜ ์ค‘๋ณต ๋ฐฉ์ง€ ์ „์šฉ) +export const IncreasePostView = { + method: 'POST', + path: (postSeq: number | string) => `/post/${postSeq}/view`, + Params: z.object({ + postSeq: z.number(), + }), + Response: ApiResponseStrict( + z.object({ + views: z.number(), + }), + ), + revalidate: (postSeq: number | string) => [ + CacheTags.Post.byId(postSeq), + CacheTags.Post.popular, + ], +} as const +export type IncreasePostViewResponse = z.infer +export type IncreasePostViewParams = z.infer diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a0e8dc..26138cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: ^3.8.0 version: 3.8.0 turbo: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.10 + version: 2.8.10 typescript: specifier: ^5 version: 5.9.3 @@ -7450,38 +7450,38 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.8.3: - resolution: {integrity: sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw==} + turbo-darwin-64@2.8.10: + resolution: {integrity: sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.8.3: - resolution: {integrity: sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ==} + turbo-darwin-arm64@2.8.10: + resolution: {integrity: sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.8.3: - resolution: {integrity: sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA==} + turbo-linux-64@2.8.10: + resolution: {integrity: sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.8.3: - resolution: {integrity: sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q==} + turbo-linux-arm64@2.8.10: + resolution: {integrity: sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ==} cpu: [arm64] os: [linux] - turbo-windows-64@2.8.3: - resolution: {integrity: sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw==} + turbo-windows-64@2.8.10: + resolution: {integrity: sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.8.3: - resolution: {integrity: sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw==} + turbo-windows-arm64@2.8.10: + resolution: {integrity: sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ==} cpu: [arm64] os: [win32] - turbo@2.8.3: - resolution: {integrity: sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ==} + turbo@2.8.10: + resolution: {integrity: sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ==} hasBin: true tw-animate-css@1.4.0: @@ -16165,32 +16165,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.8.3: + turbo-darwin-64@2.8.10: optional: true - turbo-darwin-arm64@2.8.3: + turbo-darwin-arm64@2.8.10: optional: true - turbo-linux-64@2.8.3: + turbo-linux-64@2.8.10: optional: true - turbo-linux-arm64@2.8.3: + turbo-linux-arm64@2.8.10: optional: true - turbo-windows-64@2.8.3: + turbo-windows-64@2.8.10: optional: true - turbo-windows-arm64@2.8.3: + turbo-windows-arm64@2.8.10: optional: true - turbo@2.8.3: + turbo@2.8.10: optionalDependencies: - turbo-darwin-64: 2.8.3 - turbo-darwin-arm64: 2.8.3 - turbo-linux-64: 2.8.3 - turbo-linux-arm64: 2.8.3 - turbo-windows-64: 2.8.3 - turbo-windows-arm64: 2.8.3 + turbo-darwin-64: 2.8.10 + turbo-darwin-arm64: 2.8.10 + turbo-linux-64: 2.8.10 + turbo-linux-arm64: 2.8.10 + turbo-windows-64: 2.8.10 + turbo-windows-arm64: 2.8.10 tw-animate-css@1.4.0: {}