diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..cce063c --- /dev/null +++ b/.eslintignore @@ -0,0 +1,16 @@ +# 빌드 산출물 +dist/ +coverage/ +logs/ +node_modules/ + +# 설정 파일 +*.config.js +*.config.ts + +# 환경파일 등 +.env +.env.* + +# 기타 +!.eslintrc.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..5847758 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,18 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": ["plugin:@typescript-eslint/recommended", "prettier"], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "env": { + "node": true, + "es2022": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-var-requires": "off" + } +} diff --git a/.github/ISSUE_TEMPLATE/bug-report---.md b/.github/ISSUE_TEMPLATE/bug-report---.md deleted file mode 100644 index 2d741f8..0000000 --- a/.github/ISSUE_TEMPLATE/bug-report---.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: "Bug Report(버그 보고서) \U0001F41B" -about: "Create a report to help us improve" -title: "" -labels: "\U0001F41E Bug" -assignees: "ljlm0402" ---- - -### Describe the Bug (버그 설명) - - - -### Version to Reproduce (현재 사용한 버전) - - - -### Steps to Reproduce (재현 순서) - - - -### Expected Behavior (예상 동작) - - - -### Actual Behavior (실제 동작) - - - -### Additional Context (추가 사항) - - - -### Capture screen (캡쳐 화면) - - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..b9a430c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,52 @@ +name: "\U0001F41B Bug Report" +description: Report a bug or unexpected behavior. +title: "[Bug] " +labels: [bug] +assignees: "ljlm0402" +body: + - type: textarea + id: what-happened + attributes: + label: Description + description: Please describe the bug in detail. + placeholder: ex) The app crashes when clicking the button. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: List the steps to reproduce the bug. + placeholder: | + 1. Open the app + 2. Click the X button + 3. See the error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + placeholder: ex) The screen should change normally. + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots / Logs + description: Attach screenshots or logs if available. + - type: input + id: env + attributes: + label: Environment + description: "OS, browser, version, etc. (ex: Windows 11, Chrome 124)" + validations: + required: false + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have searched for existing issues. + - label: I have checked the relevant documentation. diff --git a/.github/ISSUE_TEMPLATE/feature-request---.md b/.github/ISSUE_TEMPLATE/feature-request---.md deleted file mode 100644 index f73f982..0000000 --- a/.github/ISSUE_TEMPLATE/feature-request---.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: "Feature Request(기능 요청서) \U0001F4A1" -about: "Suggest an idea for this project" -title: "" -labels: "\U000026A1 Feature" -assignees: "ljlm0402" ---- - -### Motivation (새로운 기능 설명) - - - -### Proposed Solution (기능을 통해 얻고자 하는 솔루션) - - - -### Alternatives (제안 된 솔루션이 더 나은 이유) - - - -### Additional Context (추가 사항) - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..e8b43f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,35 @@ +name: "\U0001F4A1 Feature Request" +description: Suggest a new feature or improvement. +title: "[Feature] " +labels: [enhancement] +assignees: "ljlm0402" +body: + - type: textarea + id: summary + attributes: + label: Summary + description: Briefly describe the feature or improvement. + placeholder: ex) Add notification feature for users + validations: + required: true + - type: textarea + id: detail + attributes: + label: Details + description: Describe the feature or improvement in detail. + placeholder: ex) Send email notifications when users log in successfully. + validations: + required: true + - type: textarea + id: reason + attributes: + label: Reason / Benefit + description: Why is this needed? What benefits are expected? + placeholder: ex) Reduce churn by notifying users of key events. + validations: + required: false + - type: textarea + id: etc + attributes: + label: Additional Context + description: Any references, links, or related screenshots. diff --git a/.github/ISSUE_TEMPLATE/qna.yaml b/.github/ISSUE_TEMPLATE/qna.yaml new file mode 100644 index 0000000..a69c784 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/qna.yaml @@ -0,0 +1,35 @@ +name: "\U00002753 Q&A" +description: Ask a question or request clarification. +title: "[Q&A] " +labels: [question] +assignees: "ljlm0402" +body: + - type: textarea + id: question + attributes: + label: Question + description: Please describe your question or what you need help with. + placeholder: ex) How can I customize the login page UI? + validations: + required: true + - type: textarea + id: attempts + attributes: + label: What have you tried? + description: Describe any attempts, research, or steps you've already taken to solve the issue. + placeholder: ex) I checked the documentation and tried modifying `login.scss`, but it had no effect. + validations: + required: false + - type: textarea + id: expected + attributes: + label: What kind of answer would be helpful? + description: Let us know what kind of information or solution you are looking for. + placeholder: ex) Sample code or documentation reference would be helpful. + validations: + required: false + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other details, screenshots, or references that may help. diff --git a/.github/ISSUE_TEMPLATE/question---.md b/.github/ISSUE_TEMPLATE/question---.md deleted file mode 100644 index 454e916..0000000 --- a/.github/ISSUE_TEMPLATE/question---.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: "Question(문의) \U00002753" -about: "Ask a question about this project" -title: "" -labels: "\U00002754 Question" -assignees: "ljlm0402" ---- - -### Summary (요약) - - - -### Additional Details (추가적인 세부 사항) - - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5c9c67a..6ee0c62 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,36 +1,22 @@ -## Content (작업 내용) 📝 +## 📌 Summary - - + -### Screenshot for work content (작업 내용 스크린샷) 📸 +## 💡 Reason for Change - - + -## Links (링크) 🔗 +## ✅ Checklist -- Issue Links (이슈 링크) +- [ ] I have followed the code/documentation style guidelines. +- [ ] I have tested the relevant functionality. +- [ ] This change does not negatively affect existing features. +- [ ] I have linked related issues/commits/documents below if necessary. -- API Links (API 스팩 문서) +## 🔗 Related Issues (optional) -- Development Links (개발 문서) +- #issue_number -- Document Links (기획 문서) +## 📝 Additional Notes -- Design Links (디자인 문서) - -## Etc (기타 사항) 🔖 - - - - -## Check List (체크 사항) ✅ - - - - -- [ ] Issue (이슈) -- [ ] Tests (테스트) -- [ ] Ready to be merged (병합 준비 완료) -- [ ] Added myself to contributors table (기여자 추가) + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4efe235..837b2a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,26 +1,24 @@ name: npm publish on: - pull_request: - branches: - - master - paths: - - package.json + push: + tags: + - "v*.*.*" jobs: build: runs-on: ubuntu-latest steps: - - name: ⬇️ Checkout source code - uses: actions/checkout@v2 + - name: ⬇️ Checkout Repository + uses: actions/checkout@v3 - name: ⎔ Setup nodejs - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: - node-version: 16 + node-version: lts/* registry-url: https://registry.npmjs.org/ - - name: 🏗 Publish npm + - name: 🏗 Publish to npm run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/.npmignore b/.npmignore index 26f5c15..9143bde 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,8 @@ -.github +.vscode/ +.github/ +.eslintrc +eslint.config.js +.prettierrc +.prettierignore +pnpm-lock.yaml +*.log diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1f67368 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 150, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "arrowParens": "avoid" +} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md deleted file mode 100644 index 789d7b5..0000000 --- a/CONTRIBUTORS.md +++ /dev/null @@ -1,69 +0,0 @@ -- Jeongwon Kim [https://github.com/swtpumpkin](https://github.com/swtpumpkin) - -- João Silva [https://github.com/joaopms](https://github.com/joaopms) - -- BitYoungjae [https://github.com/BitYoungjae](https://github.com/BitYoungjae) - -- Paolo Tagliani [https://github.com/pablosproject](https://github.com/pablosproject) - -- Lloyd Park [https://github.com/yeondam88](https://github.com/yeondam88) - -- strama4 [https://github.com/strama4](https://github.com/strama4) - -- sonbyungjun [https://github.com/sonbyungjun](https://github.com/sonbyungjun) - -- Sean Maxwell [https://github.com/seanpmaxwell](https://github.com/seanpmaxwell) - -- Ed Guy [https://github.com/edguy3](https://github.com/edguy3) - -- Malavan [https://github.com/malavancs](https://github.com/malavancs) - -- Jon Gallant [https://github.com/jongio](https://github.com/jongio) - -- Kuba Rozkwitalski [https://github.com/kubarozkwitalski](https://github.com/kubarozkwitalski) - -- Craig Harman [https://github.com/craigharman](https://github.com/craigharman) - -- Edward Teixeira Dias Junior [https://github.com/edward-teixeira](https://github.com/edward-teixeira) - -- n2ptune [https://github.com/n2ptune](https://github.com/n2ptune) - -- michael r [https://github.com/alanmynah](https://github.com/alanmynah) - -- Benjamin [https://github.com/benjaminudoh10](https://github.com/benjaminudoh10) - -- Amrik Singh [https://github.com/AmrikSD](https://github.com/AmrikSD) - -- oricc [https://github.com/oricc](https://github.com/oricc) - -- Dustin Newbold [https://github.com/dustinnewbold](https://github.com/dustinnewbold) - -- WhatIfWeDigDeeper [https://github.com/WhatIfWeDigDeeper](https://github.com/WhatIfWeDigDeeper) - -- David Stewart [https://github.com/davidjmstewart](https://github.com/davidjmstewart) - -- JagTheFriend [JagTheFriend](https://github.com/JagTheFriend) - -- Tamzid Karim [Tamzid Karim](https://github.com/tamzidkarim) - -- Andrija Milojević [https://github.com/andrija29](https://github.com/andrija29) - -- Engjell Avdiu [https://github.com/engjellavdiu](https://github.com/engjellavdiu) - -- Florian Mifsud [https://github.com/florianmifsud](https://github.com/florianmifsud) - -- Rashid [https://github.com/rashidmya](https://github.com/rashidmya) - -- JoshuaOLoduca [https://github.com/JoshuaOLoduca](https://github.com/JoshuaOLoduca) - -- Markus Laubscher [https://github.com/markuslaubscher](https://github.com/markuslaubscher) - -- coder-palak [https://github.com/coder-palak](https://github.com/coder-palak) - -- H2RO [https://github.com/primary-1](https://github.com/primary-1) - -- Emmanuel Yeboah [https://github.com/noelzappy](https://github.com/noelzappy) - -- Jonathan Felicity [https://github.com/jonathanfelicity](https://github.com/jonathanfelicity) - -- Hichem Fantar [https://github.com/hichemfantar](https://github.com/hichemfantar) diff --git a/README.kr.md b/README.kr.md index ee31b6c..33c95cf 100644 --- a/README.kr.md +++ b/README.kr.md @@ -1,13 +1,13 @@


- 프로젝트 로고 + 프로젝트 로고

- 타입스크립트 익스프레스 스타터 + TypeScript Express Starter

-

🚀 타입스크립트 기반의 익스프레스 보일러 플레이트 스타터 패키지

+

🚀 TypeScript 기반 Express RESTful API 보일러플레이트

@@ -20,31 +20,16 @@ npm 버전 - npm 릴리즈 버전 + GitHub 릴리즈 버전 npm 다운로드 수 - npm 패키지 라이선스 + 라이선스

-

- - github 스타 - - - github 포크 - - - github 컨트리뷰터 - - - github 이슈 - -

-
- [🇰🇷 한국어](https://github.com/ljlm0402/typescript-express-starter/blob/master/README.kr.md) @@ -52,272 +37,142 @@
-## 😎 프로젝트를 소개합니다. - -Express는 유형 정의에 취약한 JavaScript로 구성 되어있습니다. - -이것이 바로 TypeScript를 도입하는 스타터 패키지로 수퍼 세트를 피하는 이유입니다. - -패키지는 JavaScript 대신 TypeScript를 사용하도록 구성되어 있습니다. - -> 참고 : [express-generator-typescript](https://github.com/seanpmaxwell/express-generator-typescript) - -### 🤔 Express는 무엇인가요 ? - -Node.js를 위한 빠르고 개방적인 간결한 웹 프레임워크입니다. - -## 🚀 시작하기 - -### npm 전역 설치 - -```bash -$ npm install -g typescript-express-starter -``` - -### npx를 통해 프로젝트를 설치 - -프로젝트 이름을 입력하지 않으면, 기본값으로 _typescript-express-starter_ 폴더로 설치됩니다. - -```bash -$ npx typescript-express-starter "project name" -``` - -### 원하시는 템플릿을 선택 - -예시 - -설치가 완료되면 Script 명령어를 통해 프로젝트를 실행합니다. - -#### 템플릿 종류 +## 📝 소개 -| 이름 | 설명 | -| :---------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Default | Express 기본 | -| [routing controllers](https://github.com/typestack/routing-controllers) | 데코레이터 사용량이 많은 구조화되고 선언적이며 아름답게 구성된 클래스 기반 컨트롤러 생성 | -| [Sequelize](https://github.com/sequelize/sequelize) | PostgreSQL, MySQL, MariaDB, SQLite, Microsoft SQL Server를 지원하는 Promise 패턴 기반의 Node.js ORM | -| [Mongoose](https://github.com/Automattic/mongoose) | Node.js와 MongoDB를 위한 ODM(Object Data Mapping) 라이브러리 | -| [TypeORM](https://github.com/typeorm/typeorm) | 자바스크립트, 타입스크립트과 함께 사용되어 Node.js, React Native, Expo에서 실행될 수 있는 ORM | -| [Prisma](https://github.com/prisma/prisma) | 데이터베이스에 데이터를 프로그래밍 언어의 객체와 매핑하여 기존에 SQL로 작성하던 데이터를 수정, 테이블 구조 변경등의 작업을 객체를 통해 프로그래밍적으로 할 수 있도록 해주는 ORM | -| [Knex](https://github.com/knex/knex) | 쿼리 빌더를 위한 라이브러리 | -| [GraphQL](https://github.com/graphql/graphql-js) | API 용 쿼리 언어이며 기존 데이터로 이러한 쿼리를 수행하기위한 런타임 | -| [Typegoose](https://github.com/typegoose/typegoose) | 타입스크립트 클래스를 사용하여 몽구스 모델 정의 | -| [Mikro ORM](https://github.com/mikro-orm/mikro-orm) | 데이터 매퍼, 작업 단위 및 아이덴티티 맵 패턴을 기반으로 하는 Node.js용 TypeScript ORM. MongoDB, MySQL, MariaDB, PostgreSQL 및 SQLite 데이터베이스를 지원 | -| [Node Postgres](https://node-postgres.com/) | PostgreSQL 데이터베이스와 인터페이스하기 위한 node.js 모듈 | - -#### 추후 개발 할 템플릿 - -| 이름 | 설명 | -| :------------------------------------------------------------------------------ | :------------------------------------------------------------------ | -| [Sequelize Typescript](https://github.com/RobinBuschmann/sequelize-typescript) | 데코레이터 및 Sequelize를 위한 몇 가지 기능 | -| [TS SQL](https://github.com/codemix/ts-sql) | SQL 데이터베이스는 TypeScript 유형 주석으로 순전히 구현 | -| [inversify-express-utils](https://github.com/inversify/inversify-express-utils) | InversifyJS를 사용한 Express 애플리케이션 개발을 위한 일부 유틸리티 | -| [postgress Typescript]() | | -| [graphql-prisma]() | | - -## 🛎 Script 명령어 - -- 프로덕션 모드 실행 : `npm run start` 아니면 `Start typescript-express-starter` VS Code 로 -- 개발 모드 실행 : `npm run dev` 아니면 `Dev typescript-express-starter` VS Code 로 -- 단위 테스트 : `npm test` 아니면 `Test typescript-express-starter` VS Code 로 -- 코드 포맷터 검사 : `npm run lint` 아니면 `Lint typescript-express-starter` VS Code 로 -- 코드 포맷터 적용 : `npm run lint:fix` 아니면 `Lint:Fix typescript-express-starter` VS Code 로 - -## 💎 프로젝트 기능 - -

-    -    -    -

-

-    - -    -    -    -    -    - - -

-

-    -    -    - -

+**TypeScript Express Starter**는 안정적이고 확장 가능한 RESTful API 서버를 빠르게 구축할 수 있는 보일러플레이트입니다. +Express의 유연함과 간결함에 TypeScript의 타입 안정성을 결합하여, 프로토타입 단계부터 프로덕션까지 품질과 유지보수성을 보장합니다. -### 🐳 Docker :: 컨테이너 플랫폼 +- 깔끔한 아키텍처와 모듈 구조 -[Docker](https://docs.docker.com/)란, 컨테이너 기반의 오픈소스 가상화 플랫폼이다. +- 기본 내장 보안, 로깅, 유효성 검사, 개발 도구 -[설치 홈페이지](https://docs.docker.com/get-docker/)에 접속해서 설치를 해줍니다. +- 빠른 개발과 안정적인 배포 환경 지원 -- 백그라운드에서 컨테이너를 시작하고 실행 : `docker-compose up -d` -- 컨테이너를 중지하고 컨테이너, 네트워크, 볼륨 및 이미지를 제거 : `docker-compose down` +## 💎 주요 기능 -수정을 원하시면 `docker-compose.yml`과 `Dockerfile`를 수정해주시면 됩니다. +- ⚡ **TypeScript + Express** — 최신 JavaScript와 완전한 타입 안정성 제공 -### ♻️ Nginx :: 웹 서버 +- 📜 **API 문서** — Swagger/OpenAPI를 기본 제공 -[Nginx](https://www.nginx.com/) 역방향 프록시,로드 밸런서, 메일 프록시 및 HTTP 캐시로도 사용할 수있는 웹 서버입니다. +- 🛡 **보안** — Helmet, CORS, HPP, 요청 속도 제한(rate limiting) 기본 포함 -프록시는 일반적으로 여러 서버에로드를 분산하거나, 다른 웹 사이트의 콘텐츠를 원활하게 표시하거나, HTTP 이외의 프로토콜을 통해 처리 요청을 애플리케이션 서버에 전달하는 데 사용됩니다. +- 🧩 **유효성 검사** — Zod 기반의 스키마 런타임 유효성 검사 -Nginx 요청을 프록시하면 지정된 프록시 서버로 요청을 보내고 응답을 가져 와서 클라이언트로 다시 보냅니다. +- 🔗 **의존성 주입** — tsyringe를 활용한 경량 DI 지원 -수정을 원하시면 `nginx.conf` 파일을 수정해주시면 됩니다. +- 🗄 **데이터베이스 연동** — Sequelize, Prisma, Mongoose, TypeORM, Knex, Drizzle 등 지원 -### ✨ ESLint, Prettier :: 정적 코드 분석 및 코드 스타일 변환 +- 🛠 **개발 도구** — ESLint, Prettier, Jest, Docker, PM2, NGINX, Makefile 포함 -[ESLint](https://eslint.org/)는 JavaScript 코드에서 발견 된 문제 패턴을 식별하기위한 정적 코드 분석 도구입니다. +- 🧱 **모듈형 아키텍처** — 손쉽게 확장 및 유지보수 가능 -[Prettier](https://prettier.io/)는 개발자가 작성한 코드를 정해진 코딩 스타일을 따르도록 변환해주는 도구입니다. +- 🚀 **프로덕션 준비 완료** — Docker, PM2, NGINX 지원 -코드를 구문 분석하고 최대 줄 길이를 고려하여 필요한 경우 코드를 래핑하는 자체 규칙으로 다시 인쇄하여 일관된 스타일을 적용합니다. +## ⚡️ 빠른 시작 -1. [VSCode](https://code.visualstudio.com/) Extension에서 [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode), [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) 설치합니다. - -2. 설치가 완료되면, 단축키 `CMD` + `Shift` + `P` (Mac Os) 또는 `Ctrl` + `Shift` + `P` (Windows) 입력합니다. - -3. Format Selection With 선택합니다. - -4. Configure Default Formatter... 선택합니다. - -5. Prettier - Code formatter 적용합니다. - -Formatter 설정 - -> 2019년, TSLint 지원이 종료 되어 ESLint를 적용하였습니다. - -### 📗 Swagger :: API 문서화 - -[Swagger](https://swagger.io/)는 개발자가 REST 웹 서비스를 설계, 빌드, 문서화, 소비하는 일을 도와주는 대형 도구 생태계의 지원을 받는 오픈 소스 소프트웨어 프레임워크이다. - -API를 대규모로 설계하고 문서화하는 데 용이하게 사용합니다. - -Swagger URL은 `http://localhost:3000/api-docs` 으로 작성했습니다. - -수정을 원하시면 `swagger.yaml` 파일을 수정해주시면 됩니다. - -### 🌐 REST Client :: HTTP Client 도구 - -REST 클라이언트를 사용하면 HTTP 요청을 보내고 Visual Studio Code에서 직접 응답을 볼 수 있습니다. - -VSCode Extension에서 [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) 설치합니다. - -수정을 원하시면 src/http 폴더 안에 `*.http` 파일을 수정해주시면 됩니다. - -### 🔮 PM2 :: 웹 애플리케이션을 운영 및 프로세스 관리자 - -[PM2](https://pm2.keymetrics.io/)란, 서버에서 웹 애플리케이션을 운영할 때 보통 데몬으로 서버를 띄워야 하고 Node.js의 경우 서버가 크래시나면 재시작을 하기 위해서 워치독(watchdog) 류의 프로세스 관리자이다. - -- 프로덕션 모드 :: `npm run deploy:prod` 또는 `pm2 start ecosystem.config.js --only prod` -- 개발 모드 :: `npm run deploy:dev` 또는 `pm2 start ecosystem.config.js --only dev` - -수정을 원하시면 `ecosystem.config.js` 파일을 수정해주시면 됩니다. - -### 🏎 SWC :: 강하고 빠른 자바스크립트 / 타입스크립트 컴파일러 - -[SWC](https://swc.rs/)는 차세대 고속 개발자 도구를 위한 확장 가능한 Rust 기반 플랫폼입니다. - -`SWC는 단일 스레드에서 Babel보다 20배, 4개 코어에서 70배 빠릅니다.` - -- tsc 빌드 :: `npm run build` -- swc 빌드 :: `npm run build:swc` +```bash +# 전역 설치 +npm install -g typescript-express-starter -수정을 원하시면 `.swcrc` 파일을 수정해주시면 됩니다. +# 새 프로젝트 생성 +typescript-express-starter +cd my-app -### 💄 Makefile :: Linux에서 반복 적으로 발생하는 컴파일을 쉽게하기위해서 사용하는 make 프로그램의 설정 파일 +# 개발 모드 실행 +npm run dev +``` +- 앱 접속: http://localhost:3000/ -- 도움말 :: `make help` +- 자동 생성된 API 문서: http://localhost:3000/api-docs -수정을 원하시면 `Makefile` 파일을 수정해주시면 됩니다. +### 샘플 영상 -## 🗂 코드 구조 (default) +## 📂 프로젝트 구조 ```bash -│ -├──📂 .vscode -│ ├── launch.json -│ └── settings.json -│ -├──📂 src -│ ├──📂 config -│ │ └── index.ts -│ │ -│ ├──📂 controllers -│ │ ├── auth.controller.ts -│ │ └── users.controller.ts -│ │ -│ ├──📂 dtos -│ │ └── users.dto.ts -│ │ -│ ├──📂 exceptions -│ │ └── httpException.ts -│ │ -│ ├──📂 http -│ │ ├── auth.http -│ │ └── users.http -│ │ -│ ├──📂 interfaces -│ │ ├── auth.interface.ts -│ │ ├── routes.interface.ts -│ │ └── users.interface.ts -│ │ -│ ├──📂 middlewares -│ │ ├── auth.middleware.ts -│ │ ├── error.middleware.ts -│ │ └── validation.middleware.ts -│ │ -│ ├──📂 models -│ │ └── users.model.ts -│ │ -│ ├──📂 routes -│ │ ├── auth.route.ts -│ │ └── users.route.ts -│ │ -│ ├──📂 services -│ │ ├── auth.service.ts -│ │ └── users.service.ts -│ │ -│ ├──📂 test -│ │ ├── auth.test.ts -│ │ └── users.test.ts -│ │ -│ ├──📂 utils -│ │ ├── logger.ts -│ │ └── vaildateEnv.ts -│ │ -│ ├── app.ts -│ └── server.ts -│ -├── .dockerignore -├── .editorconfig -├── .env.development.local -├── .env.production.local -├── .env.test.local -├── .eslintignore -├── .eslintrc -├── .gitignore -├── .huskyrc -├── .lintstagedrc.json -├── .prettierrc -├── .swcrc -├── docker-compose.yml -├── Dockerfile.dev -├── Dockerfile.prod -├── ecosystem.config.js -├── jest.config.js -├── Makefile -├── nginx.conf -├── nodemon.json -├── package-lock.json -├── package.json -├── swagger.yaml -└── tsconfig.json +src/ + ├── config/ # 환경 변수, 설정 파일 + ├── controllers/ # 요청 처리 및 응답 반환 + ├── dtos/ # 요청/응답 데이터 구조 정의 + ├── exceptions/ # 커스텀 예외 클래스 + ├── interfaces/ # 타입/인터페이스 정의 + ├── middlewares/ # 미들웨어 (로그, 인증, 에러 처리 등) + ├── repositories/ # 데이터베이스 접근 로직 + ├── routes/ # 라우팅 정의 + ├── services/ # 비즈니스 로직 + ├── utils/ # 유틸리티 함수 + ├── app.ts # Express 앱 초기화 + └── server.ts # 서버 실행 엔트리 포인트 + +.env # 기본 환경 변수 +.env.development.local # 개발 환경 변수 +.env.production.local # 운영 환경 변수 +.env.test.local # 테스트 환경 변수 +nodemon.json # Nodemon 환경 변수 +swagger.yaml # Swagger API 문서 정의 +tsconfig.jsnon # TypeScript 환경 변수 ``` +## 🛠 개발 도구(Devtools) 유형 + +| 구분 | 도구 / 설정 파일 | 설명 | +| --------------- | ------------------- | -------------------------------- | +| **코드 포맷터 / 린터** | `biome`, `prettier`, `eslint` | 코드 포맷팅 및 린팅 규칙 설정 | +| **빌드 / 번들러** | `swc`, `tsup` | 빌드 및 번들링 설정 | +| **테스트** | `jest`, `vitest` | 단위/통합 테스트 프레임워크 | +| **프로세스 매니저** | `pm2` | Node.js 프로세스 관리 및 모니터링 | +| **CI/CD** | `github` | GitHub Actions 워크플로우 설정 | +| **Git 훅** | `husky` | 커밋/푸시 전 린트 및 테스트 실행 | +| **컨테이너화** | `docker` | Docker 및 docker-compose 배포 환경 설정 | + +> 이 표를 통해 각 도구의 용도와 역할을 한눈에 파악할 수 있습니다. + +## 🧩 템플릿 선택 + +CLI를 통해 원하는 스택을 선택하여 프로젝트를 생성할 수 있습니다. + +| 템플릿 | 스택 / 통합 기능 | +| ------------- | ---------------------------- | +| Default | Express + TypeScript | +| Sequelize | Sequelize ORM | +| Mongoose | MongoDB ODM (Mongoose) | +| TypeORM | TypeORM | +| Prisma | Prisma ORM | +| Knex | SQL Query Builder | +| GraphQL | GraphQL 지원 | +| Typegoose | TS 친화적인 Mongoose | +| Mikro ORM | 멀티 DB 지원 Data Mapper ORM | +| Node Postgres | PostgreSQL 드라이버 (pg) | +| Drizzle | Drizzle | + +## 🤔 포지셔닝: 각 프레임워크 사용에 적합한 상황 + +| 기준 | TypeScript Express Starter | NestJS | +| -------- | ----------------------------------- | ------------------------- | +| 학습 곡선 | ✅ 낮음 — Express에 익숙하다면 바로 사용 가능 | 높음 — OOP/DI/데코레이터 학습 필요 | +| 유연성 | ✅ 매우 높음 — 스택의 모든 부분을 자유롭게 커스터마이징 가능 | 컨벤션 기반, 구조가 정해져 있음 | +| 모듈성 | 미들웨어 & 모듈 패턴 | 🌟 강력한 내장 모듈 시스템 | +| 타입 안정성 | 완전한 TypeScript 지원 | 완전한 TypeScript 지원 | +| 테스트 | ✅ Jest & Vitest 지원 — 원하는 방식 선택 가능 | Jest E2E 테스트 환경 내장 | +| 확장성 | ✅ 빠른 프로토타이핑 → 중규모 애플리케이션에 적합 | 🌟 대규모 엔터프라이즈 애플리케이션에 최적화 | +| DI 프레임워크 | 경량 tsyringe — 최소한의 오버헤드 | 🌟 기능이 풍부한 내장 DI 컨테이너 | +| 최적 사용 사례 | ✅ 마이크로서비스, MVP, 빠른 개발 속도 | 🌟 복잡하고 대규모의 엔터프라이즈 환경 | + +## 📑 권장 커밋 메시지 + +| 상황 | 커밋 메시지 | +| --------- | ------------ | +| 기능 추가 | ✨ 기능 추가 | +| 버그 수정 | 🐞 버그 수정 | +| 코드 리팩토링 | 🛠 코드 리팩토링 | +| 패키지 설치 | 📦 패키지 설치 | +| 문서 수정 | 📚 문서 수정 | +| 버전 업데이트 | 🌼 버전 업데이트 | +| 신규 템플릿 추가 | 🎉 신규 템플릿 추가 | + +## 📄 라이선스 +MIT(LICENSE) © AGUMON (ljlm0402) + ## ⭐️ 응원해주신 분들 [![Stargazers repo roster for @ljlm0402/typescript-express-starter](https://reporoster.com/stars/ljlm0402/typescript-express-starter)](https://github.com/ljlm0402/typescript-express-starter/stargazers) @@ -329,29 +184,3 @@ VSCode Extension에서 [REST Client](https://marketplace.visualstudio.com/items? ## 🤝 도움주신 분들 [![Contributors repo roster for @ljlm0402/typescript-express-starter](https://contributors-img.web.app/image?repo=ljlm0402/typescript-express-starter)](https://github.com/ljlm0402/typescript-express-starter/graphs/contributors) - -## 💳 라이선스 - -[MIT](LICENSE) - -## 📑 커밋 메시지 정의 - -| 언제 | 메시지 | -| :----------------- | :-------------------- | -| 기능 추가 | ✨ 기능 추가 | -| 버그 수정 | 🐞 버그 수정 | -| 코드 개선 | 🛠 코드 개선 | -| 패키지 설치 | 📦 패키지 설치 | -| 문서 수정 | 📚 문서 수정 | -| 버전 업데이트 | 🌼 버전 업데이트 | -| 새로운 템플릿 추가 | 🎉 새로운 템플릿 추가 | - -## 📬 이슈를 남겨주세요 - -건의 사항이나 질문 등을 이슈로 남겨주세요. - -최선을 다해 답변하고 반영하겠습니다. - -관심을 가져주셔서 감사합니다. - -# ദ്ദി*ˊᗜˋ*) diff --git a/README.md b/README.md index 40cfd4b..779ba15 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@


- Project Logo + Project Logo

TypeScript Express Starter @@ -52,278 +52,148 @@
-## 😎 Introducing The Project +--- -Express consists of JavaScript, which makes it vulnerable to type definitions. +## 📝 Introduction -That's why we avoid supersets with starter packages that introduce TypeScript. +**TypeScript Express Starter** provides a robust starting point for building secure, scalable, and maintainable RESTful APIs. -The package is configured to use TypeScript instead of JavaScript. +It blends the flexibility and simplicity of Express with TypeScript’s type safety, supporting rapid development without compromising code quality or maintainability. -> The project referred to [express-generator-typescript](https://github.com/seanpmaxwell/express-generator-typescript) +- Clean architecture and modular structure -### 🤔 What is Express ? +- Built-in security, logging, validation, and developer tooling -Express is a fast, open and concise web framework and is a Node.js based project. +- Instantly ready for both prototyping and production -## 🚀 Quick Start +## 💎 Features -### Install with the npm Global Package +- ⚡ **TypeScript + Express** — Modern JS with full type safety -```bash -$ npm install -g typescript-express-starter -``` - -### Run npx to Install The Package - -npx is a tool in the JavaScript package management module, npm. - -This is a tool that allows you to run the npm package on a single run without installing the package. - -If you do not enter a project name, it defaults to _typescript-express-starter_. - -```bash -$ npx typescript-express-starter "project name" -``` - -### Select a Templates - -Example Cli - -Start your typescript-express-starter app in development mode at `http://localhost:3000/` - -#### Template Type - -| Name | Description | -| :---------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Default | Express Default | -| [Routing Controllers](https://github.com/typestack/routing-controllers) | Create structured, declarative and beautifully organized class-based controllers with heavy decorators usage | -| [Sequelize](https://github.com/sequelize/sequelize) | Easy to use multi SQL dialect ORM for Node.js | -| [Mongoose](https://github.com/Automattic/mongoose) | MongoDB Object Modeling(ODM) designed to work in an asynchronous environment | -| [TypeORM](https://github.com/typeorm/typeorm) | An ORM that can run in Node.js and Others | -| [Prisma](https://github.com/prisma/prisma) | Modern Database Access for TypeScript & Node.js | -| [Knex](https://github.com/knex/knex) | SQL query builder for Postgres, MySQL, MariaDB, SQLite3 and Oracle | -| [GraphQL](https://github.com/graphql/graphql-js) | query language for APIs and a runtime for fulfilling those queries with your existing data | -| [Typegoose](https://github.com/typegoose/typegoose) | Define Mongoose models using TypeScript classes | -| [Mikro ORM](https://github.com/mikro-orm/mikro-orm) | TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, MariaDB, PostgreSQL and SQLite databases | -| [Node Postgres](https://node-postgres.com/) | node-postgres is a collection of node.js modules for interfacing with your PostgreSQL database | - -#### Template to be developed - -| Name | Description | -| :------------------------------------------------------------------------------ | :------------------------------------------------------------------------- | -| [Sequelize Typescript](https://github.com/RobinBuschmann/sequelize-typescript) | Decorators and some other features for sequelize | -| [TS SQL](https://github.com/codemix/ts-sql) | A SQL database implemented purely in TypeScript type annotations | -| [inversify-express-utils](https://github.com/inversify/inversify-express-utils) | Some utilities for the development of Express application with InversifyJS | -| [postgress typescript]() | | -| [graphql prisma]() | | - -## 🛎 Available Commands for the Server - -- Run the Server in production mode : `npm run start` or `Start typescript-express-starter` in VS Code -- Run the Server in development mode : `npm run dev` or `Dev typescript-express-starter` in VS Code -- Run all unit-tests : `npm test` or `Test typescript-express-starter` in VS Code -- Check for linting errors : `npm run lint` or `Lint typescript-express-starter` in VS Code -- Fix for linting : `npm run lint:fix` or `Lint:Fix typescript-express-starter` in VS Code - -## 💎 The Package Features - -

-    -    -    -

-

-    - -    -    -    -    -    - - -

-

-    -    -    - -

- -### 🐳 Docker :: Container Platform - -[Docker](https://docs.docker.com/) is a platform for developers and sysadmins to build, run, and share applications with containers. - -[Docker](https://docs.docker.com/get-docker/) Install. - -- starts the containers in the background and leaves them running : `docker-compose up -d` -- Stops containers and removes containers, networks, volumes, and images : `docker-compose down` - -Modify `docker-compose.yml` and `Dockerfile` file to your source code. - -### ♻️ NGINX :: Web Server - -[NGINX](https://www.nginx.com/) is a web server that can also be used as a reverse proxy, load balancer, mail proxy and HTTP cache. - -Proxying is typically used to distribute the load among several servers, seamlessly show content from different websites, or pass requests for processing to application servers over protocols other than HTTP. - -When NGINX proxies a request, it sends the request to a specified proxied server, fetches the response, and sends it back to the client. +- 📜 **API Docs** — Swagger/OpenAPI ready out-of-the-box -Modify `nginx.conf` file to your source code. +- 🛡 **Security** — Helmet, CORS, HPP, rate limiting -### ✨ ESLint, Prettier :: Code Formatter +- 🧩 **Validation** — Zod schema-based runtime validation -[Prettier](https://prettier.io/) is an opinionated code formatter. +- 🔗 **Dependency Injection** — Lightweight DI with tsyringe -[ESLint](https://eslint.org/), Find and fix problems in your JavaScript code +- 🗄 **Database Integrations** — Sequelize, Prisma, Mongoose, TypeORM, Knex, Drizzle, etc. -It enforces a consistent style by parsing your code and re-printing it with its own rules that take the maximum line length into account, wrapping code when necessary. +- 🛠 **Developer Tools** — ESLint, Prettier, Jest, Docker, PM2, NGINX, Makefile -1. Install [VSCode](https://code.visualstudio.com/) Extension [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode), [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) +- 🧱 **Modular Architecture** — Easily extendable and maintainable -2. `CMD` + `Shift` + `P` (Mac Os) or `Ctrl` + `Shift` + `P` (Windows) +- 🚀 **Production Ready** — Docker, PM2, NGINX support -3. Format Selection With +## ⚡️ Quick Start -4. Configure Default Formatter... - -5. Prettier - Code formatter - -Formatter Setting - -> Palantir, the backers behind TSLint announced in 2019 that they would be deprecating TSLint in favor of supporting typescript-eslint in order to benefit the community. -> So, migration from TSLint to ESLint. - -### 📗 Swagger :: API Document - -[Swagger](https://swagger.io/) is Simplify API development for users, teams, and enterprises with the Swagger open source and professional toolset. - -Easily used by Swagger to design and document APIs at scale. - -Start your app in development mode at `http://localhost:3000/api-docs` - -Modify `swagger.yaml` file to your source code. - -### 🌐 REST Client :: HTTP Client Tools - -REST Client allows you to send HTTP request and view the response in Visual Studio Code directly. - -VSCode Extension [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) Install. - -Modify `*.http` file in src/http folder to your source code. - -### 🔮 PM2 :: Advanced, Production process manager for Node.js - -[PM2](https://pm2.keymetrics.io/) is a daemon process manager that will help you manage and keep your application online 24/7. - -- production mode :: `npm run deploy:prod` or `pm2 start ecosystem.config.js --only prod` -- development mode :: `npm run deploy:dev` or `pm2 start ecosystem.config.js --only dev` - -Modify `ecosystem.config.js` file to your source code. +```bash +# Install globally +npm install -g typescript-express-starter -### 🏎 SWC :: a super-fast JavaScript / TypeScript compiler +# Scaffold a new project +typescript-express-starter +cd my-app -[SWC](https://swc.rs/) is an extensible Rust-based platform for the next generation of fast developer tools. +# Run in development mode +npm run dev +``` +- Access the app: http://localhost:3000/ -`SWC is 20x faster than Babel on a single thread and 70x faster on four cores.` +- Auto-generated API docs: http://localhost:3000/api-docs -- tsc build :: `npm run build` -- swc build :: `npm run build:swc` +### Example -Modify `.swcrc` file to your source code. +## 📂 Project Structure -### 💄 Makefile :: This is a setting file of the make program used to make the compilation that occurs repeatedly on Linux +```bash +src/ + ├── config/ # Configuration files, environment settings + ├── controllers/ # Request handling & response logic + ├── dtos/ # Data Transfer Objects for request/response + ├── exceptions/ # Custom exception classes + ├── interfaces/ # TypeScript interfaces and type definitions + ├── middlewares/ # Middlewares (logging, auth, error handling, etc.) + ├── repositories/ # Database access logic + ├── routes/ # API route definitions + ├── services/ # Business logic + ├── utils/ # Utility/helper functions + ├── app.ts # Express app initialization + └── server.ts # Server entry point + +.env # Default environment variables +.env.development.local # Development-specific variables +.env.production.local # Production-specific variables +.env.test.local # Test-specific variables +nodemon.json # Nodemon variables +swagger.yaml # Swagger API documentation +tsconfig.jsnon # TypeScript variables +``` -[Makefile](https://makefiletutorial.com/)s are used to help decide which parts of a large program need to be recompiled. +## 🛠 Devtools Types + +| Category | Tools / Configs | Description | +| --------------------------- | --------------------------- | -------------------------------------------- | +| **Code Formatter / Linter** | `biome`, `prettier`, `eslint` | Code formatting & linting rules | +| **Build / Bundler** | `swc`, `tsup` | Build & bundling configuration | +| **Testing** | `jest`, `vitest` | Unit & integration testing frameworks | +| **Process Manager** | `pm2` | Manage and monitor Node.js processes | +| **CI/CD** | `github` | GitHub Actions workflow settings | +| **Git Hooks** | `husky` | Pre-commit / pre-push hooks for lint/test | +| **Containerization** | `docker` | Docker & docker-compose setup for deployment | + +> This categorization helps developers quickly understand what each tool is used for without checking every folder. + +## 🧩 Template Choices + +Choose your preferred stack during setup! +Support for major databases and patterns via CLI: + +| Template | Stack / Integration | +| ------------- | ------------------------------ | +| Default | Express + TypeScript | +| Sequelize | Sequelize ORM | +| Mongoose | MongoDB ODM (Mongoose) | +| TypeORM | TypeORM | +| Prisma | Prisma ORM | +| Knex | SQL Query Builder | +| GraphQL | GraphQL support | +| Typegoose | TS-friendly Mongoose | +| Mikro ORM | Data Mapper ORM (multi-DB) | +| Node Postgres | PostgreSQL driver (pg) | +| Drizzle | Drizzle | + +> More templates are regularly added and updated. + +## 🤔 Positioning: When to Use Each + +| Criteria | TypeScript Express Starter | NestJS | +| ---------------- | --------------------------------------------------- | ------------------------------------------ | +| Learning Curve | ✅ Low — easy for anyone familiar with Express | Higher — requires OOP/DI/Decorators | +| Flexibility | ✅ Maximum — customize any part of the stack | Convention-based, opinionated structure | +| Modularity | Middleware & modular pattern | 🌟 Strong built-in module system | +| Type Safety | Full TypeScript support | Full TypeScript support | +| Testing | ✅ Supports Jest & Vitest — flexible choice | Built-in Jest E2E setup | +| Scale | ✅ Fast prototyping → mid-size apps | 🌟 Large-scale enterprise apps | +| DI Framework | Lightweight tsyringe — minimal overhead | 🌟 Full-featured DI container | +| Best Fit | ✅ Microservices, quick MVPs, developer agility | 🌟 Complex, enterprise-grade applications | -- help :: `make help` -Modify `Makefile` file to your source code. +## 📑 Recommended Commit Message -## 🗂 Code Structure (default) +| When | Commit Message | +| --------------- | ------------------ | +| Add Feature | ✨ Add Feature | +| Fix Bug | 🐞 Fix Bug | +| Refactor Code | 🛠 Refactor Code | +| Install Package | 📦 Install Package | +| Fix Readme | 📚 Fix Readme | +| Update Version | 🌼 Update Version | +| New Template | 🎉 New Template | -```sh -│ -├──📂 .vscode -│ ├── launch.json -│ └── settings.json -│ -├──📂 src -│ ├──📂 config -│ │ └── index.ts -│ │ -│ ├──📂 controllers -│ │ ├── auth.controller.ts -│ │ └── users.controller.ts -│ │ -│ ├──📂 dtos -│ │ └── users.dto.ts -│ │ -│ ├──📂 exceptions -│ │ └── httpException.ts -│ │ -│ ├──📂 http -│ │ ├── auth.http -│ │ └── users.http -│ │ -│ ├──📂 interfaces -│ │ ├── auth.interface.ts -│ │ ├── routes.interface.ts -│ │ └── users.interface.ts -│ │ -│ ├──📂 middlewares -│ │ ├── auth.middleware.ts -│ │ ├── error.middleware.ts -│ │ └── validation.middleware.ts -│ │ -│ ├──📂 models -│ │ └── users.model.ts -│ │ -│ ├──📂 routes -│ │ ├── auth.route.ts -│ │ └── users.route.ts -│ │ -│ ├──📂 services -│ │ ├── auth.service.ts -│ │ └── users.service.ts -│ │ -│ ├──📂 test -│ │ ├── auth.test.ts -│ │ └── users.test.ts -│ │ -│ ├──📂 utils -│ │ ├── logger.ts -│ │ └── vaildateEnv.ts -│ │ -│ ├── app.ts -│ └── server.ts -│ -├── .dockerignore -├── .editorconfig -├── .env.development.local -├── .env.production.local -├── .env.test.local -├── .eslintignore -├── .eslintrc -├── .gitignore -├── .huskyrc -├── .lintstagedrc.json -├── .prettierrc -├── .swcrc -├── docker-compose.yml -├── Dockerfile.dev -├── Dockerfile.prod -├── ecosystem.config.js -├── jest.config.js -├── Makefile -├── nginx.conf -├── nodemon.json -├── package-lock.json -├── package.json -├── swagger.yaml -└── tsconfig.json -``` +## 📄 License +MIT(LICENSE) © AGUMON (ljlm0402) ## ⭐️ Stargazers @@ -336,29 +206,3 @@ Modify `Makefile` file to your source code. ## 🤝 Contributors [![Contributors repo roster for @ljlm0402/typescript-express-starter](https://contributors-img.web.app/image?repo=ljlm0402/typescript-express-starter)](https://github.com/ljlm0402/typescript-express-starter/graphs/contributors) - -## 💳 License - -[MIT](LICENSE) - -## 📑 Recommended Commit Message - -| When | Commit Message | -| :--------------- | :----------------- | -| Add Feature | ✨ Add Feature | -| Fix Bug | 🐞 Fix Bug | -| Refactoring Code | 🛠 Refactoring Code | -| Install Package | 📦 Install Package | -| Fix Readme | 📚 Fix Readme | -| Update Version | 🌼 Update Version | -| New Template | 🎉 New Template | - -## 📬 Please request an issue - -Please leave a question or question as an issue. - -I will do my best to answer and reflect. - -Thank you for your interest. - -# ദ്ദി*ˊᗜˋ*) diff --git a/bin/cli.js b/bin/cli.js deleted file mode 100644 index 453ea9e..0000000 --- a/bin/cli.js +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env node - -/***************************************************************** - * TypeScript Express Starter - * 2019.12.18 ~ 🎮 - * Made By AGUMON 🦖 - * https://github.com/ljlm0402/typescript-express-starter - *****************************************************************/ - -const path = require("path"); -const starter = require("../lib/starter"); -const destination = getDest(process.argv[2]); - -function getDest(destFolder = "typescript-express-starter") { - return path.join(process.cwd(), destFolder); -} - -starter(destination); diff --git a/bin/common.js b/bin/common.js new file mode 100644 index 0000000..70c6a65 --- /dev/null +++ b/bin/common.js @@ -0,0 +1,273 @@ +import { fileURLToPath } from 'url'; +import path from 'path'; + +export const PACKAGE_MANAGER = [ + { label: 'npm', value: 'npm' }, + { label: 'yarn', value: 'yarn' }, + { label: 'pnpm', value: 'pnpm' }, +]; + +export const TEMPLATES_VALUES = [ + { + name: 'Default', + value: 'default', + desc: 'Basic Express + TypeScript project', + active: true, + tags: ['express', 'typescript', 'starter'], + version: 'v1.0.0', + maintainer: 'core', + lastUpdated: '2025-08-27', + }, + { + name: 'GraphQL', + value: 'graphql', + desc: 'GraphQL API template based on Apollo Server', + active: false, + tags: ['graphql', 'apollo', 'api'], + version: 'v1.2.0', + maintainer: 'core', + lastUpdated: '2025-08-10', + }, + { + name: 'Knex', + value: 'knex', + desc: 'SQL query builder template using Knex.js', + active: false, + tags: ['sql', 'knex', 'database'], + version: 'v0.95.x', + maintainer: 'db-team', + lastUpdated: '2025-07-30', + }, + { + name: 'Mikro-ORM', + value: 'mikro-orm', + desc: 'TypeScript ORM template based on Mikro-ORM', + active: false, + tags: ['orm', 'typescript', 'database'], + version: 'v6.x', + maintainer: 'orm-team', + lastUpdated: '2025-07-25', + }, + { + name: 'Mongoose', + value: 'mongoose', + desc: 'MongoDB ODM template using Mongoose', + active: false, + tags: ['mongodb', 'odm', 'database'], + version: 'v7.x', + maintainer: 'nosql-team', + lastUpdated: '2025-08-01', + }, + { + name: 'Node-Postgres', + value: 'node-postgres', + desc: 'PostgreSQL template using pg library', + active: false, + tags: ['postgres', 'sql', 'database'], + version: 'v8.x', + maintainer: 'db-team', + lastUpdated: '2025-07-20', + }, + { + name: 'Prisma', + value: 'prisma', + desc: 'SQL/NoSQL ORM template based on Prisma', + active: false, + tags: ['orm', 'prisma', 'database'], + version: 'v5.x', + maintainer: 'orm-team', + lastUpdated: '2025-08-05', + }, + { + name: 'Routing-Controllers', + value: 'routing-controllers', + desc: 'Express controller structure with TypeScript decorators', + active: false, + tags: ['decorator', 'controller', 'express'], + version: 'v0.10.x', + maintainer: 'core', + lastUpdated: '2025-07-15', + }, + { + name: 'Sequelize', + value: 'sequelize', + desc: 'SQL ORM template based on Sequelize', + active: false, + tags: ['orm', 'sequelize', 'database'], + version: 'v6.x', + maintainer: 'orm-team', + lastUpdated: '2025-07-28', + }, + { + name: 'Typegoose', + value: 'typegoose', + desc: 'TypeScript-friendly MongoDB ODM using Typegoose', + active: false, + tags: ['mongodb', 'typegoose', 'odm'], + version: 'v11.x', + maintainer: 'nosql-team', + lastUpdated: '2025-08-08', + }, + { + name: 'TypeORM', + value: 'typeorm', + desc: 'SQL/NoSQL ORM template based on TypeORM', + active: false, + tags: ['orm', 'typeorm', 'database'], + version: 'v0.3.x', + maintainer: 'orm-team', + lastUpdated: '2025-07-18', + }, +]; + +export const DEVTOOLS_VALUES = [ + // == [Formatter] == // + { + name: 'Biome', + value: 'biome', + category: 'Formatter', + files: ['.biome.json', '.biomeignore'], + pkgs: [], + devPkgs: ['@biomejs/biome@2.1.4'], + scripts: { + lint: 'biome lint .', + check: 'biome check .', + format: 'biome format . --write', + }, + desc: 'All-in-one formatter and linter', + }, + { + name: 'Prettier & ESLint', + value: 'prettier', + category: 'Formatter', + files: ['.prettierrc', 'eslint.config.js'], + pkgs: [], + devPkgs: ['eslint@^9.33.0', 'eslint-config-prettier@^10.1.1', 'globals@^15.10.0', 'prettier@3.6.2', 'typescript-eslint@^8.39.0'], + scripts: { + lint: 'eslint --ext .ts src/', + 'lint:fix': 'npm run lint -- --fix', + format: 'prettier --check .', + 'format:fix': 'prettier --write .', + }, + desc: 'Separate formatter and linter setup', + }, + + // == [Compiler] == // + { + name: 'tsup', + value: 'tsup', + category: 'Compiler', + files: ['tsup.config.ts'], + pkgs: [], + devPkgs: ['tsup@8.5.0'], + scripts: { + 'start:tsup': 'node -r tsconfig-paths/register dist/server.js', + 'build:tsup': 'tsup --config tsup.config.ts', + }, + desc: 'Fast bundler for TypeScript', + }, + { + name: 'SWC', + value: 'swc', + category: 'Compiler', + files: ['.swcrc'], + pkgs: [], + devPkgs: ['@swc/cli@0.7.8', '@swc/core@1.13.3'], + scripts: { + 'start:swc': 'node dist/server.js', + 'build:swc': 'swc src -d dist --strip-leading-paths --copy-files --delete-dir-on-start', + }, + desc: 'Rust-based TypeScript compiler', + }, + + // == [Testing] == // + { + name: 'Jest', + value: 'jest', + category: 'Testing', + files: ['jest.config.cjs', 'jest.config.ts'], + pkgs: [], + devPkgs: ['@types/supertest@6.0.3', 'supertest@7.1.4', '@types/jest@30.0.0', 'jest@30.0.5', 'ts-jest@29.4.1'], + scripts: { + 'test': 'jest --config jest.config.cjs --watch', + 'test:e2e': 'jest --config jest.config.cjs --testPathPatterns=e2e --watch', + 'test:unit': 'jest --config jest.config.cjs --testPathPatterns=unit --watch' + }, + desc: 'Industry-standard test runner for Node.js', + }, + { + name: 'Vitest', + value: 'vitest', + category: 'Testing', + files: ['vitest.config.ts'], + pkgs: [], + devPkgs: ['@types/supertest@6.0.3', 'supertest@7.1.4', 'vite-tsconfig-paths@5.1.4', 'vitest@3.2.4'], + scripts: { + "test": "vitest", + "test:unit": "vitest src/test/unit", + "test:e2e": "vitest src/test/e2e", + "test:ci": "vitest run --coverage", + "test:ci:unit": "vitest run src/test/unit --coverage", + "test:ci:e2e": "vitest run src/test/e2e --coverage" + }, + desc: 'Fast Vite-powered unit/e2e test framework', + }, + + // == [Infrastructure] == // + { + name: 'Docker', + value: 'docker', + category: 'Infrastructure', + files: ['.dockerignore', 'Dockerfile.dev', 'Dockerfile.prod', 'nginx.conf', 'Makefile'], + pkgs: [], + devPkgs: [], + scripts: {}, + desc: 'Containerized dev & prod environment', + }, + + // == [Git Tools] == // + { + name: 'Husky', + value: 'husky', + category: 'Git Tools', + files: ['.husky'], + pkgs: [], + devPkgs: ['husky'], + scripts: { prepare: 'husky install' }, + requires: [], + desc: 'Git hooks for automation', + }, + + // == [Deployment] == // + { + name: 'PM2', + value: 'pm2', + category: 'Deployment', + files: ['ecosystem.config.js'], + pkgs: ['pm2'], + devPkgs: [], + scripts: { + 'deploy:prod': 'pm2 start ecosystem.config.js --only prod', + 'deploy:dev': 'pm2 start ecosystem.config.js --only dev', + }, + desc: 'Process manager for Node.js', + }, + + // == [CI/CD] == // + { + name: 'GitHub Actions', + value: 'github', + category: 'CI/CD', + files: ['.github/workflows/ci.yml'], + pkgs: [], + devPkgs: [], + scripts: {}, + desc: 'CI/CD workflow automation', + }, +]; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const TEMPLATES = path.join(__dirname, '../templates'); + +export const DEVTOOLS = path.join(__dirname, '../devtools'); diff --git a/bin/db-map.js b/bin/db-map.js new file mode 100644 index 0000000..d5bb051 --- /dev/null +++ b/bin/db-map.js @@ -0,0 +1,95 @@ +export const TEMPLATE_DB = { + default: null, + graphql: 'postgres', + knex: 'mysql', + mikroorm: 'mongo', + mongoose: 'mongo', + 'node-postgres': 'postgres', + prisma: 'mysql', + sequelize: 'mysql', + typegoose: 'mongo', + typeorm: 'postgres', +}; + +export const DB_SERVICES = { + postgres: ` + pg: + container_name: pg + image: postgres:14.5-alpine + ports: + - "5432:5432" + env_file: + - .env.development.local + restart: always + networks: + - backend + `, + mysql: ` + mysql: + container_name: mysql + image: mysql:5.7 + ports: + - "3306:3306" + env_file: + - .env.development.local + networks: + - backend + `, + mongo: ` + mongo: + container_name: mongo + image: mongo + ports: + - "27017:27017" + env_file: + - .env.development.local + networks: + - backend + `, +}; + +export const BASE_COMPOSE = (dbSnippet = '') => ` +version: '3.9' + +services: + proxy: + container_name: proxy + image: nginx:alpine + ports: + - '80:80' + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - server + restart: unless-stopped + networks: + - backend + + server: + container_name: server + build: + context: ./ + dockerfile: Dockerfile.dev + ports: + - '3000:3000' + volumes: + - ./:/app:cached + - /app/node_modules + env_file: + - .env.development.local + depends_on: + - ${dbSnippet ? dbSnippet.match(/^\s*(\w+):/)?.[1] : ''} + restart: unless-stopped + networks: + - backend + +${dbSnippet.trim() ? dbSnippet : ''} + +networks: + backend: + driver: bridge + +volumes: + pgdata: + driver: local +`; diff --git a/bin/starter.js b/bin/starter.js new file mode 100644 index 0000000..d7d2ba4 --- /dev/null +++ b/bin/starter.js @@ -0,0 +1,365 @@ +#!/usr/bin/env node + +/***************************************************************** + * TYPESCRIPT-EXPRESS-STARTER - Quick and Easy TypeScript Express Starter + * (c) 2020-present AGUMON (https://github.com/ljlm0402/typescript-express-starter) + * + * MIT License + * + * Made with ❤️ by AGUMON 🦖 + *****************************************************************/ + +import { select, text, isCancel, intro, outro, cancel, note, confirm } from '@clack/prompts'; +import chalk from 'chalk'; +import editJsonFile from 'edit-json-file'; +import { execa } from 'execa'; +import fs from 'fs-extra'; +import ora from 'ora'; +import path from 'path'; +import { PACKAGE_MANAGER, TEMPLATES_VALUES, DEVTOOLS_VALUES, TEMPLATES, DEVTOOLS } from './common.js'; +import { TEMPLATE_DB, DB_SERVICES, BASE_COMPOSE } from './db-map.js'; + +// ========== [공통 함수들] ========== + +// Node 버전 체크 (16+) +function checkNodeVersion(min = 16) { + const major = parseInt(process.versions.node.split('.')[0], 10); + if (major < min) { + console.error(chalk.red(`Node.js ${min}+ required. You have ${process.versions.node}.`)); + process.exit(1); + } +} + +// 최신 CLI 버전 체크 & 선택적 설치 +async function checkForUpdate(pkgName, localVersion) { + try { + const { stdout } = await execa('npm', ['view', pkgName, 'version']); + const latest = stdout.trim(); + if (latest !== localVersion) { + console.log(chalk.yellow(`🔔 New version available: ${latest} (You are on ${localVersion})`)); + const shouldUpdate = await confirm({ + message: `Do you want to update ${pkgName} to version ${latest}?`, + initial: true, + }); + if (shouldUpdate) { + console.log(chalk.gray(` Updating to latest version...`)); + try { + await execa('npm', ['install', '-g', `${pkgName}@${latest}`], { stdio: 'inherit' }); + console.log(chalk.green(` ✓ Updated ${pkgName} to ${latest}`)); + } catch (err) { + printError(`Failed to update ${pkgName}`, err.message); + } + } else { + console.log(chalk.gray('Skipped updating.')); + } + } + } catch (err) { + printError('Failed to check latest version', err.message); + } +} + +// 패키지매니저 글로벌 설치여부 +async function checkPkgManagerInstalled(pm) { + try { + await execa(pm, ['--version']); + return true; + } catch { + return false; + } +} + +// 최신 버전 조회 +async function getLatestVersion(pkg) { + try { + const { stdout } = await execa('npm', ['view', pkg, 'version']); + return stdout.trim(); + } catch { + return null; + } +} + +// 도구 간 의존성 자동 해결 +function resolveDependencies(selected) { + const all = new Set(selected); + let changed = true; + while (changed) { + changed = false; + for (const tool of DEVTOOLS_VALUES) { + if (all.has(tool.value) && tool.requires) { + for (const req of tool.requires) { + if (!all.has(req)) { + all.add(req); + changed = true; + } + } + } + } + } + return Array.from(all); +} + +// 파일 복사 +async function copyDevtoolFiles(devtool, destDir) { + for (const file of devtool.files) { + const src = path.join(DEVTOOLS, devtool.value, file); + const dst = path.join(destDir, file); + if (await fs.pathExists(src)) { + await fs.copy(src, dst, { overwrite: true }); + console.log(chalk.gray(` ⎯ ${file} copied.`)); + } + } +} + +function isExplicitSpecifier(spec) { + return ( + spec.startsWith('http://') || + spec.startsWith('https://') || + spec.startsWith('git+') || + spec.startsWith('file:') || + spec.startsWith('link:') || + spec.startsWith('workspace:') || + spec.startsWith('npm:') + ); +} + +// 'pkg' / '@scope/pkg' vs 'pkg@^1.2.3' / '@scope/pkg@1.2.3' 구분 +function splitNameAndVersion(spec) { + if (spec.startsWith('@')) { + const idx = spec.indexOf('@', 1); // 스코프 다음 '@'가 버전 구분자 + if (idx === -1) return { name: spec, version: null }; + return { name: spec.slice(0, idx), version: spec.slice(idx + 1) }; + } else { + const idx = spec.indexOf('@'); + if (idx === -1) return { name: spec, version: null }; + return { name: spec.slice(0, idx), version: spec.slice(idx + 1) }; + } +} + +// 패키지 설치 (버전/범위 지정 시 그대로, 없으면 latest 조회해 고정) +async function installPackages(pkgs, pkgManager, dev = true, destDir = process.cwd()) { + if (!pkgs || pkgs.length === 0) return; + + const resolved = []; + for (const spec of pkgs) { + // URL/파일/워크스페이스/별칭은 그대로 통과 + if (isExplicitSpecifier(spec)) { + resolved.push(spec); + continue; + } + + const { name, version } = splitNameAndVersion(spec); + // 이미 버전/범위가 명시된 경우 그대로 사용 (예: ^9.33.0, ~10.1.8, 9.33.0) + if (version && version.length > 0) { + resolved.push(`${name}@${version}`); + continue; + } + + // 버전 미지정 → npm view로 latest 조회 후 고정 + const latest = await getLatestVersion(name); + resolved.push(latest ? `${name}@${latest}` : name); + } + + const installCmd = + pkgManager === 'npm' + ? ['install', dev ? '--save-dev' : '', ...resolved].filter(Boolean) + : pkgManager === 'yarn' + ? ['add', dev ? '--dev' : '', ...resolved].filter(Boolean) + : ['add', dev ? '-D' : '', ...resolved].filter(Boolean); + + await execa(pkgManager, installCmd, { cwd: destDir, stdio: 'inherit' }); +} + +// package.json 수정 (스크립트 추가 등) +async function updatePackageJson(scripts, destDir) { + const pkgPath = path.join(destDir, 'package.json'); + const file = editJsonFile(pkgPath, { autosave: true }); + Object.entries(scripts).forEach(([k, v]) => file.set(`scripts.${k}`, v)); + if (!file.get('scripts.prepare') && fs.existsSync(path.join(destDir, '.huskyrc'))) { + file.set('scripts.prepare', 'husky install'); + } + file.save(); +} + +function printError(message, suggestion = null) { + console.log(chalk.bgRed.white(' ERROR '), chalk.red(message)); + if (suggestion) { + console.log(chalk.gray('Hint:'), chalk.cyan(suggestion)); + } +} + +// docker-compose 생성 +async function generateCompose(template, destDir) { + const dbType = TEMPLATE_DB[template]; + const dbSnippet = dbType ? DB_SERVICES[dbType] : ''; + const composeYml = BASE_COMPOSE(dbSnippet); + const filePath = path.join(destDir, 'docker-compose.yml'); + await fs.writeFile(filePath, composeYml, 'utf8'); + return dbType; +} + +// Git init & 첫 커밋 +async function gitInitAndFirstCommit(destDir) { + const doGit = await confirm({ message: 'Initialize git and make first commit?', initial: true }); + if (!doGit) return; + try { + await execa('git', ['init'], { cwd: destDir }); + await execa('git', ['add', '.'], { cwd: destDir }); + await execa('git', ['commit', '-m', 'init'], { cwd: destDir }); + console.log(chalk.green(' ✓ git initialized and first commit made!')); + } catch (e) { + printError('git init/commit failed', 'Check git is installed and accessible.'); + } +} + +// ========== [메인 CLI 실행 흐름] ========== +async function main() { + // 1. Node 버전 체크 + checkNodeVersion(16); + + // 2. CLI 최신버전 안내 + await checkForUpdate('typescript-express-starter', '10.2.2'); + + const gradientBanner = + '\x1B[38;2;91;192;222m📘\x1B[39m\x1B[38;2;91;192;222m \x1B[39m\x1B[38;2;91;192;222mT\x1B[39m\x1B[38;2;82;175;222my\x1B[39m\x1B[38;2;74;159;222mp\x1B[39m\x1B[38;2;66;143;210me\x1B[39m\x1B[38;2;58;128;198mS\x1B[39m\x1B[38;2;54;124;190mc\x1B[39m\x1B[38;2;52;118;180mr\x1B[39m\x1B[38;2;50;115;172mi\x1B[39m\x1B[38;2;49;120;198mp\x1B[39m\x1B[38;2;47;110;168mt\x1B[39m\x1B[38;2;45;105;160m \x1B[39m\x1B[38;2;43;100;152mE\x1B[39m\x1B[38;2;41;95;144mx\x1B[39m\x1B[38;2;39;90;136mp\x1B[39m\x1B[38;2;37;85;128mr\x1B[39m\x1B[38;2;35;80;120me\x1B[39m\x1B[38;2;33;75;112ms\x1B[39m\x1B[38;2;30;72;106ms\x1B[39m\x1B[38;2;28;70;100m \x1B[39m\x1B[38;2;26;68;96mS\x1B[39m\x1B[38;2;25;68;94mt\x1B[39m\x1B[38;2;25;69;92ma\x1B[39m\x1B[38;2;25;70;91mr\x1B[39m\x1B[38;2;25;70;150mt\x1B[39m\x1B[38;2;25;70;150me\x1B[39m\x1B[38;2;25;70;150mr\x1B[39m'; + intro(gradientBanner); + + // 3. 패키지 매니저 선택 + 글로벌 설치 확인 + let pkgManager; + while (true) { + pkgManager = await select({ + message: 'Which package manager do you want to use?', + options: PACKAGE_MANAGER, + initialValue: 'npm', + }); + if (isCancel(pkgManager)) return cancel('❌ Aborted.'); + if (await checkPkgManagerInstalled(pkgManager)) break; + printError(`${pkgManager} is not installed globally! Please install it first.`); + } + note(`Using: ${pkgManager}`); + + // 4. 템플릿 선택 + const templateDirs = (await fs.readdir(TEMPLATES)).filter(f => fs.statSync(path.join(TEMPLATES, f)).isDirectory()); + if (templateDirs.length === 0) return printError('No templates found!'); + + const options = TEMPLATES_VALUES + .filter(t => t.active && templateDirs.includes(t.value)) + .map(t => ({ + label: t.name, // UI에 표시될 이름 + value: t.value, // 선택 값 + hint: t.desc, // 오른쪽에 표시될 설명 + })); + + const template = await select({ + message: 'Choose a template:', + options: options, + initialValue: 'default', + }); + if (isCancel(template)) return cancel('❌ Aborted.'); + + // 5. 프로젝트명 입력 (중복체크/덮어쓰기) + let projectName, destDir; + while (true) { + projectName = await text({ + message: 'Enter your project name:', + initial: 'my-app', + validate: val => (!val ? 'Project name is required' : undefined), + }); + if (isCancel(projectName)) return cancel('❌ Aborted.'); + destDir = path.resolve(process.cwd(), projectName); + if (await fs.pathExists(destDir)) { + const overwrite = await confirm({ message: `Directory "${projectName}" already exists. Overwrite?` }); + if (overwrite) break; + } else break; + } + + // 6. 개발 도구 옵션 선택 (category 기준으로 그룹화) + const groupedDevtools = DEVTOOLS_VALUES.reduce((acc, tool) => { + const cat = tool.category || 'Others'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(tool); + return acc; + }, {}); + + // 6-1. 개발 도구 옵션 선택 (category별 하나씩만 선택하는 방식) + let devtoolValues = []; + for (const [category, tools] of Object.entries(groupedDevtools)) { + const picked = await select({ + message: `Select a tool for "${category}":`, + options: [ + { label: 'None', value: null }, + ...tools.map(({ name, value, desc }) => ({ + label: `${name} (${desc})`, + value, + })), + ], + initialValue: null, + }); + if (isCancel(picked)) return cancel('❌ Aborted.'); + if (picked) devtoolValues.push(picked); + } + devtoolValues = resolveDependencies(devtoolValues); + + // === [진행] === + + // [1] 템플릿 복사 + const spinner = ora('Copying template...').start(); + try { + await fs.copy(path.join(TEMPLATES, template), destDir, { overwrite: true }); + spinner.succeed('Template copied!'); + } catch (e) { + spinner.fail('Template copy failed!'); + printError(e.message, 'Check templates folder and permissions.'); + return process.exit(1); + } + + // [1-1] Testing 도구를 선택한 경우에만 /src/test 예제 복사 + const testDevtool = devtoolValues.map(val => DEVTOOLS_VALUES.find(d => d.value === val)).find(tool => tool && tool.category === 'Testing'); + + if (testDevtool) { + const devtoolTestDir = path.join(DEVTOOLS, testDevtool.value, 'src', 'test'); + const projectTestDir = path.join(destDir, 'src', 'test'); + if (await fs.pathExists(devtoolTestDir)) { + await fs.copy(devtoolTestDir, projectTestDir, { overwrite: true }); + console.log(chalk.gray(` ⎯ test files for ${testDevtool.name} copied.`)); + } + } + + // [2] 개발 도구 파일/패키지/스크립트/코드패치 + for (const val of devtoolValues) { + const tool = DEVTOOLS_VALUES.find(d => d.value === val); + if (!tool) continue; + + spinner.start(`Setting up ${tool.name}...`); + await copyDevtoolFiles(tool, destDir); + + // [2-1] 개발 도구 - 패키지 설치 + if (tool.pkgs?.length > 0) await installPackages(tool.pkgs, pkgManager, false, destDir); + if (tool.devPkgs?.length > 0) await installPackages(tool.devPkgs, pkgManager, true, destDir); + + // [2-2] 개발 도구 - 스크립트 추가 등 + if (Object.keys(tool.scripts).length) await updatePackageJson(tool.scripts, destDir); + + // [2-3] 개발 도구 - Docker 선택 한 경우, docker-compose.yml 생성 + if (tool.value === 'docker') await generateCompose(template, destDir); + + spinner.succeed(`${tool.name} setup done.`); + } + + // [3] 템플릿 기본 패키지 설치 + spinner.start(`Installing base dependencies with ${pkgManager}...`); + await execa(pkgManager, ['install'], { cwd: destDir, stdio: 'inherit' }); + spinner.succeed('📦 Base dependencies installed!'); + + // [4] git 첫 커밋 옵션 + await gitInitAndFirstCommit(destDir); + + outro(chalk.greenBright('\n🎉 Project setup complete!\n')); + console.log(chalk.cyan(` $ cd ${projectName}`)); + console.log(chalk.cyan(` $ ${pkgManager} run dev\n`)); + console.log(chalk.gray('✨ Happy hacking!\n')); +} + +main().catch(err => { + printError('Unexpected error', err.message); + process.exit(1); +}); diff --git a/devtools/biome/.biome.json b/devtools/biome/.biome.json new file mode 100644 index 0000000..7bd5cba --- /dev/null +++ b/devtools/biome/.biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.0.0/schema.json", + "files": { + "ignore": [ + "node_modules/**", + "dist/**", + "build/**" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 150, + "quoteStyle": "single", + "trailingComma": "all", + "semiColon": true, + "arrowParentheses": "avoid" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style/noExplicitAny": "off", + "style/noNonNullAssertion": "off", + "suspicious/noExplicitAny": "off", + "style/noVar": "off", + "complexity/useArrowFunctions": "off" + } + }, + "organizeImports": { + "enabled": true + } +} diff --git a/devtools/biome/.biomeignore b/devtools/biome/.biomeignore new file mode 100644 index 0000000..b010109 --- /dev/null +++ b/devtools/biome/.biomeignore @@ -0,0 +1,29 @@ +# node_modules and build outputs +node_modules/ +dist/ +build/ +coverage/ +.tmp/ +.temp/ + +# 환경설정 및 로그 +.env +.env.* +*.log + +# TypeScript compiled output +*.js +*.d.ts +*.map + +# OS, IDE/Editor 관련 +.DS_Store +.vscode/ +.idea/ +*.swp + +# 기타 무시할 파일/폴더 +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* diff --git a/devtools/docker/.dockerignore b/devtools/docker/.dockerignore new file mode 100644 index 0000000..a83c01f --- /dev/null +++ b/devtools/docker/.dockerignore @@ -0,0 +1,27 @@ +# 빌드 불필요/민감 파일 차단 +node_modules/ +dist/ +.vscode/ +logs/ +coverage/ + +# 환경설정/시크릿/포매터 +.env +.env.* +.eslintrc* +.eslintignore +.editorconfig +.prettierrc* +.huskyrc +.lintstagedrc* +jest.config.js + +# 도커 파일 자체 +Dockerfile +docker-compose.yml + +# 시스템 +.DS_Store +Thumbs.db +npm-debug.log* +yarn-error.log* diff --git a/devtools/docker/Dockerfile.dev b/devtools/docker/Dockerfile.dev new file mode 100644 index 0000000..00820f3 --- /dev/null +++ b/devtools/docker/Dockerfile.dev @@ -0,0 +1,20 @@ +# 베이스 이미지 +FROM node:20-alpine + +# 작업 디렉토리 생성 +WORKDIR /app + +# 패키지 설치(캐시 활용) +COPY package.json pnpm-lock.yaml* yarn.lock* package-lock.json* ./ +RUN if [ -f yarn.lock ]; then yarn install; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install; \ + else npm install; fi + +# 전체 코드 복사 (단, node_modules는 제외됨) +COPY . . + +# 개발용 포트 (예: 3000) +EXPOSE 3000 + +# 개발용 명령어: nodemon, ts-node-dev 등 +CMD [ "npm", "run", "dev"] diff --git a/devtools/docker/Dockerfile.prod b/devtools/docker/Dockerfile.prod new file mode 100644 index 0000000..ee82926 --- /dev/null +++ b/devtools/docker/Dockerfile.prod @@ -0,0 +1,35 @@ +# 1. Build Stage +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json pnpm-lock.yaml* yarn.lock* package-lock.json* ./ +RUN if [ -f yarn.lock ]; then yarn install --production=false; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --prod=false; \ + else npm install --production=false; fi + +COPY . . + +# TypeScript 빌드 +RUN npx tsc + +# 2. Run Stage (더 가벼운 이미지 사용 가능) +FROM node:20-alpine + +WORKDIR /app + +# 의존성(production only) +COPY package.json pnpm-lock.yaml* yarn.lock* package-lock.json* ./ +RUN if [ -f yarn.lock ]; then yarn install --production=true --frozen-lockfile; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --prod --frozen-lockfile; \ + else npm install --production --frozen-lockfile; fi + +# 빌드된 코드만 복사 (node_modules, dist) +COPY --from=builder /app/dist ./dist + +# 환경설정 등 추가 복사 (필요시) +# COPY .env ./ + +EXPOSE 3000 + +CMD [ "npm", "start" ] diff --git a/lib/default/Makefile b/devtools/docker/Makefile similarity index 94% rename from lib/default/Makefile rename to devtools/docker/Makefile index 624834b..12fea01 100644 --- a/lib/default/Makefile +++ b/devtools/docker/Makefile @@ -40,3 +40,6 @@ clean: ## Clean the images remove: ## Remove the volumes docker volume rm -f ${APP_NAME} + +logs: ## Show container logs + docker logs -f ${APP_NAME} diff --git a/devtools/docker/nginx.conf b/devtools/docker/nginx.conf new file mode 100644 index 0000000..4cf72dc --- /dev/null +++ b/devtools/docker/nginx.conf @@ -0,0 +1,67 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # gzip compression (optional but recommended) + gzip on; + gzip_disable "msie6"; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + gzip_min_length 256; + + upstream api-server { + server server:3000; # 여러 서버면 server api1:3000; server api2:3000; ... + keepalive 100; + } + + server { + listen 80; + server_name localhost; + + # Health check endpoint + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } + + # Main reverse proxy for API + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_pass http://api-server; + proxy_read_timeout 300; + proxy_connect_timeout 60; + } + + # (Optional) Static file serving example + # location /static/ { + # alias /app/public/; + # expires 30d; + # access_log off; + # } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + include /etc/nginx/conf.d/*.conf; +} diff --git a/devtools/github/.github/workflows/ci.yml b/devtools/github/.github/workflows/ci.yml new file mode 100644 index 0000000..aa05caf --- /dev/null +++ b/devtools/github/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: Node.js CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build diff --git a/devtools/husky/.husky/commit-msg b/devtools/husky/.husky/commit-msg new file mode 100644 index 0000000..5426a93 --- /dev/null +++ b/devtools/husky/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx commitlint --edit $1 diff --git a/devtools/husky/.husky/pre-commit b/devtools/husky/.husky/pre-commit new file mode 100644 index 0000000..449fcde --- /dev/null +++ b/devtools/husky/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm test diff --git a/devtools/husky/.husky/pre-push b/devtools/husky/.husky/pre-push new file mode 100644 index 0000000..f22d70c --- /dev/null +++ b/devtools/husky/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm run build diff --git a/devtools/jest/jest.config.cjs b/devtools/jest/jest.config.cjs new file mode 100644 index 0000000..f1ad6ba --- /dev/null +++ b/devtools/jest/jest.config.cjs @@ -0,0 +1,2 @@ +require('ts-node/register'); // TS config를 실행 가능하게 +module.exports = require('./jest.config.ts').default; diff --git a/devtools/jest/jest.config.ts b/devtools/jest/jest.config.ts new file mode 100644 index 0000000..2b6cfd7 --- /dev/null +++ b/devtools/jest/jest.config.ts @@ -0,0 +1,25 @@ +import type { Config } from 'jest'; +import { pathsToModuleNameMapper } from 'ts-jest'; +import { compilerOptions } from './tsconfig.json'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + setupFiles: ['/src/test/setup.ts'], + roots: ['/src'], + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, { + prefix: '/src/', + }), + coverageDirectory: 'coverage', + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/index.ts', + ], + testPathIgnorePatterns: ['/node_modules/', '/dist/', '/logs/'], +}; + +export default config; diff --git a/devtools/jest/src/test/e2e/auth.e2e.spec.ts b/devtools/jest/src/test/e2e/auth.e2e.spec.ts new file mode 100644 index 0000000..6e9ffe6 --- /dev/null +++ b/devtools/jest/src/test/e2e/auth.e2e.spec.ts @@ -0,0 +1,40 @@ +import request from 'supertest'; +import { createTestApp, resetUserDB } from '@/test/setup'; + +describe('Auth API', () => { + let server: any; + const prefix = '/api/v1'; + + beforeAll(() => { + server = createTestApp(); // Use shared repository for testing + }); + + beforeEach(() => { + resetUserDB(); // Reset repository before each test + }); + + const user = { email: 'authuser@example.com', password: 'authpassword123' }; + + it('should successfully register a new user', async () => { + const res = await request(server).post(`${prefix}/auth/signup`).send(user); + expect(res.statusCode).toBe(201); + expect(res.body.data.email).toBe(user.email); + }); + + it('should login a user and set a cookie', async () => { + await request(server).post(`${prefix}/auth/signup`).send(user); + const res = await request(server).post(`${prefix}/auth/login`).send(user); + expect(res.statusCode).toBe(200); + expect(res.body.data.email).toBe(user.email); + expect(res.header['set-cookie']).toBeDefined(); + }); + + it('should logout a user', async () => { + await request(server).post(`${prefix}/auth/signup`).send(user); + const loginRes = await request(server).post(`${prefix}/auth/login`).send(user); + const cookie = loginRes.headers['set-cookie']; + const logoutRes = await request(server).post(`${prefix}/auth/logout`).set('Cookie', cookie[0]); + expect(logoutRes.statusCode).toBe(200); + expect(logoutRes.body.message).toBe('logout'); + }); +}); diff --git a/devtools/jest/src/test/e2e/users.e2e.spec.ts b/devtools/jest/src/test/e2e/users.e2e.spec.ts new file mode 100644 index 0000000..d52b60c --- /dev/null +++ b/devtools/jest/src/test/e2e/users.e2e.spec.ts @@ -0,0 +1,63 @@ +import request from 'supertest'; +import { createTestApp, resetUserDB } from '@/test/setup'; + +describe('Users API', () => { + let server: any; + const prefix = '/api/v1'; + + beforeAll(() => { + server = createTestApp(); // Initialize server with shared repository + }); + + beforeEach(() => { + resetUserDB(); // Reset repository before each test + }); + + const user = { email: 'user1@example.com', password: 'password123' }; + + it('should create a new user', async () => { + const res = await request(server).post(`${prefix}/users`).send(user); + expect(res.statusCode).toBe(201); + expect(res.body.data.email).toBe(user.email); + }); + + it('should retrieve all users', async () => { + await request(server).post(`${prefix}/users`).send(user); + const res = await request(server).get(`${prefix}/users`); + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data[0].email).toBe(user.email); + }); + + it('should retrieve a user by id', async () => { + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const res = await request(server).get(`${prefix}/users/${id}`); + expect(res.statusCode).toBe(200); + expect(res.body.data.email).toBe(user.email); + }); + + it('should update user information', async () => { + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const newPassword = 'newpassword123'; + const res = await request(server).put(`${prefix}/users/${id}`).send({ password: newPassword }); + expect(res.statusCode).toBe(200); + expect(res.body.data.id).toBe(id); + }); + + it('should delete a user', async () => { + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const res = await request(server).delete(`${prefix}/users/${id}`); + expect(res.statusCode).toBe(204); + }); + + it('should return 404 if user does not exist', async () => { + const res = await request(server).get(`${prefix}/users/invalid-id`); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/devtools/jest/src/test/setup.ts b/devtools/jest/src/test/setup.ts new file mode 100644 index 0000000..44e9885 --- /dev/null +++ b/devtools/jest/src/test/setup.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; +import App from '@/app'; +import { AuthRoute } from '@routes/auth.route'; +import { UsersRoute } from '@routes/users.route'; +import { UsersRepository, IUsersRepository } from '@repositories/users.repository'; + +let sharedRepo: UsersRepository; + +export function createTestApp({ mockRepo }: { mockRepo?: IUsersRepository } = {}) { + // 항상 새로운 인스턴스를 주입하고 싶으면 reset logic 추가 필요 + if (!sharedRepo) { + sharedRepo = new UsersRepository(); + container.registerInstance(UsersRepository, sharedRepo); + } + // mockRepo가 있으면 주입 + if (mockRepo) { + container.registerInstance(UsersRepository, mockRepo as UsersRepository); + } + + // 클래스 타입을 직접 주입 + const routes = [container.resolve(UsersRoute), container.resolve(AuthRoute)]; + const appInstance = new App(routes); + return appInstance.getServer(); +} + +export function resetUserDB() { + if (sharedRepo) { + sharedRepo.reset(); + } +} diff --git a/devtools/jest/src/test/unit/services/auth.service.spec.ts b/devtools/jest/src/test/unit/services/auth.service.spec.ts new file mode 100644 index 0000000..805d2db --- /dev/null +++ b/devtools/jest/src/test/unit/services/auth.service.spec.ts @@ -0,0 +1,69 @@ +import { compare, hash } from 'bcryptjs'; +import { CreateUserDto } from '@dtos/users.dto'; +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import { AuthService } from '@services/auth.service'; + +describe('AuthService (with UserMemoryRepository)', () => { + let authService: AuthService; + let userRepo: UsersRepository; + const testUser: User = { + id: '1', + email: 'authuser@example.com', + password: '', // will be replaced with actual hash + }; + + beforeEach(async () => { + userRepo = new UsersRepository(); + testUser.password = await hash('plainpw', 10); + // Add initial user + await userRepo.save({ ...testUser }); + authService = new AuthService(userRepo); // inject repo + }); + + it('should sign up a new user', async () => { + const dto: CreateUserDto = { + email: 'newuser@example.com', + password: 'newpassword123', + }; + const created = await authService.signup(dto); + expect(created.email).toBe(dto.email); + + const found = await userRepo.findByEmail(dto.email); + expect(found).toBeDefined(); + expect(await compare(dto.password, found!.password)).toBe(true); + }); + + it('should throw an error if email is already in use', async () => { + const dto: CreateUserDto = { + email: testUser.email, + password: 'anypw', + }; + await expect(authService.signup(dto)).rejects.toThrow(/already in use/); + }); + + it('should return user and cookie on successful login', async () => { + // Create user with hashed password + const plainPassword = 'mySecret123'; + const email = 'loginuser@example.com'; + const hashed = await hash(plainPassword, 10); + await userRepo.save({ id: '2', email, password: hashed }); + + const result = await authService.login({ email, password: plainPassword }); + expect(result.user.email).toBe(email); + expect(result.cookie).toContain('Authorization='); + }); + + it('should throw an error if email or password is incorrect', async () => { + // Non-existing email + await expect(authService.login({ email: 'nobody@example.com', password: 'xxx' })).rejects.toThrow(/Invalid email or password/i); + + // Incorrect password + const email = testUser.email; + await expect(authService.login({ email, password: 'wrongpw' })).rejects.toThrow(/password/i); + }); + + it('should successfully logout without errors', async () => { + await expect(authService.logout(testUser)).resolves.toBeUndefined(); + }); +}); diff --git a/devtools/jest/src/test/unit/services/users.service.spec.ts b/devtools/jest/src/test/unit/services/users.service.spec.ts new file mode 100644 index 0000000..8d117a8 --- /dev/null +++ b/devtools/jest/src/test/unit/services/users.service.spec.ts @@ -0,0 +1,89 @@ +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import { UsersService } from '@services/users.service'; + +describe('UsersService (with UsersRepository)', () => { + let usersService: UsersService; + let userRepo: UsersRepository; + + // Sample user data + const user1: User & { id: string } = { id: '1', email: 'one@example.com', password: 'pw1' }; + const user2: User & { id: string } = { id: '2', email: 'two@example.com', password: 'pw2' }; + + beforeEach(async () => { + userRepo = new UsersRepository(); + userRepo.reset(); + // Directly save users (save is async but await is optional for init) + await userRepo.save({ ...user1 }); + await userRepo.save({ ...user2 }); + usersService = new UsersService(userRepo); + }); + + it('getAllUsers: should return all users', async () => { + const users = await usersService.getAllUsers(); + expect(users.length).toBe(2); + expect(users[0].email).toBe(user1.email); + }); + + it('getUserById: should return user by ID', async () => { + const user = await usersService.getUserById('2'); + expect(user.email).toBe(user2.email); + }); + + it('getUserById: should throw if ID does not exist', async () => { + await expect(usersService.getUserById('999')).rejects.toThrow(/not found/); + }); + + it('createUser: should add a new user', async () => { + const created = await usersService.createUser({ + id: '', // ignored + email: 'new@example.com', + password: 'pw3', + }); + expect(created.email).toBe('new@example.com'); + const all = await usersService.getAllUsers(); + expect(all.length).toBe(3); + }); + + it('createUser: should throw if email already exists', async () => { + await expect( + usersService.createUser({ + id: '', + email: user1.email, + password: 'pwX', + }), + ).rejects.toThrow(/exists/); + }); + + it('updateUser: should update user password', async () => { + const newPassword = 'newpw'; + const updated = await usersService.updateUser(user2.id as string, { + id: user2.id as string, + email: user2.email, + password: newPassword, + }); + expect(updated).toBeDefined(); + expect(updated!.password).not.toBe(user2.password); // changed to hashed value + }); + + it('updateUser: should throw if ID does not exist', async () => { + await expect( + usersService.updateUser('999', { + id: '999', + email: 'no@no.com', + password: 'no', + }), + ).rejects.toThrow(/not found/); + }); + + it('deleteUser: should delete user successfully', async () => { + await usersService.deleteUser(user1.id as string); + const users = await usersService.getAllUsers(); + expect(users.length).toBe(1); + expect(users[0].id).toBe(user2.id); + }); + + it('deleteUser: should throw if ID does not exist', async () => { + await expect(usersService.deleteUser('999')).rejects.toThrow(/not found/); + }); +}); diff --git a/devtools/pm2/ecosystem.config.js b/devtools/pm2/ecosystem.config.js new file mode 100644 index 0000000..3901d93 --- /dev/null +++ b/devtools/pm2/ecosystem.config.js @@ -0,0 +1,60 @@ +module.exports = { + apps: [ + // --- PRODUCTION --- + { + name: 'prod', + script: 'dist/server.js', + exec_mode: 'cluster', + instances: 'max', + autorestart: true, + watch: false, + max_memory_restart: '1G', + output: './logs/access.log', + error: './logs/error.log', + merge_logs: true, + time: true, // 로그에 타임스탬프 + log_date_format: 'YYYY-MM-DD HH:mm:ss.SSS', + node_args: '--enable-source-maps', // 소스맵 스택트레이스 + env: { + PORT: 3000, + NODE_ENV: 'production', + }, + // env_production: { ... } // 필요 시 분리도 가능 + }, + + // --- DEVELOPMENT --- + { + name: 'dev', + script: 'src/server.ts', + interpreter: 'node', + node_args: '-r ts-node/register -r tsconfig-paths/register --enable-source-maps', + exec_mode: 'fork', + instances: 1, + autorestart: true, + watch: ['src'], + watch_delay: 300, // 저장 폭주 시 완충 + ignore_watch: ['node_modules', 'logs', 'dist'], + max_memory_restart: '1G', + output: './logs/access.log', + error: './logs/error.log', + merge_logs: true, + time: true, + env: { + PORT: 3000, + NODE_ENV: 'development', + }, + }, + ], + + deploy: { + production: { + user: 'user', + host: '0.0.0.0', + ref: 'origin/master', + repo: 'git@github.com:repo.git', + path: '/home/user/app', + 'post-deploy': + 'npm install && npm run build && pm2 reload ecosystem.config.js --env production', + }, + }, +}; diff --git a/devtools/prettier/.prettierrc b/devtools/prettier/.prettierrc new file mode 100644 index 0000000..1f67368 --- /dev/null +++ b/devtools/prettier/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 150, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "arrowParens": "avoid" +} diff --git a/devtools/prettier/eslint.config.js b/devtools/prettier/eslint.config.js new file mode 100644 index 0000000..38cef6f --- /dev/null +++ b/devtools/prettier/eslint.config.js @@ -0,0 +1,25 @@ +import tseslint from 'typescript-eslint'; +import globals from 'globals'; +import eslintConfigPrettier from 'eslint-config-prettier'; + +export default [ + { + ignores: ['dist/', 'coverage/', 'logs/', 'node_modules/', '*.config.js', '*.config.ts', '.env', '.env.*', '!.eslintrc.js'], + }, + ...tseslint.configs.recommended, + eslintConfigPrettier, + { + files: ['**/*.ts'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { ...globals.node, ...globals.es2022 }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/no-var-requires': 'off', + }, + }, +]; diff --git a/lib/default/.swcrc b/devtools/swc/.swcrc similarity index 91% rename from lib/default/.swcrc rename to devtools/swc/.swcrc index cf100a9..d427bc3 100644 --- a/lib/default/.swcrc +++ b/devtools/swc/.swcrc @@ -21,13 +21,13 @@ "baseUrl": "src", "paths": { "@/*": ["*"], - "@config": ["config"], + "@config/*": ["config/*"], "@controllers/*": ["controllers/*"], "@dtos/*": ["dtos/*"], "@exceptions/*": ["exceptions/*"], "@interfaces/*": ["interfaces/*"], "@middlewares/*": ["middlewares/*"], - "@models/*": ["models/*"], + "@repositories/*": ["repositories/*"], "@routes/*": ["routes/*"], "@services/*": ["services/*"], "@utils/*": ["utils/*"] diff --git a/devtools/tsup/tsup.config.ts b/devtools/tsup/tsup.config.ts new file mode 100644 index 0000000..9184f14 --- /dev/null +++ b/devtools/tsup/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/server.ts'], // CLI 등 추가시 여러 entry도 가능 + outDir: 'dist', // 출력 디렉토리 + format: ['cjs'], // 필요시 'esm'도 ['cjs', 'esm'] + dts: true, // 타입 선언 파일(.d.ts) 생성 + sourcemap: true, // 소스맵 생성 + clean: true, // 빌드 전 dist 폴더 정리 + target: 'es2020', // 트랜스파일 대상 + minify: false, // JS 파일을 압축(minify) 해서 더 작게 만듭니다. + treeshake: true, //사용하지 않는 코드를 제거합니다. 기본은 true지만 명시 + // alias, swc 옵션은 tsconfig.json 기준으로 자동 적용 + // external: [], // node_modules 제외하고 싶을 때 +}); diff --git a/devtools/vitest/src/test/e2e/auth.e2e.spec.ts b/devtools/vitest/src/test/e2e/auth.e2e.spec.ts new file mode 100644 index 0000000..f110cb5 --- /dev/null +++ b/devtools/vitest/src/test/e2e/auth.e2e.spec.ts @@ -0,0 +1,41 @@ +import request from 'supertest'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { createTestApp, resetUserDB } from '@/test/setup'; + +describe('Auth API', () => { + let server: any; + const prefix = '/api/v1'; + + beforeAll(() => { + server = createTestApp(); // Use shared repository for testing + }); + + beforeEach(() => { + resetUserDB(); // Reset repository before each test + }); + + const user = { email: 'authuser@example.com', password: 'authpassword123' }; + + it('should successfully register a new user', async () => { + const res = await request(server).post(`${prefix}/auth/signup`).send(user); + expect(res.statusCode).toBe(201); + expect(res.body.data.email).toBe(user.email); + }); + + it('should login a user and set a cookie', async () => { + await request(server).post(`${prefix}/auth/signup`).send(user); + const res = await request(server).post(`${prefix}/auth/login`).send(user); + expect(res.statusCode).toBe(200); + expect(res.body.data.email).toBe(user.email); + expect(res.header['set-cookie']).toBeDefined(); + }); + + it('should logout a user', async () => { + await request(server).post(`${prefix}/auth/signup`).send(user); + const loginRes = await request(server).post(`${prefix}/auth/login`).send(user); + const cookie = loginRes.headers['set-cookie']; + const logoutRes = await request(server).post(`${prefix}/auth/logout`).set('Cookie', cookie[0]); + expect(logoutRes.statusCode).toBe(200); + expect(logoutRes.body.message).toBe('logout'); + }); +}); diff --git a/devtools/vitest/src/test/e2e/users.e2e.spec.ts b/devtools/vitest/src/test/e2e/users.e2e.spec.ts new file mode 100644 index 0000000..69f8cbf --- /dev/null +++ b/devtools/vitest/src/test/e2e/users.e2e.spec.ts @@ -0,0 +1,64 @@ +import request from 'supertest'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { createTestApp, resetUserDB } from '@/test/setup'; + +describe('Users API', () => { + let server: any; + const prefix = '/api/v1'; + + beforeAll(() => { + server = createTestApp(); // Initialize server with shared repository + }); + + beforeEach(() => { + resetUserDB(); // Reset repository before each test + }); + + const user = { email: 'user1@example.com', password: 'password123' }; + + it('should create a new user', async () => { + const res = await request(server).post(`${prefix}/users`).send(user); + expect(res.statusCode).toBe(201); + expect(res.body.data.email).toBe(user.email); + }); + + it('should retrieve all users', async () => { + await request(server).post(`${prefix}/users`).send(user); + const res = await request(server).get(`${prefix}/users`); + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data[0].email).toBe(user.email); + }); + + it('should retrieve a user by id', async () => { + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const res = await request(server).get(`${prefix}/users/${id}`); + expect(res.statusCode).toBe(200); + expect(res.body.data.email).toBe(user.email); + }); + + it('should update user information', async () => { + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const newPassword = 'newpassword123'; + const res = await request(server).put(`${prefix}/users/${id}`).send({ password: newPassword }); + expect(res.statusCode).toBe(200); + expect(res.body.data.id).toBe(id); + }); + + it('should delete a user', async () => { + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const res = await request(server).delete(`${prefix}/users/${id}`); + expect(res.statusCode).toBe(204); + }); + + it('should return 404 if user does not exist', async () => { + const res = await request(server).get(`${prefix}/users/invalid-id`); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/devtools/vitest/src/test/setup.ts b/devtools/vitest/src/test/setup.ts new file mode 100644 index 0000000..44e9885 --- /dev/null +++ b/devtools/vitest/src/test/setup.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; +import App from '@/app'; +import { AuthRoute } from '@routes/auth.route'; +import { UsersRoute } from '@routes/users.route'; +import { UsersRepository, IUsersRepository } from '@repositories/users.repository'; + +let sharedRepo: UsersRepository; + +export function createTestApp({ mockRepo }: { mockRepo?: IUsersRepository } = {}) { + // 항상 새로운 인스턴스를 주입하고 싶으면 reset logic 추가 필요 + if (!sharedRepo) { + sharedRepo = new UsersRepository(); + container.registerInstance(UsersRepository, sharedRepo); + } + // mockRepo가 있으면 주입 + if (mockRepo) { + container.registerInstance(UsersRepository, mockRepo as UsersRepository); + } + + // 클래스 타입을 직접 주입 + const routes = [container.resolve(UsersRoute), container.resolve(AuthRoute)]; + const appInstance = new App(routes); + return appInstance.getServer(); +} + +export function resetUserDB() { + if (sharedRepo) { + sharedRepo.reset(); + } +} diff --git a/devtools/vitest/src/test/unit/services/auth.service.spec.ts b/devtools/vitest/src/test/unit/services/auth.service.spec.ts new file mode 100644 index 0000000..273ada8 --- /dev/null +++ b/devtools/vitest/src/test/unit/services/auth.service.spec.ts @@ -0,0 +1,70 @@ +import { compare, hash } from 'bcryptjs'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { CreateUserDto } from '@dtos/users.dto'; +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import { AuthService } from '@services/auth.service'; + +describe('AuthService (with UserMemoryRepository)', () => { + let authService: AuthService; + let userRepo: UsersRepository; + const testUser: User = { + id: '1', + email: 'authuser@example.com', + password: '', // will be replaced with actual hash + }; + + beforeEach(async () => { + userRepo = new UsersRepository(); + testUser.password = await hash('plainpw', 10); + // Add initial user + await userRepo.save({ ...testUser }); + authService = new AuthService(userRepo); // inject repo + }); + + it('should sign up a new user', async () => { + const dto: CreateUserDto = { + email: 'newuser@example.com', + password: 'newpassword123', + }; + const created = await authService.signup(dto); + expect(created.email).toBe(dto.email); + + const found = await userRepo.findByEmail(dto.email); + expect(found).toBeDefined(); + expect(await compare(dto.password, found!.password)).toBe(true); + }); + + it('should throw an error if email is already in use', async () => { + const dto: CreateUserDto = { + email: testUser.email, + password: 'anypw', + }; + await expect(authService.signup(dto)).rejects.toThrow(/already in use/); + }); + + it('should return user and cookie on successful login', async () => { + // Create user with hashed password + const plainPassword = 'mySecret123'; + const email = 'loginuser@example.com'; + const hashed = await hash(plainPassword, 10); + await userRepo.save({ id: '2', email, password: hashed }); + + const result = await authService.login({ email, password: plainPassword }); + expect(result.user.email).toBe(email); + expect(result.cookie).toContain('Authorization='); + }); + + it('should throw an error if email or password is incorrect', async () => { + // Non-existing email + await expect(authService.login({ email: 'nobody@example.com', password: 'xxx' })).rejects.toThrow(/Invalid email or password/i); + + // Incorrect password + const email = testUser.email; + await expect(authService.login({ email, password: 'wrongpw' })).rejects.toThrow(/password/i); + }); + + it('should successfully logout without errors', async () => { + await expect(authService.logout(testUser)).resolves.toBeUndefined(); + }); +}); diff --git a/devtools/vitest/src/test/unit/services/users.service.spec.ts b/devtools/vitest/src/test/unit/services/users.service.spec.ts new file mode 100644 index 0000000..b540fa8 --- /dev/null +++ b/devtools/vitest/src/test/unit/services/users.service.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import { UsersService } from '@services/users.service'; + +describe('UsersService (with UsersRepository)', () => { + let usersService: UsersService; + let userRepo: UsersRepository; + + // Sample user data + const user1: User & { id: string } = { id: '1', email: 'one@example.com', password: 'pw1' }; + const user2: User & { id: string } = { id: '2', email: 'two@example.com', password: 'pw2' }; + + beforeEach(async () => { + userRepo = new UsersRepository(); + userRepo.reset(); + // Directly save users (save is async but await is optional for init) + await userRepo.save({ ...user1 }); + await userRepo.save({ ...user2 }); + usersService = new UsersService(userRepo); + }); + + it('getAllUsers: should return all users', async () => { + const users = await usersService.getAllUsers(); + expect(users.length).toBe(2); + expect(users[0].email).toBe(user1.email); + }); + + it('getUserById: should return user by ID', async () => { + const user = await usersService.getUserById('2'); + expect(user.email).toBe(user2.email); + }); + + it('getUserById: should throw if ID does not exist', async () => { + await expect(usersService.getUserById('999')).rejects.toThrow(/not found/); + }); + + it('createUser: should add a new user', async () => { + const created = await usersService.createUser({ + id: '', // ignored + email: 'new@example.com', + password: 'pw3', + }); + expect(created.email).toBe('new@example.com'); + const all = await usersService.getAllUsers(); + expect(all.length).toBe(3); + }); + + it('createUser: should throw if email already exists', async () => { + await expect( + usersService.createUser({ + id: '', + email: user1.email, + password: 'pwX', + }), + ).rejects.toThrow(/exists/); + }); + + it('updateUser: should update user password', async () => { + const newPassword = 'newpw'; + const updated = await usersService.updateUser(user2.id as string, { + id: user2.id as string, + email: user2.email, + password: newPassword, + }); + expect(updated).toBeDefined(); + expect(updated!.password).not.toBe(user2.password); // changed to hashed value + }); + + it('updateUser: should throw if ID does not exist', async () => { + await expect( + usersService.updateUser('999', { + id: '999', + email: 'no@no.com', + password: 'no', + }), + ).rejects.toThrow(/not found/); + }); + + it('deleteUser: should delete user successfully', async () => { + await usersService.deleteUser(user1.id as string); + const users = await usersService.getAllUsers(); + expect(users.length).toBe(1); + expect(users[0].id).toBe(user2.id); + }); + + it('deleteUser: should throw if ID does not exist', async () => { + await expect(usersService.deleteUser('999')).rejects.toThrow(/not found/); + }); +}); diff --git a/devtools/vitest/vitest.config.ts b/devtools/vitest/vitest.config.ts new file mode 100644 index 0000000..bbd0dfb --- /dev/null +++ b/devtools/vitest/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: 'node', + globals: true, // 전역 expect/describe/it 사용 시 편의. 원치 않으면 제거 + setupFiles: ['src/test/setup.ts'], + include: ['src/test/**/*.{test,spec}.ts'], // src/test/e2e, src/test/unit에 최적화 + exclude: ['node_modules', 'dist', 'coverage', 'logs'], + coverage: { + provider: 'v8', // v8 사용 시 + reporter: ['text', 'html', 'lcov'], + reportsDirectory: 'coverage', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/**/*.d.ts', 'src/**/index.ts', 'src/test/**'], + }, + // e2e에서 포트/자원 충돌나면 단일 스레드: + // poolOptions: { threads: { singleThread: true } }, + }, +}); diff --git a/lib/default/.dockerignore b/lib/default/.dockerignore deleted file mode 100644 index 0b2f116..0000000 --- a/lib/default/.dockerignore +++ /dev/null @@ -1,18 +0,0 @@ -# compiled output -.vscode -/node_modules - -# code formatter -.eslintrc -.eslintignore -.editorconfig -.huskyrc -.lintstagedrc.json -.prettierrc - -# test -jest.config.js - -# docker -Dockerfile -docker-compose.yml diff --git a/lib/default/.editorconfig b/lib/default/.editorconfig deleted file mode 100644 index c6c8b36..0000000 --- a/lib/default/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true diff --git a/lib/default/.env.development.local b/lib/default/.env.development.local deleted file mode 100644 index dc6fced..0000000 --- a/lib/default/.env.development.local +++ /dev/null @@ -1,13 +0,0 @@ -# PORT -PORT = 3000 - -# TOKEN -SECRET_KEY = secretKey - -# LOG -LOG_FORMAT = dev -LOG_DIR = ../logs - -# CORS -ORIGIN = * -CREDENTIALS = true diff --git a/lib/default/.env.production.local b/lib/default/.env.production.local deleted file mode 100644 index dad9936..0000000 --- a/lib/default/.env.production.local +++ /dev/null @@ -1,13 +0,0 @@ -# PORT -PORT = 3000 - -# TOKEN -SECRET_KEY = secretKey - -# LOG -LOG_FORMAT = combined -LOG_DIR = ../logs - -# CORS -ORIGIN = your.domain.com -CREDENTIALS = true diff --git a/lib/default/.env.test.local b/lib/default/.env.test.local deleted file mode 100644 index dc6fced..0000000 --- a/lib/default/.env.test.local +++ /dev/null @@ -1,13 +0,0 @@ -# PORT -PORT = 3000 - -# TOKEN -SECRET_KEY = secretKey - -# LOG -LOG_FORMAT = dev -LOG_DIR = ../logs - -# CORS -ORIGIN = * -CREDENTIALS = true diff --git a/lib/default/.eslintignore b/lib/default/.eslintignore deleted file mode 100644 index 3e22129..0000000 --- a/lib/default/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -/dist \ No newline at end of file diff --git a/lib/default/.eslintrc b/lib/default/.eslintrc deleted file mode 100644 index 206ab05..0000000 --- a/lib/default/.eslintrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "extends": ["prettier", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], - "parserOptions": { - "ecmaVersion": 2018, - "sourceType": "module" - }, - "rules": { - "@typescript-eslint/explicit-member-accessibility": 0, - "@typescript-eslint/explicit-function-return-type": 0, - "@typescript-eslint/no-parameter-properties": 0, - "@typescript-eslint/interface-name-prefix": 0, - "@typescript-eslint/explicit-module-boundary-types": 0, - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-var-requires": "off" - } -} diff --git a/lib/default/.huskyrc b/lib/default/.huskyrc deleted file mode 100644 index 4d077c8..0000000 --- a/lib/default/.huskyrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "hooks": { - "pre-commit": "lint-staged" - } -} diff --git a/lib/default/.lintstagedrc.json b/lib/default/.lintstagedrc.json deleted file mode 100644 index d2fe776..0000000 --- a/lib/default/.lintstagedrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "*.ts": [ - "npm run lint" - ] -} \ No newline at end of file diff --git a/lib/default/.prettierrc b/lib/default/.prettierrc deleted file mode 100644 index 93a5aaf..0000000 --- a/lib/default/.prettierrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "printWidth": 150, - "tabWidth": 2, - "singleQuote": true, - "trailingComma": "all", - "semi": true, - "arrowParens": "avoid" -} \ No newline at end of file diff --git a/lib/default/.vscode/launch.json b/lib/default/.vscode/launch.json deleted file mode 100644 index 00ccfd7..0000000 --- a/lib/default/.vscode/launch.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node-terminal", - "request": "launch", - "name": "Dev typescript-express-starter", - "command": "npm run dev" - }, - { - "type": "node-terminal", - "request": "launch", - "name": "Start typescript-express-starter", - "command": "npm run start" - }, - { - "type": "node-terminal", - "request": "launch", - "name": "Test typescript-express-starter", - "command": "npm run test" - }, - { - "type": "node-terminal", - "request": "launch", - "name": "Lint typescript-express-starter", - "command": "npm run lint" - }, - { - "type": "node-terminal", - "request": "launch", - "name": "Lint:Fix typescript-express-starter", - "command": "npm run lint:fix" - } - ] -} diff --git a/lib/default/.vscode/settings.json b/lib/default/.vscode/settings.json deleted file mode 100644 index 42ab89c..0000000 --- a/lib/default/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - }, - "editor.formatOnSave": false -} diff --git a/lib/default/Dockerfile.dev b/lib/default/Dockerfile.dev deleted file mode 100644 index 0fbb991..0000000 --- a/lib/default/Dockerfile.dev +++ /dev/null @@ -1,18 +0,0 @@ -# NodeJS Version 16 -FROM node:16.18-buster-slim - -# Copy Dir -COPY . ./app - -# Work to Dir -WORKDIR /app - -# Install Node Package -RUN npm install --legacy-peer-deps - -# Set Env -ENV NODE_ENV development -EXPOSE 3000 - -# Cmd script -CMD ["npm", "run", "dev"] diff --git a/lib/default/Dockerfile.prod b/lib/default/Dockerfile.prod deleted file mode 100644 index 2f81c34..0000000 --- a/lib/default/Dockerfile.prod +++ /dev/null @@ -1,18 +0,0 @@ -# NodeJS Version 16 -FROM node:16.18-buster-slim - -# Copy Dir -COPY . ./app - -# Work to Dir -WORKDIR /app - -# Install Node Package -RUN npm install --legacy-peer-deps - -# Set Env -ENV NODE_ENV production -EXPOSE 3000 - -# Cmd script -CMD ["npm", "run", "start"] diff --git a/lib/default/docker-compose.yml b/lib/default/docker-compose.yml deleted file mode 100644 index 8138f59..0000000 --- a/lib/default/docker-compose.yml +++ /dev/null @@ -1,35 +0,0 @@ -version: "3.9" - -services: - proxy: - container_name: proxy - image: nginx:alpine - ports: - - "80:80" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf - restart: "unless-stopped" - networks: - - backend - - server: - container_name: server - build: - context: ./ - dockerfile: Dockerfile.dev - ports: - - "3000:3000" - volumes: - - ./:/app - - /app/node_modules - restart: 'unless-stopped' - networks: - - backend - -networks: - backend: - driver: bridge - -volumes: - data: - driver: local diff --git a/lib/default/ecosystem.config.js b/lib/default/ecosystem.config.js deleted file mode 100644 index 94cfa72..0000000 --- a/lib/default/ecosystem.config.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @description pm2 configuration file. - * @example - * production mode :: pm2 start ecosystem.config.js --only prod - * development mode :: pm2 start ecosystem.config.js --only dev - */ - module.exports = { - apps: [ - { - name: 'prod', // pm2 start App name - script: 'dist/server.js', - exec_mode: 'cluster', // 'cluster' or 'fork' - instance_var: 'INSTANCE_ID', // instance variable - instances: 2, // pm2 instance count - autorestart: true, // auto restart if process crash - watch: false, // files change automatic restart - ignore_watch: ['node_modules', 'logs'], // ignore files change - max_memory_restart: '1G', // restart if process use more than 1G memory - merge_logs: true, // if true, stdout and stderr will be merged and sent to pm2 log - output: './logs/access.log', // pm2 log file - error: './logs/error.log', // pm2 error log file - env: { // environment variable - PORT: 3000, - NODE_ENV: 'production', - }, - }, - { - name: 'dev', // pm2 start App name - script: 'ts-node', // ts-node - args: '-r tsconfig-paths/register --transpile-only src/server.ts', // ts-node args - exec_mode: 'cluster', // 'cluster' or 'fork' - instance_var: 'INSTANCE_ID', // instance variable - instances: 2, // pm2 instance count - autorestart: true, // auto restart if process crash - watch: false, // files change automatic restart - ignore_watch: ['node_modules', 'logs'], // ignore files change - max_memory_restart: '1G', // restart if process use more than 1G memory - merge_logs: true, // if true, stdout and stderr will be merged and sent to pm2 log - output: './logs/access.log', // pm2 log file - error: './logs/error.log', // pm2 error log file - env: { // environment variable - PORT: 3000, - NODE_ENV: 'development', - }, - }, - ], - deploy: { - production: { - user: 'user', - host: '0.0.0.0', - ref: 'origin/master', - repo: 'git@github.com:repo.git', - path: 'dist/server.js', - 'post-deploy': 'npm install && npm run build && pm2 reload ecosystem.config.js --only prod', - }, - }, -}; diff --git a/lib/default/jest.config.js b/lib/default/jest.config.js deleted file mode 100644 index 8edf5ba..0000000 --- a/lib/default/jest.config.js +++ /dev/null @@ -1,12 +0,0 @@ -const { pathsToModuleNameMapper } = require('ts-jest'); -const { compilerOptions } = require('./tsconfig.json'); - -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/src'], - transform: { - '^.+\\.tsx?$': 'ts-jest', - }, - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/src' }), -}; diff --git a/lib/default/nginx.conf b/lib/default/nginx.conf deleted file mode 100644 index 7cb62f6..0000000 --- a/lib/default/nginx.conf +++ /dev/null @@ -1,40 +0,0 @@ -user nginx; -worker_processes 1; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - upstream api-server { - server server:3000; - keepalive 100; - } - - server { - listen 80; - server_name localhost; - - location / { - proxy_http_version 1.1; - proxy_pass http://api-server; - } - - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - include /etc/nginx/conf.d/*.conf; -} diff --git a/lib/default/nodemon.json b/lib/default/nodemon.json deleted file mode 100644 index 9c4580d..0000000 --- a/lib/default/nodemon.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "watch": [ - "src", - ".env" - ], - "ext": "js,ts,json", - "ignore": [ - "src/logs/*", - "src/**/*.{spec,test}.ts" - ], - "exec": "ts-node -r tsconfig-paths/register --transpile-only src/server.ts" -} \ No newline at end of file diff --git a/lib/default/package.json b/lib/default/package.json deleted file mode 100644 index 0b2b639..0000000 --- a/lib/default/package.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "typescript-express-starter", - "version": "0.0.0", - "description": "TypeScript + Express API Server", - "author": "", - "license": "ISC", - "scripts": { - "start": "npm run build && cross-env NODE_ENV=production node dist/server.js", - "dev": "cross-env NODE_ENV=development nodemon", - "build": "swc src -d dist --source-maps --copy-files", - "build:tsc": "tsc && tsc-alias", - "test": "jest --forceExit --detectOpenHandles", - "lint": "eslint --ignore-path .gitignore --ext .ts src", - "lint:fix": "npm run lint -- --fix", - "deploy:prod": "npm run build && pm2 start ecosystem.config.js --only prod", - "deploy:dev": "pm2 start ecosystem.config.js --only dev" - }, - "dependencies": { - "bcrypt": "^5.0.1", - "class-transformer": "^0.5.1", - "class-validator": "^0.13.2", - "compression": "^1.7.4", - "cookie-parser": "^1.4.6", - "cors": "^2.8.5", - "dotenv": "^16.0.1", - "envalid": "^7.3.1", - "express": "^4.18.1", - "helmet": "^5.1.1", - "hpp": "^0.2.3", - "jsonwebtoken": "^8.5.1", - "morgan": "^1.10.0", - "reflect-metadata": "^0.1.13", - "swagger-jsdoc": "^6.2.1", - "swagger-ui-express": "^4.5.0", - "typedi": "^0.10.0", - "winston": "^3.8.1", - "winston-daily-rotate-file": "^4.7.1" - }, - "devDependencies": { - "@swc/cli": "^0.1.57", - "@swc/core": "^1.2.220", - "@types/bcrypt": "^5.0.0", - "@types/compression": "^1.7.2", - "@types/cookie-parser": "^1.4.3", - "@types/cors": "^2.8.12", - "@types/express": "^4.17.13", - "@types/hpp": "^0.2.2", - "@types/jest": "^28.1.6", - "@types/jsonwebtoken": "^8.5.8", - "@types/morgan": "^1.9.3", - "@types/node": "^17.0.45", - "@types/supertest": "^2.0.12", - "@types/swagger-jsdoc": "^6.0.1", - "@types/swagger-ui-express": "^4.1.3", - "@typescript-eslint/eslint-plugin": "^5.29.0", - "@typescript-eslint/parser": "^5.29.0", - "cross-env": "^7.0.3", - "eslint": "^8.20.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-prettier": "^4.2.1", - "husky": "^8.0.1", - "jest": "^28.1.1", - "lint-staged": "^13.0.3", - "node-config": "^0.0.2", - "node-gyp": "^9.1.0", - "nodemon": "^2.0.19", - "pm2": "^5.2.0", - "prettier": "^2.7.1", - "supertest": "^6.2.4", - "ts-jest": "^28.0.7", - "ts-node": "^10.9.1", - "tsc-alias": "^1.7.0", - "tsconfig-paths": "^4.0.0", - "typescript": "^4.7.4" - } -} diff --git a/lib/default/src/app.ts b/lib/default/src/app.ts deleted file mode 100644 index d5c6fc3..0000000 --- a/lib/default/src/app.ts +++ /dev/null @@ -1,81 +0,0 @@ -import 'reflect-metadata'; -import compression from 'compression'; -import cookieParser from 'cookie-parser'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; -import hpp from 'hpp'; -import morgan from 'morgan'; -import swaggerJSDoc from 'swagger-jsdoc'; -import swaggerUi from 'swagger-ui-express'; -import { NODE_ENV, PORT, LOG_FORMAT, ORIGIN, CREDENTIALS } from '@config'; -import { Routes } from '@interfaces/routes.interface'; -import { ErrorMiddleware } from '@middlewares/error.middleware'; -import { logger, stream } from '@utils/logger'; - -export class App { - public app: express.Application; - public env: string; - public port: string | number; - - constructor(routes: Routes[]) { - this.app = express(); - this.env = NODE_ENV || 'development'; - this.port = PORT || 3000; - - this.initializeMiddlewares(); - this.initializeRoutes(routes); - this.initializeSwagger(); - this.initializeErrorHandling(); - } - - public listen() { - this.app.listen(this.port, () => { - logger.info(`=================================`); - logger.info(`======= ENV: ${this.env} =======`); - logger.info(`🚀 App listening on the port ${this.port}`); - logger.info(`=================================`); - }); - } - - public getServer() { - return this.app; - } - - private initializeMiddlewares() { - this.app.use(morgan(LOG_FORMAT, { stream })); - this.app.use(cors({ origin: ORIGIN, credentials: CREDENTIALS })); - this.app.use(hpp()); - this.app.use(helmet()); - this.app.use(compression()); - this.app.use(express.json()); - this.app.use(express.urlencoded({ extended: true })); - this.app.use(cookieParser()); - } - - private initializeRoutes(routes: Routes[]) { - routes.forEach(route => { - this.app.use('/', route.router); - }); - } - - private initializeSwagger() { - const options = { - swaggerDefinition: { - info: { - title: 'REST API', - version: '1.0.0', - description: 'Example docs', - }, - }, - apis: ['swagger.yaml'], - }; - - const specs = swaggerJSDoc(options); - this.app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs)); - } - - private initializeErrorHandling() { - this.app.use(ErrorMiddleware); - } -} diff --git a/lib/default/src/config/index.ts b/lib/default/src/config/index.ts deleted file mode 100644 index ef17df5..0000000 --- a/lib/default/src/config/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { config } from 'dotenv'; -config({ path: `.env.${process.env.NODE_ENV || 'development'}.local` }); - -export const CREDENTIALS = process.env.CREDENTIALS === 'true'; -export const { NODE_ENV, PORT, SECRET_KEY, LOG_FORMAT, LOG_DIR, ORIGIN } = process.env; diff --git a/lib/default/src/controllers/auth.controller.ts b/lib/default/src/controllers/auth.controller.ts deleted file mode 100644 index ab035db..0000000 --- a/lib/default/src/controllers/auth.controller.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; -import { Container } from 'typedi'; -import { RequestWithUser } from '@interfaces/auth.interface'; -import { User } from '@interfaces/users.interface'; -import { AuthService } from '@services/auth.service'; - -export class AuthController { - public auth = Container.get(AuthService); - - public signUp = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userData: User = req.body; - const signUpUserData: User = await this.auth.signup(userData); - - res.status(201).json({ data: signUpUserData, message: 'signup' }); - } catch (error) { - next(error); - } - }; - - public logIn = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userData: User = req.body; - const { cookie, findUser } = await this.auth.login(userData); - - res.setHeader('Set-Cookie', [cookie]); - res.status(200).json({ data: findUser, message: 'login' }); - } catch (error) { - next(error); - } - }; - - public logOut = async (req: RequestWithUser, res: Response, next: NextFunction): Promise => { - try { - const userData: User = req.user; - const logOutUserData: User = await this.auth.logout(userData); - - res.setHeader('Set-Cookie', ['Authorization=; Max-age=0']); - res.status(200).json({ data: logOutUserData, message: 'logout' }); - } catch (error) { - next(error); - } - }; -} diff --git a/lib/default/src/controllers/users.controller.ts b/lib/default/src/controllers/users.controller.ts deleted file mode 100644 index 60f46bf..0000000 --- a/lib/default/src/controllers/users.controller.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; -import { Container } from 'typedi'; -import { User } from '@interfaces/users.interface'; -import { UserService } from '@services/users.service'; - -export class UserController { - public user = Container.get(UserService); - - public getUsers = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const findAllUsersData: User[] = await this.user.findAllUser(); - - res.status(200).json({ data: findAllUsersData, message: 'findAll' }); - } catch (error) { - next(error); - } - }; - - public getUserById = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userId = Number(req.params.id); - const findOneUserData: User = await this.user.findUserById(userId); - - res.status(200).json({ data: findOneUserData, message: 'findOne' }); - } catch (error) { - next(error); - } - }; - - public createUser = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userData: User = req.body; - const createUserData: User = await this.user.createUser(userData); - - res.status(201).json({ data: createUserData, message: 'created' }); - } catch (error) { - next(error); - } - }; - - public updateUser = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userId = Number(req.params.id); - const userData: User = req.body; - const updateUserData: User[] = await this.user.updateUser(userId, userData); - - res.status(200).json({ data: updateUserData, message: 'updated' }); - } catch (error) { - next(error); - } - }; - - public deleteUser = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userId = Number(req.params.id); - const deleteUserData: User[] = await this.user.deleteUser(userId); - - res.status(200).json({ data: deleteUserData, message: 'deleted' }); - } catch (error) { - next(error); - } - }; -} diff --git a/lib/default/src/dtos/users.dto.ts b/lib/default/src/dtos/users.dto.ts deleted file mode 100644 index 5bf903b..0000000 --- a/lib/default/src/dtos/users.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IsEmail, IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator'; - -export class CreateUserDto { - @IsEmail() - public email: string; - - @IsString() - @IsNotEmpty() - @MinLength(9) - @MaxLength(32) - public password: string; -} - -export class UpdateUserDto { - @IsString() - @IsNotEmpty() - @MinLength(9) - @MaxLength(32) - public password: string; -} diff --git a/lib/default/src/exceptions/HttpException.ts b/lib/default/src/exceptions/HttpException.ts deleted file mode 100644 index f0ae6aa..0000000 --- a/lib/default/src/exceptions/HttpException.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class HttpException extends Error { - public status: number; - public message: string; - - constructor(status: number, message: string) { - super(message); - this.status = status; - this.message = message; - } -} diff --git a/lib/default/src/http/auth.http b/lib/default/src/http/auth.http deleted file mode 100644 index 2198991..0000000 --- a/lib/default/src/http/auth.http +++ /dev/null @@ -1,27 +0,0 @@ -# baseURL -@baseURL = http://localhost:3000 - -### -# User Signup -POST {{ baseURL }}/signup -Content-Type: application/json - -{ - "email": "example@email.com", - "password": "password" -} - -### -# User Login -POST {{ baseURL }}/login -Content-Type: application/json - -{ - "email": "example@email.com", - "password": "password" -} - -### -# User Logout -POST {{ baseURL }}/logout -Content-Type: application/json diff --git a/lib/default/src/http/users.http b/lib/default/src/http/users.http deleted file mode 100644 index 13209f2..0000000 --- a/lib/default/src/http/users.http +++ /dev/null @@ -1,34 +0,0 @@ -# baseURL -@baseURL = http://localhost:3000 - -### -# Find All Users -GET {{ baseURL }}/users - -### -# Find User By Id -GET {{ baseURL }}/users/1 - -### -# Create User -POST {{ baseURL }}/users -Content-Type: application/json - -{ - "email": "example@email.com", - "password": "password" -} - -### -# Modify User By Id -PUT {{ baseURL }}/users/1 -Content-Type: application/json - -{ - "email": "example@email.com", - "password": "password" -} - -### -# Delete User By Id -DELETE {{ baseURL }}/users/1 diff --git a/lib/default/src/interfaces/users.interface.ts b/lib/default/src/interfaces/users.interface.ts deleted file mode 100644 index 07bbc28..0000000 --- a/lib/default/src/interfaces/users.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface User { - id?: number; - email?: string; - password: string; -} diff --git a/lib/default/src/middlewares/auth.middleware.ts b/lib/default/src/middlewares/auth.middleware.ts deleted file mode 100644 index c311410..0000000 --- a/lib/default/src/middlewares/auth.middleware.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextFunction, Response } from 'express'; -import { verify } from 'jsonwebtoken'; -import { SECRET_KEY } from '@config'; -import { HttpException } from '@exceptions/httpException'; -import { DataStoredInToken, RequestWithUser } from '@interfaces/auth.interface'; -import { UserModel } from '@models/users.model'; - -const getAuthorization = req => { - const coockie = req.cookies['Authorization']; - if (coockie) return coockie; - - const header = req.header('Authorization'); - if (header) return header.split('Bearer ')[1]; - - return null; -}; - -export const AuthMiddleware = async (req: RequestWithUser, res: Response, next: NextFunction) => { - try { - const Authorization = getAuthorization(req); - - if (Authorization) { - const { id } = (await verify(Authorization, SECRET_KEY)) as DataStoredInToken; - const findUser = UserModel.find(user => user.id === id); - - if (findUser) { - req.user = findUser; - next(); - } else { - next(new HttpException(401, 'Wrong authentication token')); - } - } else { - next(new HttpException(404, 'Authentication token missing')); - } - } catch (error) { - next(new HttpException(401, 'Wrong authentication token')); - } -}; diff --git a/lib/default/src/middlewares/error.middleware.ts b/lib/default/src/middlewares/error.middleware.ts deleted file mode 100644 index 1dbeb17..0000000 --- a/lib/default/src/middlewares/error.middleware.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; -import { HttpException } from '@exceptions/httpException'; -import { logger } from '@utils/logger'; - -export const ErrorMiddleware = (error: HttpException, req: Request, res: Response, next: NextFunction) => { - try { - const status: number = error.status || 500; - const message: string = error.message || 'Something went wrong'; - - logger.error(`[${req.method}] ${req.path} >> StatusCode:: ${status}, Message:: ${message}`); - res.status(status).json({ message }); - } catch (error) { - next(error); - } -}; diff --git a/lib/default/src/middlewares/validation.middleware.ts b/lib/default/src/middlewares/validation.middleware.ts deleted file mode 100644 index b351e21..0000000 --- a/lib/default/src/middlewares/validation.middleware.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { plainToInstance } from 'class-transformer'; -import { validateOrReject, ValidationError } from 'class-validator'; -import { NextFunction, Request, Response } from 'express'; -import { HttpException } from '@exceptions/httpException'; - -/** - * @name ValidationMiddleware - * @description Allows use of decorator and non-decorator based validation - * @param type dto - * @param skipMissingProperties When skipping missing properties - * @param whitelist Even if your object is an instance of a validation class it can contain additional properties that are not defined - * @param forbidNonWhitelisted If you would rather to have an error thrown when any non-whitelisted properties are present - */ -export const ValidationMiddleware = (type: any, skipMissingProperties = false, whitelist = true, forbidNonWhitelisted = true) => { - return (req: Request, res: Response, next: NextFunction) => { - const dto = plainToInstance(type, req.body); - validateOrReject(dto, { skipMissingProperties, whitelist, forbidNonWhitelisted }) - .then(() => { - req.body = dto; - next(); - }) - .catch((errors: ValidationError[]) => { - const message = errors.map((error: ValidationError) => Object.values(error.constraints)).join(', '); - next(new HttpException(400, message)); - }); - }; -}; diff --git a/lib/default/src/models/users.model.ts b/lib/default/src/models/users.model.ts deleted file mode 100644 index 7f22a20..0000000 --- a/lib/default/src/models/users.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { User } from '@interfaces/users.interface'; - -// password: password123456789 -export const UserModel: User[] = [ - { id: 1, email: 'example1@email.com', password: '$2b$10$2YC2ht8x06Yto5VAr08kben8.oxjTPrMn0yJhv8xxSVVltH3bOs4u' }, - { id: 2, email: 'example2@email.com', password: '$2b$10$2YC2ht8x06Yto5VAr08kben8.oxjTPrMn0yJhv8xxSVVltH3bOs4u' }, - { id: 3, email: 'example3@email.com', password: '$2b$10$2YC2ht8x06Yto5VAr08kben8.oxjTPrMn0yJhv8xxSVVltH3bOs4u' }, - { id: 4, email: 'example4@email.com', password: '$2b$10$2YC2ht8x06Yto5VAr08kben8.oxjTPrMn0yJhv8xxSVVltH3bOs4u' }, -]; diff --git a/lib/default/src/routes/auth.route.ts b/lib/default/src/routes/auth.route.ts deleted file mode 100644 index 1a18f73..0000000 --- a/lib/default/src/routes/auth.route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Router } from 'express'; -import { AuthController } from '@controllers/auth.controller'; -import { CreateUserDto } from '@dtos/users.dto'; -import { Routes } from '@interfaces/routes.interface'; -import { AuthMiddleware } from '@middlewares/auth.middleware'; -import { ValidationMiddleware } from '@middlewares/validation.middleware'; - -export class AuthRoute implements Routes { - public router = Router(); - public auth = new AuthController(); - - constructor() { - this.initializeRoutes(); - } - - private initializeRoutes() { - this.router.post('/signup', ValidationMiddleware(CreateUserDto), this.auth.signUp); - this.router.post('/login', ValidationMiddleware(CreateUserDto), this.auth.logIn); - this.router.post('/logout', AuthMiddleware, this.auth.logOut); - } -} diff --git a/lib/default/src/routes/users.route.ts b/lib/default/src/routes/users.route.ts deleted file mode 100644 index ed15c24..0000000 --- a/lib/default/src/routes/users.route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Router } from 'express'; -import { UserController } from '@controllers/users.controller'; -import { CreateUserDto, UpdateUserDto } from '@dtos/users.dto'; -import { Routes } from '@interfaces/routes.interface'; -import { ValidationMiddleware } from '@middlewares/validation.middleware'; - -export class UserRoute implements Routes { - public path = '/users'; - public router = Router(); - public user = new UserController(); - - constructor() { - this.initializeRoutes(); - } - - private initializeRoutes() { - this.router.get(`${this.path}`, this.user.getUsers); - this.router.get(`${this.path}/:id(\\d+)`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); - this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(UpdateUserDto), this.user.updateUser); - this.router.delete(`${this.path}/:id(\\d+)`, this.user.deleteUser); - } -} diff --git a/lib/default/src/server.ts b/lib/default/src/server.ts deleted file mode 100644 index 2362805..0000000 --- a/lib/default/src/server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { App } from '@/app'; -import { AuthRoute } from '@routes/auth.route'; -import { UserRoute } from '@routes/users.route'; -import { ValidateEnv } from '@utils/validateEnv'; - -ValidateEnv(); - -const app = new App([new UserRoute(), new AuthRoute()]); - -app.listen(); diff --git a/lib/default/src/services/auth.service.ts b/lib/default/src/services/auth.service.ts deleted file mode 100644 index 9710d5e..0000000 --- a/lib/default/src/services/auth.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { hash, compare } from 'bcrypt'; -import { sign } from 'jsonwebtoken'; -import { Service } from 'typedi'; -import { SECRET_KEY } from '@config'; -import { HttpException } from '@exceptions/httpException'; -import { DataStoredInToken, TokenData } from '@interfaces/auth.interface'; -import { User } from '@interfaces/users.interface'; -import { UserModel } from '@models/users.model'; - -const createToken = (user: User): TokenData => { - const dataStoredInToken: DataStoredInToken = { id: user.id }; - const expiresIn: number = 60 * 60; - - return { expiresIn, token: sign(dataStoredInToken, SECRET_KEY, { expiresIn }) }; -}; - -const createCookie = (tokenData: TokenData): string => { - return `Authorization=${tokenData.token}; HttpOnly; Max-Age=${tokenData.expiresIn};`; -}; - -@Service() -export class AuthService { - public async signup(userData: User): Promise { - const findUser: User = UserModel.find(user => user.email === userData.email); - if (findUser) throw new HttpException(409, `This email ${userData.email} already exists`); - - const hashedPassword = await hash(userData.password, 10); - const createUserData: User = { ...userData, id: UserModel.length + 1, password: hashedPassword }; - - return createUserData; - } - - public async login(userData: User): Promise<{ cookie: string; findUser: User }> { - const findUser: User = UserModel.find(user => user.email === userData.email); - if (!findUser) throw new HttpException(409, `This email ${userData.email} was not found`); - - const isPasswordMatching: boolean = await compare(userData.password, findUser.password); - if (!isPasswordMatching) throw new HttpException(409, "You're password not matching"); - - const tokenData = createToken(findUser); - const cookie = createCookie(tokenData); - - return { cookie, findUser }; - } - - public async logout(userData: User): Promise { - const findUser: User = UserModel.find(user => user.email === userData.email && user.password === userData.password); - if (!findUser) throw new HttpException(409, "User doesn't exist"); - - return findUser; - } -} diff --git a/lib/default/src/services/users.service.ts b/lib/default/src/services/users.service.ts deleted file mode 100644 index 8d253b8..0000000 --- a/lib/default/src/services/users.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { hash } from 'bcrypt'; -import { Service } from 'typedi'; -import { HttpException } from '@exceptions/httpException'; -import { User } from '@interfaces/users.interface'; -import { UserModel } from '@models/users.model'; - -@Service() -export class UserService { - public async findAllUser(): Promise { - const users: User[] = UserModel; - return users; - } - - public async findUserById(userId: number): Promise { - const findUser: User = UserModel.find(user => user.id === userId); - if (!findUser) throw new HttpException(409, "User doesn't exist"); - - return findUser; - } - - public async createUser(userData: User): Promise { - const findUser: User = UserModel.find(user => user.email === userData.email); - if (findUser) throw new HttpException(409, `This email ${userData.email} already exists`); - - const hashedPassword = await hash(userData.password, 10); - const createUserData: User = { ...userData, id: UserModel.length + 1, password: hashedPassword }; - - return createUserData; - } - - public async updateUser(userId: number, userData: User): Promise { - const findUser: User = UserModel.find(user => user.id === userId); - if (!findUser) throw new HttpException(409, "User doesn't exist"); - - const hashedPassword = await hash(userData.password, 10); - const updateUserData: User[] = UserModel.map((user: User) => { - if (user.id === findUser.id) user = { ...userData, id: userId, password: hashedPassword }; - return user; - }); - - return updateUserData; - } - - public async deleteUser(userId: number): Promise { - const findUser: User = UserModel.find(user => user.id === userId); - if (!findUser) throw new HttpException(409, "User doesn't exist"); - - const deleteUserData: User[] = UserModel.filter(user => user.id !== findUser.id); - return deleteUserData; - } -} diff --git a/lib/default/src/test/auth.test.ts b/lib/default/src/test/auth.test.ts deleted file mode 100644 index 11ab020..0000000 --- a/lib/default/src/test/auth.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import request from 'supertest'; -import { App } from '@/app'; -import { User } from '@interfaces/users.interface'; -import { AuthRoute } from '@routes/auth.route'; - -afterAll(async () => { - await new Promise(resolve => setTimeout(() => resolve(), 500)); -}); - -describe('TEST Authorization API', () => { - const route = new AuthRoute(); - const app = new App([route]); - - describe('[POST] /signup', () => { - it('response should have the Create userData', () => { - const userData: User = { - email: 'example@email.com', - password: 'password123456789', - }; - - return request(app.getServer()).post('/signup').send(userData).expect(201); - }); - }); - - describe('[POST] /login', () => { - it('response should have the Set-Cookie header with the Authorization token', () => { - const userData: User = { - email: 'example1@email.com', - password: 'password123456789', - }; - - return request(app.getServer()) - .post('/login') - .send(userData) - .expect('Set-Cookie', /^Authorization=.+/) - .expect(200); - }); - }); - - // error: StatusCode : 404, Message : Authentication token missing - // describe('[POST] /logout', () => { - // it('logout Set-Cookie Authorization=; Max-age=0', () => { - // const route = new AuthRoute() - // const app = new App([route]); - - // return request(app.getServer()) - // .post('/logout') - // .expect('Set-Cookie', /^Authorization=\;/) - // .expect(200); - // }); - // }); -}); diff --git a/lib/default/src/test/users.test.ts b/lib/default/src/test/users.test.ts deleted file mode 100644 index 157dd83..0000000 --- a/lib/default/src/test/users.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import request from 'supertest'; -import { App } from '@/app'; -import { User } from '@interfaces/users.interface'; -import { UserModel } from '@models/users.model'; -import { UserRoute } from '@routes/users.route'; - -afterAll(async () => { - await new Promise(resolve => setTimeout(() => resolve(), 500)); -}); - -describe('TEST Users API', () => { - const route = new UserRoute(); - const app = new App([route]); - - describe('[GET] /users', () => { - it('response statusCode 200 /findAll', () => { - const findUser: User[] = UserModel; - - return request(app.getServer()).get(`${route.path}`).expect(200, { data: findUser, message: 'findAll' }); - }); - }); - - describe('[GET] /users/:id', () => { - it('response statusCode 200 /findOne', () => { - const userId = 1; - const findUser: User = UserModel.find(user => user.id === userId); - - return request(app.getServer()).get(`${route.path}/${userId}`).expect(200, { data: findUser, message: 'findOne' }); - }); - }); - - describe('[POST] /users', () => { - it('response statusCode 201 /created', async () => { - const userData: User = { - email: 'example@email.com', - password: 'password123456789', - }; - - return request(app.getServer()).post(`${route.path}`).send(userData).expect(201); - }); - }); - - describe('[PUT] /users/:id', () => { - it('response statusCode 200 /updated', async () => { - const userId = 1; - const userData: User = { - password: 'password123456789', - }; - - return request(app.getServer()).put(`${route.path}/${userId}`).send(userData).expect(200); - }); - }); - - describe('[DELETE] /users/:id', () => { - it('response statusCode 200 /deleted', () => { - const userId = 1; - const deleteUser: User[] = UserModel.filter(user => user.id !== userId); - - return request(app.getServer()).delete(`${route.path}/${userId}`).expect(200, { data: deleteUser, message: 'deleted' }); - }); - }); -}); diff --git a/lib/default/src/utils/logger.ts b/lib/default/src/utils/logger.ts deleted file mode 100644 index b8f43a9..0000000 --- a/lib/default/src/utils/logger.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { existsSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import winston from 'winston'; -import winstonDaily from 'winston-daily-rotate-file'; -import { LOG_DIR } from '@config'; - -// logs dir -const logDir: string = join(__dirname, LOG_DIR); - -if (!existsSync(logDir)) { - mkdirSync(logDir); -} - -// Define log format -const logFormat = winston.format.printf(({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`); - -/* - * Log Level - * error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6 - */ -const logger = winston.createLogger({ - format: winston.format.combine( - winston.format.timestamp({ - format: 'YYYY-MM-DD HH:mm:ss', - }), - logFormat, - ), - transports: [ - // debug log setting - new winstonDaily({ - level: 'debug', - datePattern: 'YYYY-MM-DD', - dirname: logDir + '/debug', // log file /logs/debug/*.log in save - filename: `%DATE%.log`, - maxFiles: 30, // 30 Days saved - json: false, - zippedArchive: true, - }), - // error log setting - new winstonDaily({ - level: 'error', - datePattern: 'YYYY-MM-DD', - dirname: logDir + '/error', // log file /logs/error/*.log in save - filename: `%DATE%.log`, - maxFiles: 30, // 30 Days saved - handleExceptions: true, - json: false, - zippedArchive: true, - }), - ], -}); - -logger.add( - new winston.transports.Console({ - format: winston.format.combine(winston.format.splat(), winston.format.colorize()), - }), -); - -const stream = { - write: (message: string) => { - logger.info(message.substring(0, message.lastIndexOf('\n'))); - }, -}; - -export { logger, stream }; diff --git a/lib/default/src/utils/validateEnv.ts b/lib/default/src/utils/validateEnv.ts deleted file mode 100644 index a6f1904..0000000 --- a/lib/default/src/utils/validateEnv.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { cleanEnv, port, str } from 'envalid'; - -export const ValidateEnv = () => { - cleanEnv(process.env, { - NODE_ENV: str(), - PORT: port(), - }); -}; diff --git a/lib/default/swagger.yaml b/lib/default/swagger.yaml deleted file mode 100644 index eebcb31..0000000 --- a/lib/default/swagger.yaml +++ /dev/null @@ -1,123 +0,0 @@ -tags: -- name: users - description: users API - -paths: -# [GET] users - /users: - get: - tags: - - users - summary: Find All Users - responses: - 200: - description: 'OK' - 500: - description: 'Server Error' - -# [POST] users - post: - tags: - - users - summary: Add User - parameters: - - name: body - in: body - description: user Data - required: true - schema: - $ref: '#/definitions/users' - responses: - 201: - description: 'Created' - 400: - description: 'Bad Request' - 409: - description: 'Conflict' - 500: - description: 'Server Error' - -# [GET] users/id - /users/{id}: - get: - tags: - - users - summary: Find User By Id - parameters: - - name: id - in: path - description: User Id - required: true - type: integer - responses: - 200: - description: 'OK' - 409: - description: 'Conflict' - 500: - description: 'Server Error' - -# [PUT] users/id - put: - tags: - - users - summary: Update User By Id - parameters: - - name: id - in: path - description: user Id - required: true - type: integer - - name: body - in: body - description: user Data - required: true - schema: - $ref: '#/definitions/users' - responses: - 200: - description: 'OK' - 400: - description: 'Bad Request' - 409: - description: 'Conflict' - 500: - description: 'Server Error' - -# [DELETE] users/id - delete: - tags: - - users - summary: Delete User By Id - parameters: - - name: id - in: path - description: user Id - required: true - type: integer - responses: - 200: - description: 'OK' - 409: - description: 'Conflict' - 500: - description: 'Server Error' - -# definitions -definitions: - users: - type: object - required: - - email - - password - properties: - email: - type: string - description: user Email - password: - type: string - description: user Password - -schemes: - - https - - http diff --git a/lib/mikro-orm/src/routes/auth.route.ts b/lib/mikro-orm/src/routes/auth.route.ts index 118af5e..a32969f 100644 --- a/lib/mikro-orm/src/routes/auth.route.ts +++ b/lib/mikro-orm/src/routes/auth.route.ts @@ -15,8 +15,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post(`${this.path}logout`, AuthMiddleware, this.auth.logOut); } } diff --git a/lib/mikro-orm/src/routes/users.route.ts b/lib/mikro-orm/src/routes/users.route.ts index c56a39b..957f192 100644 --- a/lib/mikro-orm/src/routes/users.route.ts +++ b/lib/mikro-orm/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id`, this.user.deleteUser); } } diff --git a/lib/mongoose/src/routes/auth.route.ts b/lib/mongoose/src/routes/auth.route.ts index 118af5e..a32969f 100644 --- a/lib/mongoose/src/routes/auth.route.ts +++ b/lib/mongoose/src/routes/auth.route.ts @@ -15,8 +15,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post(`${this.path}logout`, AuthMiddleware, this.auth.logOut); } } diff --git a/lib/mongoose/src/routes/users.route.ts b/lib/mongoose/src/routes/users.route.ts index c56a39b..957f192 100644 --- a/lib/mongoose/src/routes/users.route.ts +++ b/lib/mongoose/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id`, this.user.deleteUser); } } diff --git a/lib/prisma/src/routes/auth.route.ts b/lib/prisma/src/routes/auth.route.ts index 118af5e..a32969f 100644 --- a/lib/prisma/src/routes/auth.route.ts +++ b/lib/prisma/src/routes/auth.route.ts @@ -15,8 +15,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post(`${this.path}logout`, AuthMiddleware, this.auth.logOut); } } diff --git a/lib/prisma/src/routes/users.route.ts b/lib/prisma/src/routes/users.route.ts index 8f097f5..b750b9f 100644 --- a/lib/prisma/src/routes/users.route.ts +++ b/lib/prisma/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id(\\d+)`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id(\\d+)`, this.user.deleteUser); } } diff --git a/lib/routing-controllers/package.json b/lib/routing-controllers/package.json index cbdb85d..b066800 100644 --- a/lib/routing-controllers/package.json +++ b/lib/routing-controllers/package.json @@ -10,7 +10,7 @@ "build": "swc src -d dist --source-maps --copy-files", "build:tsc": "tsc && tsc-alias", "test": "jest --forceExit --detectOpenHandles", - "lint": "eslint --ignore-path .gitignore --ext .ts src/", + "lint": "eslint --ignore-path .gitignore --ext .ts src", "lint:fix": "npm run lint -- --fix", "deploy:prod": "npm run build && pm2 start ecosystem.config.js --only prod", "deploy:dev": "pm2 start ecosystem.config.js --only dev" diff --git a/lib/routing-controllers/src/controllers/auth.controller.ts b/lib/routing-controllers/src/controllers/auth.controller.ts index bd4133e..7bac911 100644 --- a/lib/routing-controllers/src/controllers/auth.controller.ts +++ b/lib/routing-controllers/src/controllers/auth.controller.ts @@ -13,7 +13,7 @@ export class AuthController { public auth = Container.get(AuthService); @Post('/signup') - @UseBefore(ValidationMiddleware(CreateUserDto, 'body')) + @UseBefore(ValidationMiddleware(CreateUserDto)) @HttpCode(201) async signUp(@Body() userData: User) { const signUpUserData: User = await this.auth.signup(userData); @@ -21,7 +21,7 @@ export class AuthController { } @Post('/login') - @UseBefore(ValidationMiddleware(CreateUserDto, 'body')) + @UseBefore(ValidationMiddleware(CreateUserDto)) async logIn(@Res() res: Response, @Body() userData: User) { const { cookie, findUser } = await this.auth.login(userData); diff --git a/lib/routing-controllers/src/controllers/users.controller.ts b/lib/routing-controllers/src/controllers/users.controller.ts index 4a9ce7a..8a7084f 100644 --- a/lib/routing-controllers/src/controllers/users.controller.ts +++ b/lib/routing-controllers/src/controllers/users.controller.ts @@ -27,7 +27,7 @@ export class UserController { @Post('/users') @HttpCode(201) - @UseBefore(ValidationMiddleware(CreateUserDto, 'body')) + @UseBefore(ValidationMiddleware(CreateUserDto)) @OpenAPI({ summary: 'Create a new user' }) async createUser(@Body() userData: User) { const createUserData: User = await this.user.createUser(userData); @@ -35,7 +35,7 @@ export class UserController { } @Put('/users/:id') - @UseBefore(ValidationMiddleware(CreateUserDto, 'body', true)) + @UseBefore(ValidationMiddleware(CreateUserDto, true)) @OpenAPI({ summary: 'Update a user' }) async updateUser(@Param('id') userId: number, @Body() userData: User) { const updateUserData: User[] = await this.user.updateUser(userId, userData); diff --git a/lib/sequelize/src/routes/auth.route.ts b/lib/sequelize/src/routes/auth.route.ts index 1025094..1a18f73 100644 --- a/lib/sequelize/src/routes/auth.route.ts +++ b/lib/sequelize/src/routes/auth.route.ts @@ -14,8 +14,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post('/signup', ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post('/login', ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post('/signup', ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post('/login', ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post('/logout', AuthMiddleware, this.auth.logOut); } } diff --git a/lib/sequelize/src/routes/users.route.ts b/lib/sequelize/src/routes/users.route.ts index 8f097f5..b750b9f 100644 --- a/lib/sequelize/src/routes/users.route.ts +++ b/lib/sequelize/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id(\\d+)`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id(\\d+)`, this.user.deleteUser); } } diff --git a/lib/starter.js b/lib/starter.js deleted file mode 100644 index 16bec17..0000000 --- a/lib/starter.js +++ /dev/null @@ -1,262 +0,0 @@ -/***************************************************************** - * Create TypeScript Express Starter - * 2019.12.18 ~ 🎮 - * Made By AGUMON 🦖 - * https://github.com/ljlm0402/typescript-express-starter - *****************************************************************/ - -const chalk = require("chalk"); -const { exec } = require("child_process"); -const editJsonFile = require("edit-json-file"); -const { createWriteStream, readdir } = require("fs"); -const { writeFile } = require("gitignore"); -const inquirer = require("inquirer"); -const { ncp } = require("ncp"); -const ora = require("ora"); -const path = require("path"); -const { promisify } = require("util"); - -const readDir = promisify(readdir); -const asyncExec = promisify(exec); -const writeGitignore = promisify(writeFile); - -/** - * @method createProject - * @description Create a project - */ -const createProject = async (projectName) => { - let spinner; - - try { - const template = await chooseTemplates(); - // const isUpdated = await dependenciesUpdates(); - // const isDeduped = await dependenciesDeduped(); - - console.log("[ 1 / 3 ] 🔍 copying project..."); - console.log("[ 2 / 3 ] 🚚 fetching node_modules..."); - - await copyProjectFiles(projectName, template); - await updatePackageJson(projectName); - - console.log("[ 3 / 3 ] 🔗 linking node_modules..."); - console.log( - "\u001b[2m──────────────────────────────────────────\u001b[22m" - ); - - spinner = ora(); - spinner.start(); - - await installNodeModules(projectName, spinner); - // isUpdated && (await updateNodeModules(projectName, spinner)); - // isDeduped && (await dedupeNodeModules(projectName, spinner)); - await postInstallScripts(projectName, template, spinner); - - await createGitignore(projectName, spinner); - await initGit(projectName); - - await succeedConsole(template, spinner); - } catch (error) { - await failConsole(error, spinner); - } -}; - -/** - * @method getDirectories - * @description Get the templates directory. - */ -const getTemplateDir = async () => { - const contents = await readDir(__dirname, { withFileTypes: true }); - const directories = contents - .filter((p) => p.isDirectory()) - .map((p) => p.name); - - return directories; -}; - -/** - * @method chooseTemplates - * @description Choose a template. - */ -const chooseTemplates = async () => { - const directories = await getTemplateDir(); - const { chooseTemplates } = await inquirer.prompt([ - { - type: "list", - name: "chooseTemplates", - message: "Select a template", - choices: [...directories, new inquirer.Separator()], - }, - ]); - - return chooseTemplates; -}; - -/** - * @method dependenciesUpdates - * @description npm dependencies updated. - */ -const dependenciesUpdates = async () => { - const { isUpdated } = await inquirer.prompt([ - { - type: "confirm", - name: "isUpdated", - message: "Update the package dependencies to their latest versions ?", - }, - ]); - - if (isUpdated) { - const { isUpdatedReconfirm } = await inquirer.prompt([ - { - type: "confirm", - name: "isUpdatedReconfirm", - message: - "The updated dependencies may contain breaking changes. Continue to update the dependencies anyway ?", - }, - ]); - - return isUpdatedReconfirm; - } - - return false; -}; - -/** - * @method dependenciesDeduped - * @description npm duplicate dependencies removed. - */ -const dependenciesDeduped = async () => { - const { isDeduped } = await inquirer.prompt([ - { - type: "confirm", - name: "isDeduped", - message: "Deduplicate the package dependency tree (recommended) ?", - }, - ]); - - return isDeduped; -}; - -/** - * @method copyProjectFiles - * @description Duplicate the template. - */ -const copyProjectFiles = async (destination, directory) => { - return new Promise((resolve, reject) => { - const source = path.join(__dirname, `./${directory}`); - const options = { - clobber: true, - stopOnErr: true, - }; - - ncp.limit = 16; - ncp(source, destination, options, function (err) { - if (err) reject(err); - resolve(); - }); - }); -}; - -/** - * @method updatePackageJson - * @description Edit package.json. - */ -const updatePackageJson = async (destination) => { - const file = editJsonFile(`${destination}/package.json`, { autosave: true }); - - file.set("name", path.basename(destination)); -}; - -/** - * @method installNodeModules - * @description Install node modules. - */ -const installNodeModules = async (destination, spinner) => { - spinner.text = "Install node_modules...\n"; - await asyncExec("npm install --legacy-peer-deps", { cwd: destination }); -}; - -/** - * @method updateNodeModules - * @description Update node modules. - */ -const updateNodeModules = async (destination, spinner) => { - spinner.text = "Update node_modules...\n"; - await asyncExec("npm update --legacy-peer-deps", { cwd: destination }); -}; - -/** - * @method dedupeNodeModules - * @description Dedupe node modules. - */ -const dedupeNodeModules = async (destination, spinner) => { - spinner.text = "Dedupe node_modules...\n"; - await asyncExec("npm dedupe --legacy-peer-deps", { cwd: destination }); -}; - -/** - * @method postInstallScripts - * @description After installation, configure the settings according to the template. - */ -const postInstallScripts = async (destination, template, spinner) => { - switch (template) { - case "prisma": - { - spinner.text = "Run prisma generate..."; - await asyncExec("npm run prisma:generate", { cwd: destination }); - } - break; - } -}; - -/** - * @method createGitignore - * @description Create a .gitignore. - */ -const createGitignore = async (destination, spinner) => { - spinner.text = "Create .gitignore..."; - - const file = createWriteStream(path.join(destination, ".gitignore"), { - flags: "a", - }); - - return writeGitignore({ - type: "Node", - file: file, - }); -}; - -/** - * @method initGit - * @description Initialize git settings. - */ -const initGit = async (destination) => { - await asyncExec("git init", { cwd: destination }); -}; - -/** - * @method succeedConsole - * @description When the project is successful, the console is displayed. - */ -const succeedConsole = async (template, spinner) => { - spinner.succeed(chalk`{green Complete setup project}`); - - const msg = - { - prisma: - "⛰ Prisma installed. Check your .env settings and then run `npm run prisma:migrate`", - knex: "⛰ Knex installed. Check your .env settings and then run `npm run migrate`", - }[template] || ""; - - msg && console.log(msg); -}; - -/** - * @method failConsole - * @description When the project is fail, the console is displayed. - */ -const failConsole = async (error, spinner) => { - spinner.fail(chalk`{red Please leave this error as an issue}`); - console.error(error); -}; - -module.exports = createProject; diff --git a/lib/typegoose/src/routes/auth.route.ts b/lib/typegoose/src/routes/auth.route.ts index 1025094..1a18f73 100644 --- a/lib/typegoose/src/routes/auth.route.ts +++ b/lib/typegoose/src/routes/auth.route.ts @@ -14,8 +14,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post('/signup', ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post('/login', ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post('/signup', ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post('/login', ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post('/logout', AuthMiddleware, this.auth.logOut); } } diff --git a/lib/typegoose/src/routes/users.route.ts b/lib/typegoose/src/routes/users.route.ts index c56a39b..957f192 100644 --- a/lib/typegoose/src/routes/users.route.ts +++ b/lib/typegoose/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id`, this.user.deleteUser); } } diff --git a/lib/typeorm/src/routes/auth.route.ts b/lib/typeorm/src/routes/auth.route.ts index 1025094..1a18f73 100644 --- a/lib/typeorm/src/routes/auth.route.ts +++ b/lib/typeorm/src/routes/auth.route.ts @@ -14,8 +14,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post('/signup', ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post('/login', ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post('/signup', ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post('/login', ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post('/logout', AuthMiddleware, this.auth.logOut); } } diff --git a/lib/typeorm/src/routes/users.route.ts b/lib/typeorm/src/routes/users.route.ts index 8f097f5..b750b9f 100644 --- a/lib/typeorm/src/routes/users.route.ts +++ b/lib/typeorm/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id(\\d+)`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id(\\d+)`, this.user.deleteUser); } } diff --git a/package.json b/package.json index b863807..372aa42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typescript-express-starter", - "version": "10.1.1", + "version": "10.2.2", "description": "Quick and Easy TypeScript Express Starter", "author": "AGUMON ", "license": "MIT", @@ -29,27 +29,38 @@ "nginx", "swc" ], - "main": "bin/cli.js", + "type": "module", + "main": "bin/starter.js", "bin": { - "typescript-express-starter": "bin/cli.js" + "typescript-express-starter": "bin/starter.js" }, "scripts": { - "start": "node bin/cli.js" + "start": "node bin/starter.js" }, "dependencies": { + "@clack/prompts": "^0.11.0", "chalk": "^4.1.2", - "edit-json-file": "^1.5.0", + "cli-progress": "^3.12.0", + "edit-json-file": "^1.8.1", + "execa": "^9.6.0", + "fs-extra": "^11.3.0", "gitignore": "^0.6.0", "inquirer": "^6.5.2", "ncp": "^2.0.0", - "ora": "^4.0.3" + "ora": "^4.1.1", + "recast": "^0.23.11" + }, + "devDependencies": { + "@babel/parser": "^7.28.0", + "eslint": "^9.30.1", + "prettier": "^3.6.2" }, "publishConfig": { "access": "public" }, "engines": { - "node": ">= 10.13.0", - "npm": ">= 6.11.0" + "node": ">= 16.13.0", + "npm": ">= 9.11.0" }, "repository": { "type": "git", diff --git a/templates/default/.env b/templates/default/.env new file mode 100644 index 0000000..15c50ce --- /dev/null +++ b/templates/default/.env @@ -0,0 +1,21 @@ +# APP +PORT=3000 + +# JWT / Token +SECRET_KEY=yourSuperSecretKey + +# LOGGING +LOG_DIR=../logs +LOG_LEVEL=info + +# CORS 기본값 +ORIGIN=http://localhost:3000 +CREDENTIALS=true +CORS_ORIGINS=http://localhost:3000,https://yourdomain.com + +# SERVER URL +API_SERVER_URL=https://api.yourdomain.com + +# 선택적 환경변수 +SENTRY_DSN= +REDIS_URL=redis://localhost:6379 \ No newline at end of file diff --git a/templates/default/.env.development.local b/templates/default/.env.development.local new file mode 100644 index 0000000..505fd79 --- /dev/null +++ b/templates/default/.env.development.local @@ -0,0 +1,5 @@ +# APP +NODE_ENV=development + +# LOGGING +LOG_FORMAT=dev diff --git a/templates/default/.env.production.local b/templates/default/.env.production.local new file mode 100644 index 0000000..11ad836 --- /dev/null +++ b/templates/default/.env.production.local @@ -0,0 +1,5 @@ +# APP +NODE_ENV=production + +# LOGGING +LOG_FORMAT=combined diff --git a/templates/default/.env.test.local b/templates/default/.env.test.local new file mode 100644 index 0000000..2a51176 --- /dev/null +++ b/templates/default/.env.test.local @@ -0,0 +1,5 @@ +# APP +NODE_ENV=test + +# LOGGING +LOG_FORMAT=dev diff --git a/templates/default/nodemon.json b/templates/default/nodemon.json new file mode 100644 index 0000000..1fb5df9 --- /dev/null +++ b/templates/default/nodemon.json @@ -0,0 +1,7 @@ +{ + "watch": ["src", ".env"], + "ext": "js,ts,json", + "ignore": ["logs/*", "**/*.spec.ts", "**/*.test.ts"], + "exec": "ts-node -r tsconfig-paths/register --transpile-only src/server.ts", + "delay": "500" +} diff --git a/templates/default/package.json b/templates/default/package.json new file mode 100644 index 0000000..912abde --- /dev/null +++ b/templates/default/package.json @@ -0,0 +1,52 @@ +{ + "name": "default", + "version": "1.0.0", + "description": "default template", + "author": "", + "license": "ISC", + "scripts": { + "start": "cross-env NODE_ENV=production node dist/server.js", + "dev": "cross-env NODE_ENV=development nodemon", + "build": "tsc && tsc-alias" + }, + "dependencies": { + "bcryptjs": "^3.0.2", + "compression": "^1.8.1", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^17.2.1", + "express": "^5.1.0", + "express-rate-limit": "^8.0.1", + "helmet": "^8.1.0", + "hpp": "^0.2.3", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.1", + "pino": "^9.9.0", + "pino-pretty": "^13.1.1", + "reflect-metadata": "^0.2.2", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0", + "zod": "^4.1.3" + }, + "devDependencies": { + "@types/compression": "^1.8.1", + "@types/cookie-parser": "^1.4.9", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/hpp": "^0.2.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", + "@types/node": "^24.3.0", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", + "cross-env": "^10.0.0", + "node-gyp": "^11.4.2", + "nodemon": "^3.1.10", + "ts-node": "^10.9.2", + "tsc-alias": "^1.8.16", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.9.2" + } +} diff --git a/templates/default/src/app.ts b/templates/default/src/app.ts new file mode 100644 index 0000000..761d72b --- /dev/null +++ b/templates/default/src/app.ts @@ -0,0 +1,155 @@ +import compression from 'compression'; +import cookieParser from 'cookie-parser'; +import cors from 'cors'; +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import helmet from 'helmet'; +import hpp from 'hpp'; +import morgan from 'morgan'; +import swaggerJSDoc from 'swagger-jsdoc'; +import swaggerUi from 'swagger-ui-express'; +import { + NODE_ENV, PORT, LOG_FORMAT, CREDENTIALS, + CORS_ORIGIN_LIST, API_SERVER_URL, +} from '@config/env'; +import { Routes } from '@interfaces/routes.interface'; +import { ErrorMiddleware } from '@middlewares/error.middleware'; +import { logger, stream } from '@utils/logger'; + +class App { + public app: express.Application; + public env: string; + public port: string | number; + + constructor(routes: Routes[], apiPrefix = '/api/v1') { + this.app = express(); + this.env = NODE_ENV || 'development'; + this.port = PORT || 3000; + + this.initializeTrustProxy(); + this.initializeMiddlewares(); + this.initializeRoutes(routes, apiPrefix); + this.initializeSwagger(apiPrefix); + this.initializeErrorHandling(); + } + + public listen() { + const server = this.app.listen(this.port, () => { + logger.info(`=================================`); + logger.info(`======= ENV: ${this.env} =======`); + logger.info(`🚀 App listening on the port ${this.port}`); + logger.info(`=================================`); + }); + + return server; + } + + public getServer() { + return this.app; + } + + private initializeTrustProxy() { + // Nginx, Heroku, Cloudflare 등 프록시 환경에서 실IP 추출을 위해 필요 + this.app.set('trust proxy', 1); + } + + private initializeMiddlewares() { + this.app.use( + rateLimit({ + windowMs: 60 * 1000, // 1분 + max: this.env === 'production' ? 100 : 0, // 개발환경에서는 제한 없음 + message: { error: 'Too many requests, please try again later.' }, + keyGenerator: req => req.ip || '', // 신뢰 프록시 하에서 실IP, undefined 방지 + skip: req => this.env !== 'production' || req.ip === '127.0.0.1', + legacyHeaders: false, + standardHeaders: true, + }), + ); + + this.app.use(morgan(LOG_FORMAT || 'dev', { stream })); + + // CORS 화이트리스트를 환경변수에서 관리 + const allowedOrigins = CORS_ORIGIN_LIST.length > 0 + ? CORS_ORIGIN_LIST + : ['http://localhost:3000']; + + this.app.use( + cors({ + origin: (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: CREDENTIALS, + }), + ); + + this.app.use(hpp()); + this.app.use( + helmet({ + contentSecurityPolicy: + this.env === 'production' + ? { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + objectSrc: ["'none'"], + upgradeInsecureRequests: [], + }, + } + : false, // 개발 환경에서는 CSP 비활성화 (hot reload 등 편의) + referrerPolicy: { policy: 'no-referrer' }, + }), + ); + this.app.use(compression()); + this.app.use(express.json({ limit: '10mb' })); + this.app.use(express.urlencoded({ extended: true, limit: '10mb' })); + this.app.use(cookieParser()); + } + + private initializeRoutes(routes: Routes[], apiPrefix: string) { + routes.forEach(route => { + this.app.use(apiPrefix, route.router); + }); + } + + private initializeSwagger(apiPrefix: string) { + const options = { + swaggerDefinition: { + openapi: '3.0.0', + info: { + title: 'REST API', + version: '1.0.0', + description: 'Example API Documentation', + }, + servers: [ + { + url: API_SERVER_URL || `http://localhost:${this.port}${apiPrefix}`, + description: this.env === 'production' ? 'Production server' : 'Local server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + }, + apis: ['swagger.yaml', 'src/controllers/*.ts'], + }; + + const specs = swaggerJSDoc(options); + this.app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs)); + } + + private initializeErrorHandling() { + this.app.use(ErrorMiddleware); + } +} + +export default App; diff --git a/templates/default/src/config/env.ts b/templates/default/src/config/env.ts new file mode 100644 index 0000000..bb8b718 --- /dev/null +++ b/templates/default/src/config/env.ts @@ -0,0 +1,76 @@ +import { config } from 'dotenv'; +import { existsSync } from 'fs'; +import { resolve } from 'path'; +import { z } from 'zod'; + +/** + * 1) dotenv 로드 순서 + * - .env (공통) + * - .env.{NODE_ENV}.local (환경별 override, 있으면 덮어씀) + */ +config(); // .env +const nodeEnv = process.env.NODE_ENV || 'development'; +const layerPath = resolve(process.cwd(), `.env.${nodeEnv}.local`); +if (existsSync(layerPath)) { + config({ path: layerPath }); +} + +/** + * 2) Zod 스키마 정의 + * - 필수/선택/기본값 정책은 필요에 맞게 수정 가능 + */ +const EnvSchema = z + .object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().optional(), // 기본값은 app.ts에서 3000 처리 + + SECRET_KEY: z.string().min(1), + + LOG_FORMAT: z.string().min(1).optional(), // 기본값은 app.ts에서 'dev' + LOG_DIR: z.string().min(1), + LOG_LEVEL: z.string().min(1), + + ORIGIN: z.string().min(1), // 필요시 배열화 가능 + CREDENTIALS: z.coerce.boolean(), // 'true'/'false' 문자열 → boolean + CORS_ORIGINS: z.string().optional(), // "http://a.com,http://b.com" + + API_SERVER_URL: z.string().url().optional(), + + SENTRY_DSN: z.string().default(''), + REDIS_URL: z.string().url().default('redis://localhost:6379'), + }) + .strip(); + +/** + * 3) 검증(모듈 import 시점에 실행) + */ +const parsed = EnvSchema.safeParse(process.env); +if (!parsed.success) { + console.error('\n❌ Invalid environment variables:\n'); + console.error(parsed.error.format()); + process.exit(1); +} +const env = parsed.data; + +/** + * 4) 타입 안전한 상수 export + * - 다른 파일에서는 process.env 직접 쓰지 말고 여기서만 가져가세요. + */ +export const NODE_ENV = env.NODE_ENV; +export const PORT = env.PORT; // app.ts에서 PORT || 3000 +export const SECRET_KEY = env.SECRET_KEY; + +export const LOG_FORMAT = env.LOG_FORMAT; // app.ts에서 LOG_FORMAT || 'dev' +export const LOG_DIR = env.LOG_DIR; +export const LOG_LEVEL= env.LOG_LEVEL; + +export const ORIGIN = env.ORIGIN; +export const CREDENTIALS = env.CREDENTIALS; + +export const SENTRY_DSN = env.SENTRY_DSN; +export const REDIS_URL = env.REDIS_URL; +export const API_SERVER_URL = env.API_SERVER_URL; + +// CORS Origins를 배열로도 제공 (없으면 []) +export const CORS_ORIGIN_LIST = + (env.CORS_ORIGINS?.split(',').map(s => s.trim()).filter(Boolean)) ?? []; diff --git a/templates/default/src/controllers/auth.controller.ts b/templates/default/src/controllers/auth.controller.ts new file mode 100644 index 0000000..4a533cc --- /dev/null +++ b/templates/default/src/controllers/auth.controller.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express'; +import { injectable, inject } from 'tsyringe'; +import { RequestWithUser } from '@interfaces/auth.interface'; +import { User } from '@interfaces/users.interface'; +import { AuthService } from '@services/auth.service'; + +@injectable() +export class AuthController { + constructor(@inject(AuthService) private readonly authService: AuthService) {} + + public signUp = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userData: User = req.body; + const signUpUserData = await this.authService.signup(userData); + res.status(201).json({ data: signUpUserData, message: 'signup' }); + } catch (error) { + next(error); + } + }; + + public logIn = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const loginData: User = req.body; + const { cookie, user } = await this.authService.login(loginData); + + res.setHeader('Set-Cookie', [cookie]); + res.status(200).json({ data: user, message: 'login' }); + } catch (error) { + next(error); + } + }; + + public logOut = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userReq = req as RequestWithUser; + const user = userReq.user; + await this.authService.logout(user); + + res.clearCookie('Authorization', { + httpOnly: true, + path: '/', + sameSite: 'lax', + // secure: true, // 프로덕션에서 HTTPS일 때만 + }); + res.status(200).json({ message: 'logout' }); + } catch (error) { + next(error); + } + }; +} diff --git a/templates/default/src/controllers/users.controller.ts b/templates/default/src/controllers/users.controller.ts new file mode 100644 index 0000000..929a4d9 --- /dev/null +++ b/templates/default/src/controllers/users.controller.ts @@ -0,0 +1,59 @@ +import { Request, Response, NextFunction } from 'express'; +import { injectable, inject } from 'tsyringe'; +import { User } from '@interfaces/users.interface'; +import { UsersService } from '@services/users.service'; + +@injectable() +export class UsersController { + constructor(@inject(UsersService) private readonly userService: UsersService) {} + + getUsers = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const users = await this.userService.getAllUsers(); + res.json({ data: users, message: 'findAll' }); + } catch (e) { + next(e); + } + }; + + getUserById = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userId: string = req.params.id; + const user = await this.userService.getUserById(userId); + res.json({ data: user, message: 'findById' }); + } catch (e) { + next(e); + } + }; + + createUser = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userData: User = req.body; + const user = await this.userService.createUser(userData); + res.status(201).json({ data: user, message: 'create' }); + } catch (e) { + next(e); + } + }; + + updateUser = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userId: string = req.params.id; + const userData: User = req.body; + const user = await this.userService.updateUser(userId, userData); + res.json({ data: user, message: 'update' }); + } catch (e) { + next(e); + } + }; + + deleteUser = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userId: string = req.params.id; + await this.userService.deleteUser(userId); + res.status(204).json({ message: 'delete' }); + } catch (e) { + next(e); + } + }; +} diff --git a/templates/default/src/dtos/users.dto.ts b/templates/default/src/dtos/users.dto.ts new file mode 100644 index 0000000..3ad96b5 --- /dev/null +++ b/templates/default/src/dtos/users.dto.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +// 비밀번호 공통 스키마 +export const passwordSchema = z + .string() + .min(9, { message: 'Password must be at least 9 characters long.' }) + .max(32, { message: 'Password must be at most 32 characters long.' }); + +// 회원가입 DTO (signup, login 공용) +export const createUserSchema = z.object({ + email: z.string().email({ message: 'Invalid email format.' }), + password: passwordSchema, +}); + +export type CreateUserDto = z.infer; + +// 수정 DTO (패스워드만 optional) +export const updateUserSchema = createUserSchema.partial(); + +export type UpdateUserDto = z.infer; diff --git a/templates/default/src/exceptions/httpException.ts b/templates/default/src/exceptions/httpException.ts new file mode 100644 index 0000000..ac7b567 --- /dev/null +++ b/templates/default/src/exceptions/httpException.ts @@ -0,0 +1,14 @@ +export class HttpException extends Error { + public status: number; + public message: string; + public data?: unknown; + + constructor(status: number, message: string, data?: unknown) { + super(message); + this.status = status; + this.message = message; + this.data = data; + this.name = this.constructor.name; + Error.captureStackTrace?.(this, this.constructor); + } +} diff --git a/lib/default/src/interfaces/auth.interface.ts b/templates/default/src/interfaces/auth.interface.ts similarity index 92% rename from lib/default/src/interfaces/auth.interface.ts rename to templates/default/src/interfaces/auth.interface.ts index 83947b8..76f16bd 100644 --- a/lib/default/src/interfaces/auth.interface.ts +++ b/templates/default/src/interfaces/auth.interface.ts @@ -2,7 +2,7 @@ import { Request } from 'express'; import { User } from '@interfaces/users.interface'; export interface DataStoredInToken { - id: number; + id: number | string; } export interface TokenData { diff --git a/lib/default/src/interfaces/routes.interface.ts b/templates/default/src/interfaces/routes.interface.ts similarity index 100% rename from lib/default/src/interfaces/routes.interface.ts rename to templates/default/src/interfaces/routes.interface.ts diff --git a/templates/default/src/interfaces/users.interface.ts b/templates/default/src/interfaces/users.interface.ts new file mode 100644 index 0000000..82761d9 --- /dev/null +++ b/templates/default/src/interfaces/users.interface.ts @@ -0,0 +1,5 @@ +export interface User { + id?: string | number; // uuid or number-string + email: string; + password: string; +} diff --git a/templates/default/src/middlewares/auth.middleware.ts b/templates/default/src/middlewares/auth.middleware.ts new file mode 100644 index 0000000..46789d2 --- /dev/null +++ b/templates/default/src/middlewares/auth.middleware.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction } from 'express'; +import { verify, TokenExpiredError, JsonWebTokenError } from 'jsonwebtoken'; +import { container } from 'tsyringe'; +import { SECRET_KEY } from '@config/env'; +import { HttpException } from '@exceptions/httpException'; +import { DataStoredInToken, RequestWithUser } from '@interfaces/auth.interface'; +import { UsersRepository } from '@repositories/users.repository'; + +const getAuthorization = (req: RequestWithUser) => { + const cookie = req.cookies['Authorization']; + if (cookie) return cookie; + + const header = req.header('Authorization'); + if (header && header.startsWith('Bearer ')) { + return header.replace('Bearer ', '').trim(); + } + return null; +}; + +export const AuthMiddleware = async (req: Request, res: Response, next: NextFunction) => { + try { + const userReq = req as RequestWithUser; + const token = getAuthorization(userReq); + if (!token) return next(new HttpException(401, 'Authentication token missing')); + + let payload: DataStoredInToken; + try { + payload = verify(token, SECRET_KEY as string) as DataStoredInToken; + } catch (err) { + if (err instanceof TokenExpiredError) { + return next(new HttpException(401, 'Authentication token expired')); + } + if (err instanceof JsonWebTokenError) { + return next(new HttpException(401, 'Invalid authentication token')); + } + return next(new HttpException(401, 'Authentication failed')); + } + + // 타입 일치 유의 (number/string) + const userRepo = container.resolve(UsersRepository); + const findUser = await userRepo.findById(String(payload.id)); + if (!findUser) return next(new HttpException(401, 'User not found with this token')); + + (req as RequestWithUser).user = findUser; + next(); + } catch { + next(new HttpException(500, 'Authentication middleware error')); + } +}; diff --git a/templates/default/src/middlewares/error.middleware.ts b/templates/default/src/middlewares/error.middleware.ts new file mode 100644 index 0000000..06307bc --- /dev/null +++ b/templates/default/src/middlewares/error.middleware.ts @@ -0,0 +1,20 @@ +import { Request, Response } from 'express'; +import { NODE_ENV } from '@config/env'; +import { HttpException } from '@exceptions/httpException'; +import { logger } from '@utils/logger'; + +export const ErrorMiddleware = (error: HttpException, req: Request, res: Response) => { + const status = error.status || 500; + const message = error.message || 'Something went wrong'; + logger.error(`[${req.method}] ${req.originalUrl} | Status: ${status} | Message: ${message}`); + + res.status(status).json({ + success: false, + error: { + code: status, + message, + ...(NODE_ENV === 'development' && error.stack ? { stack: error.stack } : {}), + ...(typeof error.data === 'object' && error.data !== null ? { data: error.data } : {}), + }, + }); +}; diff --git a/templates/default/src/middlewares/validation.middleware.ts b/templates/default/src/middlewares/validation.middleware.ts new file mode 100644 index 0000000..a508c99 --- /dev/null +++ b/templates/default/src/middlewares/validation.middleware.ts @@ -0,0 +1,15 @@ +import { NextFunction, Request, Response } from 'express'; +import type { ZodTypeAny } from 'zod'; +import { HttpException } from '@exceptions/httpException'; + +export function ValidationMiddleware(schema: ZodTypeAny) { + return (req: Request, res: Response, next: NextFunction) => { + const result = schema.safeParse(req.body); + if (!result.success) { + const message = result.error.issues.map(e => e.message).join(', '); + return next(new HttpException(400, message)); + } + req.body = result.data; + next(); + }; +} diff --git a/templates/default/src/repositories/users.repository.ts b/templates/default/src/repositories/users.repository.ts new file mode 100644 index 0000000..93699d0 --- /dev/null +++ b/templates/default/src/repositories/users.repository.ts @@ -0,0 +1,52 @@ +import { singleton } from 'tsyringe'; +import { User } from '@interfaces/users.interface'; + +export interface IUsersRepository { + findAll(): Promise; + findById(id: string): Promise; + findByEmail(email: string): Promise; + save(user: User): Promise; + update(id: string, update: User): Promise; + delete(id: string): Promise; +} + +@singleton() +export class UsersRepository implements IUsersRepository { + private users: User[] = []; + + async findAll(): Promise { + return this.users; + } + + async findById(id: string): Promise { + return this.users.find(u => u.id === id); + } + + async findByEmail(email: string): Promise { + return this.users.find(u => u.email === email); + } + + async save(user: User): Promise { + this.users.push(user); + return user; + } + + async update(id: string, user: User): Promise { + const idx = this.users.findIndex(u => u.id === id); + if (idx === -1) return undefined; + + this.users[idx] = { ...this.users[idx], password: user.password }; + return this.users[idx]; + } + + async delete(id: string): Promise { + const idx = this.users.findIndex(u => u.id === id); + if (idx === -1) return false; + this.users.splice(idx, 1); + return true; + } + + reset() { + this.users = []; + } +} diff --git a/templates/default/src/routes/auth.route.ts b/templates/default/src/routes/auth.route.ts new file mode 100644 index 0000000..53d09d6 --- /dev/null +++ b/templates/default/src/routes/auth.route.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { injectable, inject } from 'tsyringe'; +import { AuthController } from '@controllers/auth.controller'; +import { createUserSchema } from '@dtos/users.dto'; +import { Routes } from '@interfaces/routes.interface'; +import { AuthMiddleware } from '@middlewares/auth.middleware'; +import { ValidationMiddleware } from '@middlewares/validation.middleware'; + +@injectable() +export class AuthRoute implements Routes { + public router: Router = Router(); + public path = '/auth'; + + constructor(@inject(AuthController) private authController: AuthController) { + this.initializeRoutes(); + } + + private initializeRoutes() { + this.router.post(`${this.path}/signup`, ValidationMiddleware(createUserSchema), this.authController.signUp); + this.router.post(`${this.path}/login`, ValidationMiddleware(createUserSchema), this.authController.logIn); + this.router.post(`${this.path}/logout`, AuthMiddleware, this.authController.logOut); + } +} diff --git a/templates/default/src/routes/users.route.ts b/templates/default/src/routes/users.route.ts new file mode 100644 index 0000000..594001d --- /dev/null +++ b/templates/default/src/routes/users.route.ts @@ -0,0 +1,24 @@ +import { Router } from 'express'; +import { injectable, inject } from 'tsyringe'; +import { UsersController } from '@controllers/users.controller'; +import { createUserSchema, updateUserSchema } from '@dtos/users.dto'; +import { Routes } from '@interfaces/routes.interface'; +import { ValidationMiddleware } from '@middlewares/validation.middleware'; + +@injectable() +export class UsersRoute implements Routes { + public router: Router = Router(); + public path = '/users'; + + constructor(@inject(UsersController) private userController: UsersController) { + this.initializeRoutes(); + } + + private initializeRoutes() { + this.router.get(this.path, this.userController.getUsers); + this.router.get(`${this.path}/:id`, this.userController.getUserById); + this.router.post(this.path, ValidationMiddleware(createUserSchema), this.userController.createUser); + this.router.put(`${this.path}/:id`, ValidationMiddleware(updateUserSchema), this.userController.updateUser); + this.router.delete(`${this.path}/:id`, this.userController.deleteUser); + } +} diff --git a/templates/default/src/server.ts b/templates/default/src/server.ts new file mode 100644 index 0000000..05add38 --- /dev/null +++ b/templates/default/src/server.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import '@config/env'; +import { container } from 'tsyringe'; +import App from '@/app'; +import { UsersRepository } from '@repositories/users.repository'; +import { AuthRoute } from '@routes/auth.route'; +import { UsersRoute } from '@routes/users.route'; + +// DI 등록 +container.registerInstance(UsersRepository, new UsersRepository()); + +// 라우트 모듈을 필요에 따라 동적으로 배열화 가능 +const routes = [container.resolve(UsersRoute), container.resolve(AuthRoute)]; + +// API prefix는 app.ts에서 기본값 세팅, 필요하면 인자로 전달 +const appInstance = new App(routes); + +// listen()이 서버 객체(http.Server)를 반환하도록 app.ts를 살짝 수정 +const server = appInstance.listen(); // PORT를 쓰려면 이렇게 전달도 가능 + +// Graceful Shutdown: 운영환경에서 필수! +if (server && typeof server.close === 'function') { + ['SIGINT', 'SIGTERM'].forEach(signal => { + process.on(signal, () => { + console.log(`Received ${signal}, closing server...`); + server.close(() => { + console.log('HTTP server closed gracefully'); + // 필요하면 DB/Redis 등 외부 자원 해제 코드 추가 + process.exit(0); + }); + }); + }); +} + +export default server; diff --git a/templates/default/src/services/auth.service.ts b/templates/default/src/services/auth.service.ts new file mode 100644 index 0000000..8199c9a --- /dev/null +++ b/templates/default/src/services/auth.service.ts @@ -0,0 +1,63 @@ +import { hash, compare } from 'bcryptjs'; +import { sign } from 'jsonwebtoken'; +import { injectable, inject } from 'tsyringe'; +import { NODE_ENV, SECRET_KEY } from '@config/env'; +import { HttpException } from '@exceptions/httpException'; +import { DataStoredInToken, TokenData } from '@interfaces/auth.interface'; +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import type { IUsersRepository } from '@repositories/users.repository'; + +@injectable() +export class AuthService { + constructor(@inject(UsersRepository) private usersRepository: IUsersRepository) {} + + private createToken(user: User): TokenData { + if (!SECRET_KEY) throw new Error('SECRET_KEY is not defined'); + + if (user.id === undefined) { + throw new Error('User id is undefined'); + } + + const dataStoredInToken: DataStoredInToken = { id: user.id }; + const expiresIn = 60 * 60; // 1h + const token = sign(dataStoredInToken, SECRET_KEY as string, { expiresIn }); + return { expiresIn, token }; + } + + private createCookie(tokenData: TokenData): string { + return `Authorization=${tokenData.token}; HttpOnly; Max-Age=${tokenData.expiresIn}; Path=/; SameSite=Lax;${NODE_ENV === 'production' ? ' Secure;' : ''}`; + } + + public async signup(userData: User): Promise { + const findUser = await this.usersRepository.findByEmail(userData.email); + if (findUser) throw new HttpException(409, `Email is already in use`); + + const hashedPassword = await hash(userData.password, 10); + const newUser: User = { id: String(Date.now()), email: userData.email, password: hashedPassword }; + + await this.usersRepository.save(newUser); + return newUser; + } + + public async login(loginData: User): Promise<{ cookie: string; user: User }> { + const findUser = await this.usersRepository.findByEmail(loginData.email); + if (!findUser) throw new HttpException(401, `Invalid email or password.`); + + const isPasswordMatching = await compare(loginData.password, findUser.password); + if (!isPasswordMatching) throw new HttpException(401, 'Password is incorrect'); + + const tokenData = this.createToken(findUser); + const cookie = this.createCookie(tokenData); + + return { cookie, user: findUser }; + } + + public async logout(user: User): Promise { + // 로그아웃은 실제 서비스에서는 서버에서 세션/리프레시토큰을 블랙리스트 처리 등 구현 가능 + // 여기서는 클라이언트의 쿠키를 삭제하면 충분 + console.log(`User with email ${user.email} logged out.`); + + return; + } +} diff --git a/templates/default/src/services/users.service.ts b/templates/default/src/services/users.service.ts new file mode 100644 index 0000000..2f1af5f --- /dev/null +++ b/templates/default/src/services/users.service.ts @@ -0,0 +1,48 @@ +import { hash } from 'bcryptjs'; +import { injectable, inject } from 'tsyringe'; +import { HttpException } from '@exceptions/httpException'; +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import type { IUsersRepository } from '@repositories/users.repository'; + +@injectable() +export class UsersService { + constructor(@inject(UsersRepository) private usersRepository: IUsersRepository) {} + + async getAllUsers(): Promise { + return this.usersRepository.findAll(); + } + + async getUserById(id: string): Promise { + const user = await this.usersRepository.findById(id); + if (!user) throw new HttpException(404, 'User not found'); + return user; + } + + async createUser(user: User): Promise { + const exists = await this.usersRepository.findByEmail(user.email); + if (exists) throw new HttpException(409, 'Email already exists'); + + const hashedPassword = await hash(user.password, 10); + const created: User = { id: String(Date.now()), email: user.email, password: hashedPassword }; + await this.usersRepository.save(created); + return created; + } + + async updateUser(id: string, update: User): Promise { + const exists = await this.usersRepository.findById(id); + if (!exists) throw new HttpException(404, 'User not found'); + + const hashedPassword = await hash(update.password, 10); + const user = { ...update, password: hashedPassword }; + + const updated = await this.usersRepository.update(id, user); + if (!updated) throw new HttpException(404, 'User not found'); + return updated; + } + + async deleteUser(id: string): Promise { + const deleted = await this.usersRepository.delete(id); + if (!deleted) throw new HttpException(404, 'User not found'); + } +} diff --git a/templates/default/src/utils/logger.ts b/templates/default/src/utils/logger.ts new file mode 100644 index 0000000..e37fc92 --- /dev/null +++ b/templates/default/src/utils/logger.ts @@ -0,0 +1,57 @@ +import { existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import pino from 'pino'; +import { LOG_DIR, LOG_LEVEL, NODE_ENV } from '@config/env'; + +const logDir: string = join(__dirname, LOG_DIR || '/logs'); +if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }); +} + +// 파일 로깅용 경로 +const debugLogPath = join(logDir, 'debug.log'); +const errorLogPath = join(logDir, 'error.log'); + +// 로그 레벨 및 환경 설정 +const isProd = NODE_ENV === 'production'; +const logLevel = LOG_LEVEL || 'info'; + +// Pino 인스턴스 +export const logger = pino( + { + level: logLevel, + formatters: { + level: label => ({ level: label }), + }, + transport: !isProd + ? { + // 개발환경: 예쁜 콘솔 출력 + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'yyyy-mm-dd HH:MM:ss', + ignore: 'pid,hostname', + }, + } + : undefined, + }, + isProd ? pino.destination(debugLogPath) : undefined, +); + +// 파일 로깅은 에러만 별도 핸들러로 예시 +export const errorLogger = isProd ? pino(pino.destination(errorLogPath)) : logger; + +// morgan stream 인터페이스 +export const stream = { + write: (msg: string) => logger.info(msg.trim()), +}; + +// 전역 에러 핸들링 (필요하면) +process.on('uncaughtException', err => { + logger.error(`Uncaught Exception: ${err.message}`); + process.exit(1); +}); +process.on('unhandledRejection', reason => { + logger.error(`Unhandled Rejection: ${JSON.stringify(reason)}`); + process.exit(1); +}); diff --git a/templates/default/swagger.yaml b/templates/default/swagger.yaml new file mode 100644 index 0000000..2f6bb18 --- /dev/null +++ b/templates/default/swagger.yaml @@ -0,0 +1,124 @@ +openapi: 3.0.0 +info: + title: Express Typescript API + description: User API Docs + version: "1.0.0" +schemes: + - https + - http +tags: + - name: users + description: users API +paths: + /users: + get: + tags: [users] + summary: Find All Users + responses: + 200: + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + 500: + description: Server Error + post: + tags: [users] + summary: Add User + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 201: + description: Created + 400: + description: Bad Request + 409: + description: Conflict + 500: + description: Server Error + /users/{id}: + get: + tags: [users] + summary: Find User By Id + parameters: + - name: id + in: path + description: User Id + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + 404: + description: Not Found + 500: + description: Server Error + put: + tags: [users] + summary: Update User By Id + parameters: + - name: id + in: path + description: user Id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 200: + description: OK + 400: + description: Bad Request + 409: + description: Conflict + 500: + description: Server Error + delete: + tags: [users] + summary: Delete User By Id + parameters: + - name: id + in: path + description: user Id + required: true + schema: + type: string + responses: + 204: + description: No Content + 404: + description: Not Found + 500: + description: Server Error + +components: + schemas: + User: + type: object + required: + - email + - password + properties: + id: + type: string + email: + type: string + password: + type: string diff --git a/lib/default/tsconfig.json b/templates/default/tsconfig.json similarity index 68% rename from lib/default/tsconfig.json rename to templates/default/tsconfig.json index d18ecef..2fd24be 100644 --- a/lib/default/tsconfig.json +++ b/templates/default/tsconfig.json @@ -1,39 +1,40 @@ { - "compileOnSave": false, "compilerOptions": { - "target": "es2017", - "lib": ["es2017", "esnext.asynciterable"], - "typeRoots": ["node_modules/@types"], - "allowSyntheticDefaultImports": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", + "target": "es2020", "module": "commonjs", - "pretty": true, - "sourceMap": true, - "declaration": true, + "moduleResolution": "node", "outDir": "dist", - "allowJs": true, - "noEmit": false, - "esModuleInterop": true, - "resolveJsonModule": true, - "importHelpers": true, + "declaration": true, + "sourceMap": true, + "strict": true, + "lib": ["es2020", "esnext.asynciterable"], + "baseUrl": "src", "paths": { "@/*": ["*"], - "@config": ["config"], + "@config/*": ["config/*"], "@controllers/*": ["controllers/*"], "@dtos/*": ["dtos/*"], "@exceptions/*": ["exceptions/*"], "@interfaces/*": ["interfaces/*"], "@middlewares/*": ["middlewares/*"], - "@models/*": ["models/*"], + "@repositories/*": ["repositories/*"], "@routes/*": ["routes/*"], "@services/*": ["services/*"], "@utils/*": ["utils/*"] - } + }, + + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "importHelpers": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "pretty": true, + "allowJs": false, + "noEmit": false }, - "include": ["src/**/*.ts", "src/**/*.json", ".env"], - "exclude": ["node_modules", "src/http", "src/logs"] + "include": ["src/**/*.ts", "src/**/*.json"], + "exclude": ["node_modules", "dist", "coverage", "logs", "src/http"] }