├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc.yml ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── examples └── basic │ ├── .eslintrc.yml │ ├── .gitignore │ ├── .prettierrc.yml │ ├── README.md │ ├── config │ └── development.yml │ ├── nodemon.json │ ├── package.json │ ├── src │ ├── index.ts │ ├── lib │ │ ├── config.ts │ │ └── deps.ts │ └── routes │ │ └── index.ts │ └── tsconfig.json ├── package.json ├── packages ├── common │ ├── .eslintrc.yml │ ├── README.md │ ├── nodemon.json │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── interfaces │ │ │ ├── index.ts │ │ │ ├── zephyr-handler.ts │ │ │ ├── zephyr-hooks.ts │ │ │ ├── zephyr-middleware.ts │ │ │ ├── zephyr-request.ts │ │ │ ├── zephyr-response.ts │ │ │ └── zephyr-route.ts │ └── tsconfig.json ├── config │ ├── .eslintrc.yml │ ├── README.md │ ├── nodemon.json │ ├── package.json │ ├── src │ │ ├── __mocks__ │ │ │ └── app │ │ │ │ ├── .env │ │ │ │ └── config │ │ │ │ ├── basic.yml │ │ │ │ ├── env.yml │ │ │ │ └── variables.yml │ │ ├── index.ts │ │ ├── load.spec.ts │ │ └── load.ts │ ├── tsconfig.json │ └── vite.config.ts ├── core │ ├── .eslintrc.yml │ ├── README.md │ ├── nodemon.json │ ├── package.json │ ├── src │ │ ├── __mocks__ │ │ │ └── app │ │ │ │ ├── routes │ │ │ │ ├── [id].ts │ │ │ │ ├── index.ts │ │ │ │ ├── items │ │ │ │ │ └── [itemId].ts │ │ │ │ ├── sum.ts │ │ │ │ ├── todos.ts │ │ │ │ └── v1 │ │ │ │ │ ├── [id].ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── todos.ts │ │ │ │ └── services │ │ │ │ └── calculator.ts │ │ ├── create-app.spec.ts │ │ ├── create-app.ts │ │ ├── create-router.ts │ │ ├── define-route.spec-d.ts │ │ ├── define-route.ts │ │ ├── dependency-injection.spec.ts │ │ ├── index.ts │ │ ├── lifecycle-hooks.spec.ts │ │ └── utils │ │ │ ├── common.spec.ts │ │ │ ├── common.ts │ │ │ ├── middlewares.spec.ts │ │ │ ├── middlewares.ts │ │ │ ├── routes-loader.spec.ts │ │ │ └── routes-loader.ts │ ├── tsconfig.json │ └── vite.config.ts ├── create-zephyr-app │ ├── .eslintrc.yml │ ├── README.md │ ├── package.json │ ├── src │ │ ├── gradient.ts │ │ ├── index.ts │ │ ├── lib │ │ │ └── cli-kit │ │ │ │ ├── index.ts │ │ │ │ ├── messages │ │ │ │ └── index.ts │ │ │ │ ├── project │ │ │ │ ├── index.ts │ │ │ │ └── words.ts │ │ │ │ ├── prompt │ │ │ │ ├── elements │ │ │ │ │ ├── confirm.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── multiselect.js │ │ │ │ │ ├── prompt.js │ │ │ │ │ ├── select.js │ │ │ │ │ └── text.js │ │ │ │ ├── prompt.js │ │ │ │ └── util │ │ │ │ │ ├── action.ts │ │ │ │ │ └── clear.ts │ │ │ │ ├── spinner │ │ │ │ └── index.ts │ │ │ │ └── utils │ │ │ │ └── index.ts │ │ ├── logger.ts │ │ ├── messages.ts │ │ └── templates.ts │ └── tsconfig.json ├── di │ ├── .eslintrc.yml │ ├── README.md │ ├── nodemon.json │ ├── package.json │ ├── src │ │ ├── container.spec.ts │ │ ├── container.ts │ │ ├── index.ts │ │ ├── types │ │ │ └── constructor.ts │ │ └── utils │ │ │ ├── is-function.ts │ │ │ ├── token.spec.ts │ │ │ └── token.ts │ ├── tsconfig.json │ └── vite.config.ts ├── eslint-config-custom │ ├── index.js │ └── package.json └── tsconfig │ ├── base.json │ └── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, other/github-workflow-windows ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | name: Build and Test 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | node-version: [ 16.x, 18.x ] 16 | os: [ ubuntu-latest, windows-latest ] 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v3 21 | 22 | - name: Install pnpm 23 | uses: pnpm/action-setup@v2.0.1 24 | with: 25 | version: 6.32.2 26 | 27 | - name: Setup Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: pnpm 32 | 33 | - name: Install dependencies 34 | run: pnpm i 35 | 36 | - name: Lint 37 | run: pnpm lint 38 | 39 | - name: Build 40 | run: pnpm build 41 | 42 | - name: Test 43 | run: pnpm test 44 | 45 | - name: Type Test 46 | run: pnpm test:typecheck 47 | 48 | - name: Upload `core` coverage to Codecov 49 | uses: codecov/codecov-action@v3 50 | with: 51 | flags: core 52 | files: ./packages/core/coverage/coverage-final.json 53 | 54 | - name: Upload `di` coverage to Codecov 55 | uses: codecov/codecov-action@v3 56 | with: 57 | flags: di 58 | files: ./packages/di/coverage/coverage-final.json 59 | 60 | - name: Upload `config` coverage to Codecov 61 | uses: codecov/codecov-action@v3 62 | with: 63 | flags: config 64 | files: ./packages/config/coverage/coverage-final.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | coverage 5 | .turbo 6 | assets -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | semi: true 2 | singleQuote: true 3 | trailingComma: all 4 | tabWidth: 2 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "eslint.workingDirectories": [ 4 | "packages/core", 5 | "packages/common", 6 | "packages/di", 7 | "packages/config", 8 | "packages/create-zephyr-app", 9 | ], 10 | "json.schemas": [ 11 | { 12 | "fileMatch": [ 13 | "eslint-config-custom/index.js", 14 | ".eslintrc.yml" 15 | ], 16 | "url": "https://json.schemastore.org/eslintrc" 17 | }, 18 | { 19 | "fileMatch": [ 20 | "tsconfig/base.json", 21 | "tsconfig.json" 22 | ], 23 | "url": "https://json.schemastore.org/tsconfig" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 KaKeng Loh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | """ 24 | This license applies to parts of the `packages/create-zephyr-app` subdirectories originating from the https://github.com/withastro/astro repository: 25 | 26 | MIT License 27 | 28 | Copyright (c) 2021 Fred K. Schott 29 | 30 | Permission is hereby granted, free of charge, to any person obtaining a copy 31 | of this software and associated documentation files (the "Software"), to deal 32 | in the Software without restriction, including without limitation the rights 33 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 34 | copies of the Software, and to permit persons to whom the Software is 35 | furnished to do so, subject to the following conditions: 36 | 37 | The above copyright notice and this permission notice shall be included in all 38 | copies or substantial portions of the Software. 39 | 40 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 41 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 42 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 43 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 44 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 45 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 46 | SOFTWARE. 47 | """ 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Zephyr.js logo 5 | 6 |

Zephyr.js

7 |

8 |

Build Typesafe Node API in minutes

9 |

10 | 11 | Zephyr.js CI workflow 12 | 13 | 14 | 15 | 16 | 17 | CodeFactor 18 | 19 |

20 |

21 | 22 |

23 | 24 | ## Description 25 | 26 | **Zephyr** is a Typescript server-side meta framework that is inspired by Next.js for its **file-based routing**. 27 | It is built on top of Express.js and uses Zod in request / response validation as well as providing typesafe API. 28 | 29 | **Zephyr** places a high value on **FP (Functional Programming)**. Instead of using classes as API controllers, declare and export API routes with functions. 30 | 31 | ## Philosophy 32 | 33 | The established server-side web frameworks for Node.js at the moment are [Nest.js](https://nestjs.com/) and [Adonis.js](https://adonisjs.com/), both of which are fantastic and rely on controllers and decorators in OOP. However, some programmers prefer functional programming to object-oriented programming (OOP). As a result, Zephyr seeks to let programmers to define API routes with functions and provides file-based routing out of the box. 34 | 35 | ## Getting started 36 | 37 | Kindly visit our documentation on [zephyrjs.com](https://zephyrjs.com/) 38 | 39 | ## Overview 40 | 41 | **Bootstrapping Project** 42 | 43 | ```bash 44 | npm create zephyr-app 45 | yarn create zephyr-app 46 | pnpm create zephyr-app 47 | ``` 48 | 49 | **Defining API Route** 50 | 51 | ```ts 52 | // src/routes/login.ts 53 | 54 | import { defineRoute } from '@zephyr-js/core'; 55 | 56 | // POST /login 57 | export function POST() { 58 | return defineRoute({ 59 | schema: z.object({ 60 | body: z.object({ 61 | email: z.string(), 62 | password: z.string(), 63 | }), 64 | response: z.object({ 65 | success: z.boolean(), 66 | }), 67 | }), 68 | onRequest(req) { 69 | logger.info('Request received', req.method, req.path); 70 | }, 71 | async handler({ body }) { 72 | await login(body); 73 | return { success: true }; 74 | }, 75 | onErrorCaptured(req, res, err) { 76 | logger.error('Login failed', err); 77 | res.status(500); 78 | return { success: false }; 79 | }, 80 | }); 81 | } 82 | ``` 83 | 84 | ## TODO 85 | 86 | - [x] Complete `create-zephyr-app` 87 | - [x] Publish `@zephyr-js/core`, `@zephyr-js/common` and `create-zephyr-app` to [NPM](https://www.npmjs.com/) 88 | - [x] Create unit tests 89 | - [x] Supports dependency injection 90 | - [ ] Create `zephyr` cli 91 | -------------------------------------------------------------------------------- /examples/basic/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | extends: 4 | - 'eslint:recommended' 5 | - 'plugin:@typescript-eslint/recommended' 6 | - 'eslint-config-prettier' 7 | overrides: [] 8 | parser: '@typescript-eslint/parser' 9 | parserOptions: 10 | ecmaVersion: latest 11 | sourceType: module 12 | plugins: 13 | - '@typescript-eslint' 14 | - import 15 | rules: 16 | indent: 17 | - error 18 | - 2 19 | linebreak-style: 20 | - error 21 | - unix 22 | quotes: 23 | - error 24 | - single 25 | semi: 26 | - error 27 | - always 28 | -------------------------------------------------------------------------------- /examples/basic/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | *.pem 17 | 18 | # debug 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | .pnpm-debug.log* 23 | 24 | # local env files 25 | .env*.local 26 | 27 | # typescript 28 | *.tsbuildinfo 29 | next-env.d.ts -------------------------------------------------------------------------------- /examples/basic/.prettierrc.yml: -------------------------------------------------------------------------------- 1 | semi: true 2 | singleQuote: true 3 | trailingComma: all 4 | tabWidth: 2 -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | Zephyr.js 2 | -------------------------------------------------------------------------------- /examples/basic/config/development.yml: -------------------------------------------------------------------------------- 1 | env: development 2 | port: 3000 -------------------------------------------------------------------------------- /examples/basic/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "ignore": [ 7 | "node_modules", 8 | "dist" 9 | ], 10 | "exec": "node -r @swc-node/register -r tsconfig-paths/register src" 11 | } -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zephyr-example-basic", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nodemon", 7 | "build": "tsc -b", 8 | "start": "node dist", 9 | "lint": "eslint --fix src" 10 | }, 11 | "dependencies": { 12 | "@zephyr-js/common": "^0.2.1", 13 | "@zephyr-js/core": "^0.3.0", 14 | "@zephyr-js/di": "^0.2.1", 15 | "@zephyr-js/config": "^0.2.0", 16 | "zod": "3.19.1" 17 | }, 18 | "devDependencies": { 19 | "@swc-node/register": "1.5.4", 20 | "@swc/core": "1.3.18", 21 | "@types/express": "4.17.14", 22 | "@types/node": "18.11.9", 23 | "@typescript-eslint/eslint-plugin": "5.43.0", 24 | "@typescript-eslint/parser": "5.43.0", 25 | "eslint": "8.27.0", 26 | "eslint-config-prettier": "8.5.0", 27 | "eslint-import-resolver-typescript": "3.5.2", 28 | "eslint-plugin-import": "2.26.0", 29 | "nodemon": "2.0.20", 30 | "prettier": "2.7.1", 31 | "tsconfig-paths": "4.1.0", 32 | "typescript": "4.9.3" 33 | } 34 | } -------------------------------------------------------------------------------- /examples/basic/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp, cors, json } from '@zephyr-js/core'; 2 | import deps from '@/lib/deps'; 3 | 4 | /** 5 | * Bootstrap Zephyr application 6 | */ 7 | async function bootstrap(): Promise { 8 | const dependencies = await deps.init(); 9 | 10 | const app = await createApp({ 11 | dependencies, 12 | middlewares: [cors(), json()], 13 | }); 14 | 15 | const { config } = dependencies; 16 | 17 | await app.listen(config.port); 18 | } 19 | 20 | bootstrap().catch((err) => { 21 | console.error(err); 22 | process.exit(1); 23 | }); 24 | -------------------------------------------------------------------------------- /examples/basic/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import * as config from '@zephyr-js/config'; 2 | import { z } from 'zod'; 3 | 4 | const schema = z.object({ 5 | env: z.string(), 6 | port: z.number(), 7 | }); 8 | 9 | export type Config = z.infer; 10 | 11 | /** 12 | * Load config from `/config/.json` 13 | * @returns {Config} Config instance 14 | * 15 | * {@link https://zephyrjs.com/docs} 16 | */ 17 | export function load(): Config { 18 | return config.load({ schema }); 19 | } 20 | 21 | export default { load }; 22 | -------------------------------------------------------------------------------- /examples/basic/src/lib/deps.ts: -------------------------------------------------------------------------------- 1 | import config from '@/lib/config'; 2 | import { Config } from '@/lib/config'; 3 | 4 | export interface AppDeps { 5 | config: Config; 6 | } 7 | 8 | /** 9 | * Initialize app dependencies 10 | */ 11 | export async function init(): Promise { 12 | return { 13 | config: config.load(), 14 | }; 15 | } 16 | 17 | export default { init }; 18 | -------------------------------------------------------------------------------- /examples/basic/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '@zephyr-js/core'; 2 | import { z } from 'zod'; 3 | 4 | export function GET() { 5 | return defineRoute({ 6 | schema: z.object({ 7 | response: z.object({ 8 | message: z.string(), 9 | }), 10 | }), 11 | handler() { 12 | return { message: 'Hello world!' }; 13 | }, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES2016", 5 | "module": "CommonJS", 6 | "lib": [ 7 | "ESNext" 8 | ], 9 | "esModuleInterop": true, 10 | "incremental": true, 11 | "inlineSourceMap": true, 12 | "inlineSources": true, 13 | "noImplicitThis": true, 14 | "strict": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "skipLibCheck": true, 18 | "pretty": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "outDir": "dist", 21 | "baseUrl": "src", 22 | "paths": { 23 | "@/*": [ 24 | "*" 25 | ] 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zephyrjs", 3 | "description": "Zephyr - Functional Node.js meta framework designed to provide the best developer experience possible", 4 | "author": { 5 | "name": "KaKeng Loh", 6 | "email": "kakengloh@gmail.com" 7 | }, 8 | "homepage": "https://github.com/zephyr-js/zephyr", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/zephyr-js/zephyr" 12 | }, 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "license": "MIT", 17 | "keywords": [ 18 | "node", 19 | "express", 20 | "zephyr", 21 | "zephyr.js" 22 | ], 23 | "scripts": { 24 | "dev": "turbo run dev", 25 | "lint": "turbo run lint", 26 | "test": "turbo run test", 27 | "test:typecheck": "turbo run test:typecheck", 28 | "build": "turbo run build", 29 | "pack": "turbo run pack" 30 | }, 31 | "devDependencies": { 32 | "turbo": "^1.6.3", 33 | "prettier": "^2.7.1" 34 | } 35 | } -------------------------------------------------------------------------------- /packages/common/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: custom -------------------------------------------------------------------------------- /packages/common/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Zephyr.js logo 5 | 6 |

Zephyr.js

7 |

8 |

Build Typesafe Node API in minutes

9 |

10 | 11 | Zephyr.js CI workflow 12 | 13 | 14 | 15 | 16 | 17 | CodeFactor 18 | 19 |

20 |

21 | 22 |

23 | 24 | ## Description 25 | 26 | **Zephyr** is a Typescript server-side meta framework that is inspired by Next.js for its **file-based routing**. 27 | It is built on top of Express.js and uses Zod in request / response validation as well as providing typesafe API. 28 | 29 | **Zephyr** places a high value on **FP (Functional Programming)**. Instead of using classes as API controllers, declare and export API routes with functions. 30 | 31 | ## Philosophy 32 | 33 | The established server-side web frameworks for Node.js at the moment are [Nest.js](https://nestjs.com/) and [Adonis.js](https://adonisjs.com/), both of which are fantastic and rely on controllers and decorators in OOP. However, some programmers prefer functional programming to object-oriented programming (OOP). As a result, Zephyr seeks to let programmers to define API routes with functions and provides file-based routing out of the box. 34 | 35 | ## Getting started 36 | 37 | Kindly visit our documentation on [zephyrjs.com](https://zephyrjs.com/) 38 | 39 | ## Overview 40 | 41 | **Bootstrapping Project** 42 | 43 | ```bash 44 | npm create zephyr-app 45 | yarn create zephyr-app 46 | pnpm create zephyr-app 47 | ``` 48 | 49 | **Defining API Route** 50 | 51 | ```ts 52 | // src/routes/login.ts 53 | 54 | import { defineRoute } from '@zephyr-js/core'; 55 | 56 | // POST /login 57 | export function POST() { 58 | return defineRoute({ 59 | schema: z.object({ 60 | body: z.object({ 61 | email: z.string(), 62 | password: z.string(), 63 | }), 64 | response: z.object({ 65 | success: z.boolean(), 66 | }), 67 | }), 68 | onRequest(req) { 69 | logger.info('Request received', req.method, req.path); 70 | }, 71 | async handler({ body }) { 72 | await login(body); 73 | return { success: true }; 74 | }, 75 | onErrorCaptured(req, res, err) { 76 | logger.error('Login failed', err); 77 | res.status(500); 78 | return { success: false }; 79 | }, 80 | }); 81 | } 82 | ``` 83 | 84 | ## TODO 85 | 86 | - [x] Complete `create-zephyr-app` 87 | - [x] Publish `@zephyr-js/core`, `@zephyr-js/common` and `create-zephyr-app` to [NPM](https://www.npmjs.com/) 88 | - [x] Create unit tests 89 | - [x] Supports dependency injection 90 | - [ ] Create `zephyr` cli 91 | -------------------------------------------------------------------------------- /packages/common/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "ignore": [ 7 | "node_modules", 8 | "dist" 9 | ], 10 | "exec": "tsc -b" 11 | } -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zephyr-js/common", 3 | "version": "0.2.1", 4 | "description": "Zephyr - Zephyr - An Express TS meta framework designed with DX in mind (@common)", 5 | "author": { 6 | "name": "KaKeng Loh", 7 | "email": "kakengloh@gmail.com" 8 | }, 9 | "homepage": "https://github.com/zephyr-js/zephyr", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/zephyr-js/zephyr", 13 | "directory": "packages/common" 14 | }, 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "license": "MIT", 19 | "keywords": [ 20 | "node", 21 | "express", 22 | "zephyr", 23 | "zephyr.js" 24 | ], 25 | "main": "dist/index.js", 26 | "types": "dist/index.d.ts", 27 | "files": [ 28 | "dist" 29 | ], 30 | "scripts": { 31 | "dev": "nodemon", 32 | "lint": "eslint --fix src", 33 | "build": "tsc -b", 34 | "pack": "npm pack" 35 | }, 36 | "devDependencies": { 37 | "tsconfig": "workspace:*", 38 | "eslint-config-custom": "workspace:*", 39 | "typescript": "^4.8.4", 40 | "@types/node": "^18.11.9", 41 | "zod": "^3.19.1", 42 | "@types/express": "^4.17.14", 43 | "nodemon": "^2.0.20" 44 | } 45 | } -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interfaces'; 2 | -------------------------------------------------------------------------------- /packages/common/src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './zephyr-request'; 2 | export * from './zephyr-response'; 3 | export * from './zephyr-handler'; 4 | export * from './zephyr-middleware'; 5 | export * from './zephyr-route'; 6 | export * from './zephyr-hooks'; 7 | -------------------------------------------------------------------------------- /packages/common/src/interfaces/zephyr-handler.ts: -------------------------------------------------------------------------------- 1 | import { ZephyrBaseRequest, ZephyrRequest } from './zephyr-request'; 2 | import { ZephyrResponse } from './zephyr-response'; 3 | 4 | export type ZephyrHandler< 5 | TRequest extends ZephyrBaseRequest = any, 6 | TResponse = any, 7 | > = ( 8 | req: ZephyrRequest, 9 | res: ZephyrResponse, 10 | ) => TResponse | Promise | undefined | Promise; 11 | 12 | export type ZephyrHandlerWithError< 13 | TRequest extends ZephyrBaseRequest = any, 14 | TResponse = any, 15 | TError = unknown, 16 | > = ( 17 | req: ZephyrRequest, 18 | res: ZephyrResponse, 19 | err: TError, 20 | ) => TResponse | Promise | undefined | Promise; 21 | -------------------------------------------------------------------------------- /packages/common/src/interfaces/zephyr-hooks.ts: -------------------------------------------------------------------------------- 1 | import { ZephyrHandler, ZephyrHandlerWithError } from './zephyr-handler'; 2 | import { ZephyrBaseRequest } from './zephyr-request'; 3 | import { ZephyrRoute } from './zephyr-route'; 4 | 5 | // App hooks 6 | export type OnReadyHook = () => void | Promise; 7 | export type OnRouteHook = (route: ZephyrRoute) => void; 8 | 9 | // Route hooks 10 | export type OnBeforeHandleHook< 11 | TRequest extends ZephyrBaseRequest, 12 | TResponse, 13 | > = ZephyrHandler; 14 | 15 | export type OnBeforeValidateHook< 16 | TRequest extends ZephyrBaseRequest, 17 | TResponse, 18 | > = ZephyrHandler; 19 | 20 | export type OnRequestHook< 21 | TRequest extends ZephyrBaseRequest, 22 | TResponse, 23 | > = ZephyrHandler; 24 | 25 | export type OnResponseHook< 26 | TRequest extends ZephyrBaseRequest, 27 | TResponse, 28 | > = ZephyrHandler; 29 | 30 | export type OnErrorCapturedHook< 31 | TRequest extends ZephyrBaseRequest = any, 32 | TResponse = any, 33 | TError = unknown, 34 | > = ZephyrHandlerWithError; 35 | -------------------------------------------------------------------------------- /packages/common/src/interfaces/zephyr-middleware.ts: -------------------------------------------------------------------------------- 1 | import { ZephyrHandler } from './zephyr-handler'; 2 | import { ZephyrBaseRequest } from './zephyr-request'; 3 | 4 | export type ZephyrMiddleware = 5 | ZephyrHandler; 6 | -------------------------------------------------------------------------------- /packages/common/src/interfaces/zephyr-request.ts: -------------------------------------------------------------------------------- 1 | import type { Request as ExpressRequest } from 'express'; 2 | 3 | export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; 4 | 5 | export interface ZephyrBaseRequest { 6 | params?: object; 7 | query?: object; 8 | body?: object; 9 | } 10 | 11 | export type ZephyrRequest< 12 | T extends ZephyrBaseRequest = any, 13 | TResponse = any, 14 | > = ExpressRequest; 15 | -------------------------------------------------------------------------------- /packages/common/src/interfaces/zephyr-response.ts: -------------------------------------------------------------------------------- 1 | import { Response as ExpressResponse } from 'express'; 2 | 3 | export type ZephyrResponse = ExpressResponse; -------------------------------------------------------------------------------- /packages/common/src/interfaces/zephyr-route.ts: -------------------------------------------------------------------------------- 1 | import type { AnyZodObject, ZodObject, ZodTypeAny } from 'zod'; 2 | import { ZephyrHandler } from './zephyr-handler'; 3 | import { 4 | OnBeforeHandleHook, 5 | OnBeforeValidateHook, 6 | OnErrorCapturedHook, 7 | OnRequestHook, 8 | OnResponseHook, 9 | } from './zephyr-hooks'; 10 | import { ZephyrBaseRequest } from './zephyr-request'; 11 | 12 | export const ROUTE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const; 13 | 14 | export type RouteMethod = typeof ROUTE_METHODS[number]; 15 | 16 | export type ZephyrRouteSchema = ZodObject<{ 17 | params?: AnyZodObject; 18 | query?: AnyZodObject; 19 | body?: AnyZodObject; 20 | response?: ZodTypeAny; 21 | }>; 22 | 23 | export interface ZephyrRoute< 24 | TRequest extends ZephyrBaseRequest = any, 25 | TResponse = any, 26 | > extends ZephyrRouteHooks { 27 | name?: string; 28 | method: RouteMethod; 29 | path: string; 30 | schema?: ZephyrRouteSchema; 31 | handler: ZephyrHandler; 32 | } 33 | 34 | export interface ZephyrRouteHooks< 35 | TRequest extends ZephyrBaseRequest, 36 | TResponse, 37 | > { 38 | onRequest?: OnRequestHook; 39 | onBeforeHandle?: OnBeforeHandleHook; 40 | onBeforeValidate?: OnBeforeValidateHook; 41 | onResponse?: OnResponseHook; 42 | onErrorCaptured?: OnErrorCapturedHook; 43 | } 44 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "dist", 6 | "baseUrl": "src" 7 | }, 8 | "exclude": [ 9 | "node_modules", 10 | "dist" 11 | ] 12 | } -------------------------------------------------------------------------------- /packages/config/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: custom 3 | rules: 4 | '@typescript-eslint/no-var-requires': 0 -------------------------------------------------------------------------------- /packages/config/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Zephyr.js logo 5 | 6 |

Zephyr.js

7 |

8 |

Build Typesafe Node API in minutes

9 |

10 | 11 | Zephyr.js CI workflow 12 | 13 | 14 | 15 | 16 | 17 | CodeFactor 18 | 19 |

20 |

21 | 22 |

23 | 24 | ## Description 25 | 26 | **Zephyr** is a Typescript server-side meta framework that is inspired by Next.js for its **file-based routing**. 27 | It is built on top of Express.js and uses Zod in request / response validation as well as providing typesafe API. 28 | 29 | **Zephyr** places a high value on **FP (Functional Programming)**. Instead of using classes as API controllers, declare and export API routes with functions. 30 | 31 | ## Philosophy 32 | 33 | The established server-side web frameworks for Node.js at the moment are [Nest.js](https://nestjs.com/) and [Adonis.js](https://adonisjs.com/), both of which are fantastic and rely on controllers and decorators in OOP. However, some programmers prefer functional programming to object-oriented programming (OOP). As a result, Zephyr seeks to let programmers to define API routes with functions and provides file-based routing out of the box. 34 | 35 | ## Getting started 36 | 37 | Kindly visit our documentation on [zephyrjs.com](https://zephyrjs.com/) 38 | 39 | ## Overview 40 | 41 | **Bootstrapping Project** 42 | 43 | ```bash 44 | npm create zephyr-app 45 | yarn create zephyr-app 46 | pnpm create zephyr-app 47 | ``` 48 | 49 | **Defining API Route** 50 | 51 | ```ts 52 | // src/routes/login.ts 53 | 54 | import { defineRoute } from '@zephyr-js/core'; 55 | 56 | // POST /login 57 | export function POST() { 58 | return defineRoute({ 59 | schema: z.object({ 60 | body: z.object({ 61 | email: z.string(), 62 | password: z.string(), 63 | }), 64 | response: z.object({ 65 | success: z.boolean(), 66 | }), 67 | }), 68 | onRequest(req) { 69 | logger.info('Request received', req.method, req.path); 70 | }, 71 | async handler({ body }) { 72 | await login(body); 73 | return { success: true }; 74 | }, 75 | onErrorCaptured(req, res, err) { 76 | logger.error('Login failed', err); 77 | res.status(500); 78 | return { success: false }; 79 | }, 80 | }); 81 | } 82 | ``` 83 | 84 | ## TODO 85 | 86 | - [x] Complete `create-zephyr-app` 87 | - [x] Publish `@zephyr-js/core`, `@zephyr-js/common` and `create-zephyr-app` to [NPM](https://www.npmjs.com/) 88 | - [x] Create unit tests 89 | - [x] Supports dependency injection 90 | - [ ] Create `zephyr` cli 91 | -------------------------------------------------------------------------------- /packages/config/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "ignore": [ 7 | "node_modules", 8 | "dist" 9 | ], 10 | "exec": "tsc -b" 11 | } -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zephyr-js/config", 3 | "version": "0.3.0", 4 | "description": "Zephyr - An Express TS meta framework designed with DX in mind (@config)", 5 | "author": { 6 | "name": "KaKeng Loh", 7 | "email": "kakengloh@gmail.com" 8 | }, 9 | "homepage": "https://github.com/zephyr-js/zephyr", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/zephyr-js/zephyr", 13 | "directory": "packages/config" 14 | }, 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "license": "MIT", 19 | "keywords": [ 20 | "node", 21 | "express", 22 | "zephyr", 23 | "zephyr.js" 24 | ], 25 | "main": "dist/index.js", 26 | "types": "dist/index.d.ts", 27 | "files": [ 28 | "dist" 29 | ], 30 | "scripts": { 31 | "dev": "nodemon", 32 | "lint": "eslint --fix src", 33 | "test": "vitest --run --coverage", 34 | "build": "tsc -b", 35 | "pack": "npm pack" 36 | }, 37 | "dependencies": { 38 | "dotenv": "^16.0.3", 39 | "zod": "^3.19.1", 40 | "yaml": "^2.1.3", 41 | "eta": "^1.12.3" 42 | }, 43 | "peerDependencies": { 44 | "zod": "^3.19.1" 45 | }, 46 | "devDependencies": { 47 | "tsconfig": "workspace:*", 48 | "eslint-config-custom": "workspace:*", 49 | "@types/node": "^18.11.9", 50 | "tsconfig-paths": "^4.1.0", 51 | "nodemon": "^2.0.20", 52 | "vitest": "^0.25.0", 53 | "@vitest/coverage-c8": "^0.24.5", 54 | "@types/dotenv": "^8.2.0" 55 | } 56 | } -------------------------------------------------------------------------------- /packages/config/src/__mocks__/app/.env: -------------------------------------------------------------------------------- 1 | JWT_SECRET=123 2 | DB_PASS=456 -------------------------------------------------------------------------------- /packages/config/src/__mocks__/app/config/basic.yml: -------------------------------------------------------------------------------- 1 | port: 3000 -------------------------------------------------------------------------------- /packages/config/src/__mocks__/app/config/env.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | secret: '<%~ process.env.JWT_SECRET %>' 3 | 4 | database: 5 | username: root 6 | password: '<%~ process.env.DB_PASS %>' -------------------------------------------------------------------------------- /packages/config/src/__mocks__/app/config/variables.yml: -------------------------------------------------------------------------------- 1 | aws: 2 | accessKeyId: "<%= it.AWS_ACCESS_KEY_ID %>" 3 | secretAccessKey: "<%= it.AWS_SECRET_ACCESS_KEY %>" 4 | -------------------------------------------------------------------------------- /packages/config/src/index.ts: -------------------------------------------------------------------------------- 1 | export { load } from './load'; 2 | -------------------------------------------------------------------------------- /packages/config/src/load.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | afterAll, 3 | afterEach, 4 | beforeAll, 5 | describe, 6 | expect, 7 | test, 8 | vi, 9 | } from 'vitest'; 10 | import path from 'path'; 11 | import fs from 'fs'; 12 | import { 13 | getConfigFilePath, 14 | getEnvFilePath, 15 | load, 16 | parseVariables, 17 | validateConfig, 18 | } from './load'; 19 | import { z } from 'zod'; 20 | 21 | describe('parseVariables()', () => { 22 | test('should return parsed content', () => { 23 | const content = parseVariables( 24 | 'Hello <%= it.message %> <%= it.message %>', 25 | { 26 | message: 'world', 27 | }, 28 | ); 29 | expect(content).toEqual('Hello world world'); 30 | }); 31 | }); 32 | 33 | describe('validateConfig()', () => { 34 | test('should return void when validation is success', () => { 35 | const schema = z.object({ 36 | port: z.number(), 37 | }); 38 | 39 | const config = { 40 | port: 3000, 41 | }; 42 | 43 | expect(validateConfig(schema, config)).toBeUndefined; 44 | }); 45 | 46 | test('should throw error when validation fails', () => { 47 | const schema = z.object({ 48 | port: z.number(), 49 | }); 50 | 51 | const config = { 52 | port: '3000', 53 | }; 54 | 55 | expect(() => validateConfig(schema, config)).toThrow( 56 | 'Config validation error:\n`port`: Expected number, received string', 57 | ); 58 | }); 59 | }); 60 | 61 | describe('load()', () => { 62 | const env = { ...process.env }; 63 | 64 | beforeAll(() => { 65 | vi.spyOn(process, 'cwd').mockReturnValue( 66 | path.join(__dirname, '__mocks__', 'app'), 67 | ); 68 | }); 69 | 70 | afterAll(() => { 71 | vi.restoreAllMocks(); 72 | }); 73 | 74 | afterEach(() => { 75 | process.env = env; 76 | }); 77 | 78 | test('should load config from file', () => { 79 | const config = load({ 80 | path: path.join(__dirname, '__mocks__', 'app', 'config', 'basic.yml'), 81 | }); 82 | expect(config).to.deep.equals({ port: 3000 }); 83 | }); 84 | 85 | test('should load config from file and parse specific variables', () => { 86 | const config = load({ 87 | path: path.join(__dirname, '__mocks__', 'app', 'config', 'variables.yml'), 88 | variables: { 89 | AWS_ACCESS_KEY_ID: '123', 90 | AWS_SECRET_ACCESS_KEY: '456', 91 | }, 92 | }); 93 | expect(config).to.deep.equals({ 94 | aws: { 95 | accessKeyId: '123', 96 | secretAccessKey: '456', 97 | }, 98 | }); 99 | }); 100 | 101 | test('should load config from file and parse variables from process.env', () => { 102 | process.env.JWT_SECRET = 'jwt-secret'; 103 | process.env.DB_PASS = 'db-pass'; 104 | 105 | const config = load({ 106 | path: path.join(__dirname, '__mocks__', 'app', 'config', 'env.yml'), 107 | }); 108 | 109 | expect(config).to.deep.equals({ 110 | jwt: { 111 | secret: 'jwt-secret', 112 | }, 113 | database: { 114 | username: 'root', 115 | password: 'db-pass', 116 | }, 117 | }); 118 | 119 | delete process.env.JWT_SECRET; 120 | delete process.env.DB_PASS; 121 | }); 122 | 123 | test('should load config from file and parse variables from .env file', () => { 124 | const config = load({ 125 | path: path.join(__dirname, '__mocks__', 'app', 'config', 'env.yml'), 126 | }); 127 | 128 | expect(config).to.deep.equals({ 129 | jwt: { 130 | secret: '123', 131 | }, 132 | database: { 133 | username: 'root', 134 | password: '456', 135 | }, 136 | }); 137 | }); 138 | 139 | test('should throw error when config file not exists', () => { 140 | expect(() => load({ path: 'foo' })).toThrow( 141 | 'Config file not found at path: \'foo\'', 142 | ); 143 | }); 144 | 145 | test('should throw error when config file not exists (default)', () => { 146 | expect(() => load()).toThrow('Config file not found at path: \'null\''); 147 | }); 148 | 149 | test('should load config from file and validate it', () => { 150 | const config = load({ 151 | path: path.join(__dirname, '__mocks__', 'app', 'config', 'variables.yml'), 152 | variables: { 153 | AWS_ACCESS_KEY_ID: '123', 154 | AWS_SECRET_ACCESS_KEY: '456', 155 | }, 156 | schema: z.object({ 157 | aws: z.object({ 158 | accessKeyId: z.string(), 159 | secretAccessKey: z.string(), 160 | }), 161 | }), 162 | }); 163 | expect(config).to.deep.equals({ 164 | aws: { 165 | accessKeyId: '123', 166 | secretAccessKey: '456', 167 | }, 168 | }); 169 | }); 170 | }); 171 | 172 | describe('getConfigFilePath()', () => { 173 | test('should return correct config file path with current environment (test)', () => { 174 | vi.spyOn(fs, 'existsSync').mockReturnValueOnce(true); 175 | 176 | expect(getConfigFilePath()).toEqual( 177 | path.join(__dirname, '..', 'config', 'test.yml'), 178 | ); 179 | }); 180 | 181 | test('should return null', () => { 182 | process.env.NODE_ENV = undefined; 183 | expect(getConfigFilePath()).toBeNull(); 184 | }); 185 | 186 | test('should return correct config file path', () => { 187 | vi.spyOn(fs, 'existsSync').mockReturnValueOnce(true); 188 | 189 | expect(getConfigFilePath()).toEqual( 190 | path.join(__dirname, '..', 'config', 'development.yml'), 191 | ); 192 | }); 193 | }); 194 | 195 | describe('getEnvFilePath()', () => { 196 | test('should return correct .env file path', () => { 197 | vi.spyOn(process, 'cwd').mockReturnValue( 198 | path.join(__dirname, '__mocks__', 'app'), 199 | ); 200 | 201 | expect(getEnvFilePath()).toEqual( 202 | path.join(__dirname, '__mocks__', 'app', '.env'), 203 | ); 204 | 205 | vi.restoreAllMocks(); 206 | }); 207 | }); 208 | -------------------------------------------------------------------------------- /packages/config/src/load.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { AnyZodObject } from 'zod'; 3 | import yaml from 'yaml'; 4 | import dotenv from 'dotenv'; 5 | import * as eta from 'eta'; 6 | import path from 'path'; 7 | 8 | export interface LoadOptions { 9 | path?: string | null; 10 | variables?: object; 11 | schema?: AnyZodObject; 12 | } 13 | 14 | export function getConfigFilePath( 15 | env: string = process.env.NODE_ENV || 'development', 16 | ): string | null { 17 | const file = path.join(process.cwd(), 'config', env); 18 | const extensions = ['.yml', '.yaml']; 19 | 20 | for (const extension of extensions) { 21 | if (fs.existsSync(file + extension)) { 22 | return file + extension; 23 | } 24 | } 25 | 26 | return null; 27 | } 28 | 29 | export function getEnvFilePath(): string { 30 | return path.join(process.cwd(), '.env'); 31 | } 32 | 33 | export function parseVariables(template: string, variables: object): string { 34 | return eta.render(template, variables, { autoTrim: false }) as string; 35 | } 36 | 37 | export function validateConfig(schema: AnyZodObject, config: unknown): void { 38 | const validation = schema.safeParse(config); 39 | if (!validation.success) { 40 | const message = validation.error.issues.reduce((acc: string, issue) => { 41 | acc += `\`${issue.path}\`: ${issue.message}`; 42 | return acc; 43 | }, 'Config validation error:\n'); 44 | throw new Error(message); 45 | } 46 | } 47 | 48 | export function load({ 49 | path = getConfigFilePath(), 50 | variables = {}, 51 | schema, 52 | }: LoadOptions = {}): T { 53 | if (path === null || !fs.existsSync(path)) { 54 | throw new Error(`Config file not found at path: '${path}'`); 55 | } 56 | 57 | dotenv.config(); 58 | 59 | const template = fs.readFileSync(path, 'utf-8'); 60 | const content = parseVariables(template, variables); 61 | 62 | const config = yaml.parse(content); 63 | 64 | if (schema) { 65 | validateConfig(schema, config); 66 | } 67 | 68 | return config as T; 69 | } 70 | -------------------------------------------------------------------------------- /packages/config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "dist", 6 | "baseUrl": "src", 7 | "paths": { 8 | "@/*": [ 9 | "*" 10 | ] 11 | }, 12 | }, 13 | "include": [ 14 | "src" 15 | ], 16 | "exclude": [ 17 | "node_modules", 18 | "dist", 19 | "src/__mocks__", 20 | "**/*.spec.ts", 21 | "**/*.spec-d.ts" 22 | ] 23 | } -------------------------------------------------------------------------------- /packages/config/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ['text', 'json', 'html'], 7 | }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/core/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: custom -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Zephyr.js logo 5 | 6 |

Zephyr.js

7 |

8 |

Build Typesafe Node API in minutes

9 |

10 | 11 | Zephyr.js CI workflow 12 | 13 | 14 | 15 | 16 | 17 | CodeFactor 18 | 19 |

20 |

21 | 22 |

23 | 24 | ## Description 25 | 26 | **Zephyr** is a Typescript server-side meta framework that is inspired by Next.js for its **file-based routing**. 27 | It is built on top of Express.js and uses Zod in request / response validation as well as providing typesafe API. 28 | 29 | **Zephyr** places a high value on **FP (Functional Programming)**. Instead of using classes as API controllers, declare and export API routes with functions. 30 | 31 | ## Philosophy 32 | 33 | The established server-side web frameworks for Node.js at the moment are [Nest.js](https://nestjs.com/) and [Adonis.js](https://adonisjs.com/), both of which are fantastic and rely on controllers and decorators in OOP. However, some programmers prefer functional programming to object-oriented programming (OOP). As a result, Zephyr seeks to let programmers to define API routes with functions and provides file-based routing out of the box. 34 | 35 | ## Getting started 36 | 37 | Kindly visit our documentation on [zephyrjs.com](https://zephyrjs.com/) 38 | 39 | ## Overview 40 | 41 | **Bootstrapping Project** 42 | 43 | ```bash 44 | npm create zephyr-app 45 | yarn create zephyr-app 46 | pnpm create zephyr-app 47 | ``` 48 | 49 | **Defining API Route** 50 | 51 | ```ts 52 | // src/routes/login.ts 53 | 54 | import { defineRoute } from '@zephyr-js/core'; 55 | 56 | // POST /login 57 | export function POST() { 58 | return defineRoute({ 59 | schema: z.object({ 60 | body: z.object({ 61 | email: z.string(), 62 | password: z.string(), 63 | }), 64 | response: z.object({ 65 | success: z.boolean(), 66 | }), 67 | }), 68 | onRequest(req) { 69 | logger.info('Request received', req.method, req.path); 70 | }, 71 | async handler({ body }) { 72 | await login(body); 73 | return { success: true }; 74 | }, 75 | onErrorCaptured(req, res, err) { 76 | logger.error('Login failed', err); 77 | res.status(500); 78 | return { success: false }; 79 | }, 80 | }); 81 | } 82 | ``` 83 | 84 | ## TODO 85 | 86 | - [x] Complete `create-zephyr-app` 87 | - [x] Publish `@zephyr-js/core`, `@zephyr-js/common` and `create-zephyr-app` to [NPM](https://www.npmjs.com/) 88 | - [x] Create unit tests 89 | - [x] Supports dependency injection 90 | - [ ] Create `zephyr` cli 91 | -------------------------------------------------------------------------------- /packages/core/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "ignore": [ 7 | "node_modules", 8 | "dist" 9 | ], 10 | "exec": "tsc -b" 11 | } -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zephyr-js/core", 3 | "version": "0.3.1", 4 | "description": "Zephyr - Zephyr - An Express TS meta framework designed with DX in mind (@core)", 5 | "author": { 6 | "name": "KaKeng Loh", 7 | "email": "kakengloh@gmail.com" 8 | }, 9 | "homepage": "https://github.com/zephyr-js/zephyr", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/zephyr-js/zephyr", 13 | "directory": "packages/core" 14 | }, 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "license": "MIT", 19 | "keywords": [ 20 | "node", 21 | "express", 22 | "zephyr", 23 | "zephyr.js" 24 | ], 25 | "main": "dist/index.js", 26 | "types": "dist/index.d.ts", 27 | "files": [ 28 | "dist" 29 | ], 30 | "scripts": { 31 | "dev": "nodemon", 32 | "lint": "eslint --fix src", 33 | "test": "vitest --run --coverage --silent", 34 | "test:typecheck": "vitest typecheck --run", 35 | "build": "tsc -b", 36 | "pack": "npm pack" 37 | }, 38 | "dependencies": { 39 | "glob": "^8.0.3", 40 | "zod": "^3.19.1", 41 | "express": "^4.18.2", 42 | "cors": "^2.8.5" 43 | }, 44 | "peerDependencies": { 45 | "@zephyr-js/common": "^0.2.0", 46 | "zod": "^3.19.1" 47 | }, 48 | "devDependencies": { 49 | "tsconfig": "workspace:*", 50 | "eslint-config-custom": "workspace:*", 51 | "@zephyr-js/common": "workspace:*", 52 | "@zephyr-js/di": "workspace:*", 53 | "@types/node": "^18.11.9", 54 | "@types/express": "^4.17.14", 55 | "@types/cors": "^2.8.12", 56 | "@types/body-parser": "^1.19.2", 57 | "@types/glob": "^8.0.0", 58 | "tsconfig-paths": "^4.1.0", 59 | "nodemon": "^2.0.20", 60 | "vitest": "^0.25.0", 61 | "supertest": "^6.3.1", 62 | "@types/supertest": "^2.0.12", 63 | "@types/express-serve-static-core": "^4.17.31", 64 | "@vitest/coverage-c8": "^0.24.5", 65 | "got": "^12.5.3" 66 | } 67 | } -------------------------------------------------------------------------------- /packages/core/src/__mocks__/app/routes/[id].ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../define-route'; 2 | 3 | export function GET() { 4 | return defineRoute({ 5 | handler() { 6 | return 'OK'; 7 | }, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/app/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../define-route'; 2 | 3 | export function GET() { 4 | return defineRoute({ 5 | handler() { 6 | return 'OK'; 7 | }, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/app/routes/items/[itemId].ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../../define-route'; 2 | 3 | export function GET() { 4 | return defineRoute({ 5 | handler() { 6 | return { item: null }; 7 | }, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/app/routes/sum.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { defineRoute } from '../../../define-route'; 3 | import { Calculator } from '../services/calculator'; 4 | 5 | export function POST({ calculator }: { calculator: Calculator }) { 6 | return defineRoute({ 7 | schema: z.object({ 8 | body: z.object({ 9 | x: z.number(), 10 | y: z.number(), 11 | }), 12 | }), 13 | handler(req, res) { 14 | const { x, y } = req.body; 15 | const answer = calculator.sum(x, y); 16 | return res.send(answer.toString()); 17 | }, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/app/routes/todos.ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../define-route'; 2 | 3 | export function GET() { 4 | return defineRoute({ 5 | handler() { 6 | return { todos: [] }; 7 | }, 8 | }); 9 | } 10 | 11 | export function POST() { 12 | return defineRoute({ 13 | handler() { 14 | return { todo: null }; 15 | }, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/app/routes/v1/[id].ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../../define-route'; 2 | 3 | export function GET() { 4 | return defineRoute({ 5 | handler() { 6 | return 'OK'; 7 | }, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/app/routes/v1/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../../define-route'; 2 | 3 | export function GET() { 4 | return defineRoute({ 5 | handler() { 6 | return 'OK'; 7 | }, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/app/routes/v1/todos.ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../../define-route'; 2 | 3 | export function GET() { 4 | return defineRoute({ 5 | handler() { 6 | return { todos: [] }; 7 | }, 8 | }); 9 | } 10 | 11 | export function POST() { 12 | return defineRoute({ 13 | handler() { 14 | return { todo: null }; 15 | }, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/app/services/calculator.ts: -------------------------------------------------------------------------------- 1 | export class Calculator { 2 | sum(x: number, y: number): number { 3 | return x + y; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/core/src/create-app.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import { afterEach, describe, expect, test, vi } from 'vitest'; 3 | import got from 'got'; 4 | import * as routesLoader from './utils/routes-loader'; 5 | import { createApp } from './create-app'; 6 | 7 | describe('createApp()', () => { 8 | afterEach(() => { 9 | vi.restoreAllMocks(); 10 | }); 11 | 12 | test('should create basic express app with root endpoint', async () => { 13 | const loadRoutesSpy = vi.spyOn(routesLoader, 'loadRoutes'); 14 | loadRoutesSpy.mockResolvedValueOnce([]); 15 | 16 | const app = await createApp(); 17 | app.get('/', (_, res) => res.send('OK')); 18 | const response = await supertest(app).get('/').expect(200); 19 | expect(response.text).toBe('OK'); 20 | }); 21 | 22 | test('should load routes from directory and register them on express app', async () => { 23 | const loadRoutesSpy = vi.spyOn(routesLoader, 'loadRoutes'); 24 | loadRoutesSpy.mockResolvedValueOnce([ 25 | { 26 | method: 'GET', 27 | path: '/', 28 | handler(_, res) { 29 | res.send('OK'); 30 | }, 31 | }, 32 | ]); 33 | 34 | const app = await createApp(); 35 | const response = await supertest(app).get('/').expect(200); 36 | expect(response.text).toBe('OK'); 37 | }); 38 | 39 | test('should append middlewares', async () => { 40 | const fn = vi.fn(); 41 | 42 | const app = await createApp({ 43 | middlewares: [ 44 | (req, res, next) => { 45 | fn(); 46 | next(); 47 | }, 48 | ], 49 | }); 50 | 51 | app.get('/', (_, res) => res.send('OK')); 52 | 53 | await supertest(app).get('/').expect(200); 54 | 55 | expect(fn).toHaveBeenCalledOnce(); 56 | }); 57 | 58 | describe('listen()', () => { 59 | test('should return OK', async () => { 60 | const app = await createApp(); 61 | app.get('/', (_, res) => res.send('OK')); 62 | await app.listen(3000); 63 | const response = await got('http://localhost:3000'); 64 | expect(response.body).toEqual('OK'); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/core/src/create-app.ts: -------------------------------------------------------------------------------- 1 | import express, { RequestHandler } from 'express'; 2 | import { CorsOptions, CorsOptionsDelegate } from 'cors'; 3 | import { OptionsJson } from 'body-parser'; 4 | import { loadRoutes } from './utils/routes-loader'; 5 | import { createRouter } from './create-router'; 6 | import { 7 | Application, 8 | ApplicationRequestHandler, 9 | } from 'express-serve-static-core'; 10 | 11 | export interface ZephyrApplication extends Omit { 12 | listen(port?: number): Promise; 13 | use: ApplicationRequestHandler; 14 | } 15 | 16 | export interface CreateAppOptions { 17 | cors?: boolean | CorsOptions | CorsOptionsDelegate; 18 | json?: boolean | OptionsJson; 19 | dependencies?: TDependencies; 20 | middlewares?: RequestHandler[]; 21 | } 22 | 23 | /** 24 | * Creates an Express application, with routes loaded 25 | */ 26 | export async function createApp({ 27 | dependencies = Object.create(null), 28 | middlewares = [], 29 | }: CreateAppOptions = {}): Promise { 30 | const app = express(); 31 | 32 | if (middlewares.length) { 33 | app.use(...middlewares); 34 | } 35 | 36 | const routes = await loadRoutes({ dependencies }); 37 | 38 | const router = createRouter(routes); 39 | 40 | app.use(router); 41 | 42 | function listen(port?: number) { 43 | return new Promise((resolve, reject) => { 44 | app 45 | .listen(port, () => { 46 | console.info( 47 | 'Zephyr application is ready on', 48 | `http://localhost:${port}`, 49 | ); 50 | resolve(); 51 | }) 52 | .on('error', (err) => reject(err)); 53 | }); 54 | } 55 | 56 | const proxy = new Proxy(app as unknown as ZephyrApplication, { 57 | get(target, prop) { 58 | if (prop === 'listen') { 59 | return listen; 60 | } 61 | return Reflect.get(target, prop); 62 | }, 63 | }); 64 | 65 | return proxy; 66 | } 67 | -------------------------------------------------------------------------------- /packages/core/src/create-router.ts: -------------------------------------------------------------------------------- 1 | import { ZephyrRoute } from '@zephyr-js/common'; 2 | import { RequestHandler, Router } from 'express'; 3 | import { 4 | createErrorMiddleware, 5 | createHandlerMiddleware, 6 | createValidationMiddleware, 7 | } from './utils/middlewares'; 8 | 9 | /** 10 | * Create a main router with loaded routes 11 | * @param routes Instances of `ZephyrRoute` 12 | * @returns {Router} `Router` 13 | */ 14 | export function createRouter(routes: ZephyrRoute[]): Router { 15 | const router = Router(); 16 | 17 | for (const route of routes) { 18 | const { 19 | path, 20 | handler, 21 | schema, 22 | onRequest, 23 | onBeforeValidate, 24 | onBeforeHandle, 25 | onErrorCaptured, 26 | onResponse, 27 | } = route; 28 | 29 | const method = route.method.toLowerCase() as 30 | | 'get' 31 | | 'post' 32 | | 'put' 33 | | 'delete' 34 | | 'patch'; 35 | 36 | const middlewares: RequestHandler[] = []; 37 | 38 | if (onRequest) { 39 | middlewares.push(createHandlerMiddleware(onRequest)); 40 | } 41 | 42 | if (schema) { 43 | if (onBeforeValidate) { 44 | middlewares.push(createHandlerMiddleware(onBeforeValidate)); 45 | } 46 | middlewares.push(createValidationMiddleware(schema)); 47 | } 48 | 49 | if (onBeforeHandle) { 50 | middlewares.push(createHandlerMiddleware(onBeforeHandle)); 51 | } 52 | middlewares.push(createHandlerMiddleware(handler, onErrorCaptured)); 53 | 54 | if (onResponse) { 55 | middlewares.push(createHandlerMiddleware(onResponse)); 56 | } 57 | 58 | router[method](path, ...middlewares); 59 | } 60 | 61 | router.use(createErrorMiddleware()); 62 | 63 | return router; 64 | } 65 | -------------------------------------------------------------------------------- /packages/core/src/define-route.spec-d.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assertType, expectTypeOf } from 'vitest'; 2 | import { z } from 'zod'; 3 | import { defineRoute } from './define-route'; 4 | 5 | describe('Route schema type inference', () => { 6 | test('should able to inference req.params type', () => { 7 | defineRoute({ 8 | schema: z.object({ 9 | params: z.object({ 10 | id: z.number(), 11 | email: z.string(), 12 | phone: z.string().optional(), 13 | }), 14 | }), 15 | handler(req) { 16 | type Expected = { 17 | id: number; 18 | email: string; 19 | phone?: string; 20 | }; 21 | assertType(req.params); 22 | }, 23 | }); 24 | }); 25 | 26 | test('should able to inference req.query type', () => { 27 | defineRoute({ 28 | schema: z.object({ 29 | query: z.object({ 30 | skip: z.number().optional(), 31 | limit: z.number().optional(), 32 | q: z.string().optional(), 33 | active: z.boolean().optional(), 34 | }), 35 | }), 36 | handler(req) { 37 | type Expected = { 38 | skip?: number; 39 | limit?: number; 40 | q?: string; 41 | active?: boolean; 42 | }; 43 | assertType(req.query); 44 | }, 45 | }); 46 | }); 47 | 48 | test('should able to inference req.body type', () => { 49 | defineRoute({ 50 | schema: z.object({ 51 | body: z.object({ 52 | id: z.number(), 53 | success: z.boolean(), 54 | }), 55 | }), 56 | handler(req) { 57 | type Expected = { 58 | id: number; 59 | success: boolean; 60 | }; 61 | assertType(req.body); 62 | }, 63 | }); 64 | }); 65 | 66 | test('should able to inference res.json type', () => { 67 | defineRoute({ 68 | schema: z.object({ 69 | response: z.object({ 70 | firstName: z.string(), 71 | lastName: z.string().optional(), 72 | email: z.string(), 73 | gender: z.enum(['male', 'female']), 74 | }), 75 | }), 76 | handler(_, res) { 77 | type Expected = 78 | | { 79 | firstName: string; 80 | lastName?: string; 81 | email: string; 82 | gender: 'male' | 'female'; 83 | } 84 | | undefined; 85 | expectTypeOf(res.json).parameter(0).toEqualTypeOf(); 86 | }, 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /packages/core/src/define-route.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ZephyrBaseRequest, 3 | ZephyrHandler, 4 | ZephyrRouteHooks, 5 | } from '@zephyr-js/common'; 6 | import { AnyZodObject, z } from 'zod'; 7 | 8 | type RequestFromSchema = Omit, 'response'>; 9 | type ResponseFromSchema = z.infer['response']; 10 | 11 | export type DefineRouteOptions< 12 | TRequest extends ZephyrBaseRequest = any, 13 | TResponse = any, 14 | TSchema extends AnyZodObject = any, 15 | > = { 16 | name?: string; 17 | schema?: TSchema; 18 | handler: ZephyrHandler; 19 | } & ZephyrRouteHooks; 20 | 21 | export function defineRoute< 22 | TSchema extends AnyZodObject = any, 23 | TRequest extends ZephyrBaseRequest = RequestFromSchema, 24 | TResponse = ResponseFromSchema, 25 | >(options: DefineRouteOptions) { 26 | return options; 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/src/dependency-injection.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import { expect, test, vi } from 'vitest'; 3 | import { createApp } from './create-app'; 4 | import * as routesLoader from './utils/routes-loader'; 5 | import { Calculator } from './__mocks__/app/services/calculator'; 6 | import * as routes from './__mocks__/app/routes/sum'; 7 | import { json } from 'express'; 8 | 9 | test('should able to inject dependencies on route handler', async () => { 10 | const dependencies = { 11 | calculator: new Calculator(), 12 | }; 13 | 14 | const loadRoutesSpy = vi.spyOn(routesLoader, 'loadRoutes'); 15 | loadRoutesSpy.mockResolvedValueOnce([ 16 | { 17 | ...routes.POST(dependencies), 18 | method: 'POST', 19 | path: '/sum', 20 | }, 21 | ]); 22 | 23 | const app = await createApp({ 24 | middlewares: [json()], 25 | }); 26 | 27 | const response = await supertest(app) 28 | .post('/sum') 29 | .send({ x: 1, y: 2 }) 30 | .expect(200); 31 | 32 | expect(response.text).to.equal('3'); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { createApp } from './create-app'; 2 | export { defineRoute } from './define-route'; 3 | export { createApp as default } from './create-app'; 4 | export { default as cors } from 'cors'; 5 | export { json } from 'express'; 6 | -------------------------------------------------------------------------------- /packages/core/src/lifecycle-hooks.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import { expect, test, vi } from 'vitest'; 3 | import { z } from 'zod'; 4 | import { createApp } from './create-app'; 5 | import * as routesLoader from './utils/routes-loader'; 6 | 7 | test('should call `onRequest` hook', async () => { 8 | const onRequestMock = vi.fn(); 9 | 10 | const loadRoutesSpy = vi.spyOn(routesLoader, 'loadRoutes'); 11 | loadRoutesSpy.mockResolvedValueOnce([ 12 | { 13 | method: 'GET', 14 | path: '/', 15 | onRequest: onRequestMock, 16 | handler(_, res) { 17 | res.send('OK'); 18 | }, 19 | }, 20 | ]); 21 | 22 | const app = await createApp(); 23 | await supertest(app).get('/').expect(200); 24 | expect(onRequestMock).toHaveBeenCalledOnce(); 25 | }); 26 | 27 | test('should call `onBeforeValidate` hook', async () => { 28 | const onBeforeValidateMock = vi.fn(); 29 | 30 | const loadRoutesSpy = vi.spyOn(routesLoader, 'loadRoutes'); 31 | loadRoutesSpy.mockResolvedValueOnce([ 32 | { 33 | method: 'GET', 34 | path: '/', 35 | onBeforeValidate: onBeforeValidateMock, 36 | schema: z.object({}), 37 | handler(_, res) { 38 | res.send('OK'); 39 | }, 40 | }, 41 | ]); 42 | 43 | const app = await createApp(); 44 | await supertest(app).get('/').expect(200); 45 | expect(onBeforeValidateMock).toHaveBeenCalledOnce(); 46 | }); 47 | 48 | test('should call `onBeforeHandle` hook', async () => { 49 | const onBeforeHandleMock = vi.fn(); 50 | 51 | const loadRoutesSpy = vi.spyOn(routesLoader, 'loadRoutes'); 52 | loadRoutesSpy.mockResolvedValueOnce([ 53 | { 54 | method: 'GET', 55 | path: '/', 56 | onBeforeHandle: onBeforeHandleMock, 57 | handler(_, res) { 58 | res.send('OK'); 59 | }, 60 | }, 61 | ]); 62 | 63 | const app = await createApp(); 64 | await supertest(app).get('/').expect(200); 65 | expect(onBeforeHandleMock).toHaveBeenCalledOnce(); 66 | }); 67 | 68 | test('should call `onErrorCaptured` hook', async () => { 69 | const onErrorCapturedMock = vi.fn().mockImplementation((_, res) => { 70 | return res.status(500).send('Something went wrong'); 71 | }); 72 | 73 | const loadRoutesSpy = vi.spyOn(routesLoader, 'loadRoutes'); 74 | loadRoutesSpy.mockResolvedValueOnce([ 75 | { 76 | method: 'GET', 77 | path: '/', 78 | onErrorCaptured: onErrorCapturedMock, 79 | handler() { 80 | throw new Error('Something went wrong'); 81 | }, 82 | }, 83 | ]); 84 | 85 | const app = await createApp(); 86 | await supertest(app).get('/').expect(500); 87 | expect(onErrorCapturedMock).toHaveBeenCalledOnce(); 88 | }); 89 | 90 | test('should call `onResponse` hook', async () => { 91 | const onResponseMock = vi.fn(); 92 | 93 | const loadRoutesSpy = vi.spyOn(routesLoader, 'loadRoutes'); 94 | loadRoutesSpy.mockResolvedValueOnce([ 95 | { 96 | method: 'GET', 97 | path: '/', 98 | onResponse: onResponseMock, 99 | handler(_, res) { 100 | res.send('OK'); 101 | }, 102 | }, 103 | ]); 104 | 105 | const app = await createApp(); 106 | await supertest(app).get('/').expect(200); 107 | expect(onResponseMock).toHaveBeenCalledOnce(); 108 | }); 109 | -------------------------------------------------------------------------------- /packages/core/src/utils/common.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { ZodError } from 'zod'; 3 | import { isValidationError } from './common'; 4 | 5 | describe('isValidationError', () => { 6 | test('should return true', () => { 7 | const err = new ZodError([]); 8 | expect(isValidationError(err)).toBeTruthy(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/core/src/utils/common.ts: -------------------------------------------------------------------------------- 1 | import { ZodError } from 'zod'; 2 | 3 | export const isValidationError = (err: unknown): err is ZodError => { 4 | return err instanceof Error && err.name === 'ZodError'; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/core/src/utils/middlewares.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, beforeAll, expect } from 'vitest'; 2 | import supertest from 'supertest'; 3 | import { 4 | createErrorMiddleware, 5 | createHandlerMiddleware, 6 | createValidationMiddleware, 7 | } from './middlewares'; 8 | import express, { RequestHandler } from 'express'; 9 | import { z } from 'zod'; 10 | import { ZephyrHandler } from '@zephyr-js/common'; 11 | 12 | describe('createHandlerMiddleware()', () => { 13 | let app: ReturnType; 14 | 15 | test('should return 200', async () => { 16 | const handler: RequestHandler = async (_, res) => { 17 | return res.send('OK'); 18 | }; 19 | const handlerMiddleware = createHandlerMiddleware(handler as ZephyrHandler); 20 | 21 | app = express(); 22 | app.get('/', handlerMiddleware); 23 | 24 | const response = await supertest(app).get('/').expect(200); 25 | expect(response.text).toBe('OK'); 26 | }); 27 | 28 | test('should assign body to res.send when string is returned', async () => { 29 | const handler: RequestHandler = () => { 30 | return 'OK'; 31 | }; 32 | const handlerMiddleware = createHandlerMiddleware(handler as ZephyrHandler); 33 | 34 | app = express(); 35 | app.get('/', handlerMiddleware); 36 | 37 | const response = await supertest(app).get('/').expect(200); 38 | expect(response.text).toBe('OK'); 39 | }); 40 | 41 | test('should assign body to res.json when object is returned', async () => { 42 | const handler: RequestHandler = () => { 43 | return { foo: 'bar' }; 44 | }; 45 | const handlerMiddleware = createHandlerMiddleware(handler as ZephyrHandler); 46 | 47 | app = express(); 48 | app.get('/', handlerMiddleware); 49 | 50 | const response = await supertest(app).get('/').expect(200); 51 | expect(response.body).to.deep.equals({ foo: 'bar' }); 52 | }); 53 | }); 54 | 55 | describe('createValidationMiddleware()', () => { 56 | let app: ReturnType; 57 | 58 | beforeAll(() => { 59 | app = express(); 60 | 61 | const schema = z.object({ 62 | query: z.object({ 63 | message: z.string(), 64 | }), 65 | }); 66 | 67 | const validationMiddleware = createValidationMiddleware(schema); 68 | const handler: ZephyrHandler = (_, res) => res.send('OK'); 69 | 70 | app.get('/', validationMiddleware, handler); 71 | 72 | app.use(createErrorMiddleware()); 73 | }); 74 | 75 | test('should return 200 with no validation errors', async () => { 76 | const response = await supertest(app) 77 | .get('/') 78 | .query({ message: 'hello' }) 79 | .expect(200); 80 | expect(response.text).toBe('OK'); 81 | expect(response.body).to.not.haveOwnProperty('errors'); 82 | }); 83 | 84 | test('should return 400 with validation errors', async () => { 85 | const response = await supertest(app).get('/').expect(400); 86 | expect(response.body).toHaveProperty('errors'); 87 | expect(response.body.errors).toHaveLength(1); 88 | }); 89 | }); 90 | 91 | describe('createErrorMiddleware()', () => { 92 | test('Default error middleware', async () => { 93 | const app = express(); 94 | 95 | const handlerMiddleware = createHandlerMiddleware(async () => { 96 | throw new Error('Something went wrong'); 97 | }); 98 | app.get('/', handlerMiddleware); 99 | 100 | const errorMiddleware = createErrorMiddleware(); 101 | app.use(errorMiddleware); 102 | 103 | const response = await supertest(app).get('/').expect(500); 104 | expect(response.text).toBe('Internal server error'); 105 | }); 106 | 107 | test('Custom error function in middleware', async () => { 108 | const app = express(); 109 | 110 | const handlerMiddleware = createHandlerMiddleware(async () => { 111 | throw new Error('Something went wrong'); 112 | }); 113 | app.get('/', handlerMiddleware); 114 | 115 | const errorMiddleware = createErrorMiddleware((req, res) => { 116 | return res.status(401).send('Unauthorized'); 117 | }); 118 | app.use(errorMiddleware); 119 | 120 | const response = await supertest(app).get('/').expect(401); 121 | expect(response.text).toBe('Unauthorized'); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /packages/core/src/utils/middlewares.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OnErrorCapturedHook, 3 | ZephyrBaseRequest, 4 | ZephyrHandler, 5 | ZephyrRequest, 6 | ZephyrResponse, 7 | ZephyrRouteSchema, 8 | } from '@zephyr-js/common'; 9 | import { ErrorRequestHandler, RequestHandler } from 'express'; 10 | import { ServerResponse } from 'http'; 11 | import { isValidationError } from './common'; 12 | 13 | export const createHandlerMiddleware = ( 14 | handler: ZephyrHandler, 15 | onErrorCaptured?: OnErrorCapturedHook, 16 | ): RequestHandler => { 17 | return async (req, res, next) => { 18 | try { 19 | const body = await handler(req, res); 20 | if (body && !(body instanceof ServerResponse) && !res.headersSent) { 21 | switch (typeof body) { 22 | case 'string': { 23 | res.send(body); 24 | break; 25 | } 26 | case 'object': { 27 | res.json(body); 28 | break; 29 | } 30 | } 31 | } 32 | } catch (err) { 33 | if (onErrorCaptured) { 34 | console.error(err); 35 | onErrorCaptured(req, res, err); 36 | } else { 37 | return next(err); 38 | } 39 | } 40 | return next(); 41 | }; 42 | }; 43 | 44 | export const createValidationMiddleware = ( 45 | schema: ZephyrRouteSchema, 46 | ): RequestHandler => { 47 | return (req, _, next) => { 48 | try { 49 | schema 50 | .pick({ 51 | params: true, 52 | body: true, 53 | query: true, 54 | }) 55 | .parse(req); 56 | } catch (err) { 57 | return next(err); 58 | } 59 | return next(); 60 | }; 61 | }; 62 | 63 | export type ErrorFunction< 64 | TError = unknown, 65 | TRequest extends ZephyrBaseRequest = any, 66 | TResponse = any, 67 | > = ( 68 | req: ZephyrRequest, 69 | res: ZephyrResponse, 70 | err: TError, 71 | ) => any; 72 | 73 | const defaultOnErrorCaptured: OnErrorCapturedHook = (_, res, err) => { 74 | if (isValidationError(err)) { 75 | const { errors } = err; 76 | return res.status(400).json({ errors }); 77 | } 78 | return res.status(500).send('Internal server error'); 79 | }; 80 | 81 | export const createErrorMiddleware = ( 82 | onErrorCaptured = defaultOnErrorCaptured, 83 | ): ErrorRequestHandler => { 84 | return (err, req, res, next) => { 85 | console.error(err); 86 | onErrorCaptured(req, res, err); 87 | return next(); 88 | }; 89 | }; 90 | -------------------------------------------------------------------------------- /packages/core/src/utils/routes-loader.spec.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { extractPath, loadRoutes } from './routes-loader'; 4 | 5 | describe('extractPath()', () => { 6 | test('should extract path', () => { 7 | const file = '/Users/user/project/src/routes/v1/todos.ts'; 8 | const path = extractPath(file, '/Users/user/project/src/routes'); 9 | expect(path).toEqual('/v1/todos'); 10 | }); 11 | 12 | test('should extract index path', () => { 13 | const file = '/Users/user/project/src/routes/v1/todos/index.ts'; 14 | const path = extractPath(file, '/Users/user/project/src/routes'); 15 | expect(path).toEqual('/v1/todos'); 16 | }); 17 | 18 | test('should format dynamic routes', () => { 19 | const file = '/Users/user/project/src/routes/v1/items/[itemId].ts'; 20 | const path = extractPath(file, '/Users/user/project/src/routes'); 21 | expect(path).toEqual('/v1/items/:itemId'); 22 | }); 23 | }); 24 | 25 | describe('loadRoutes()', () => { 26 | test('should load routes from __mocks__/app/routes directory', async () => { 27 | const dir = path.join(__dirname, '..', '__mocks__', 'app', 'routes'); 28 | const routes = await loadRoutes({ dir }); 29 | 30 | const routeAssertions = [ 31 | { method: 'GET', path: '/:id' }, 32 | { method: 'POST', path: '/sum' }, 33 | { method: 'GET', path: '/todos' }, 34 | { method: 'POST', path: '/todos' }, 35 | { method: 'GET', path: '/v1' }, 36 | { method: 'GET', path: '/v1/todos' }, 37 | { method: 'POST', path: '/v1/todos' }, 38 | { method: 'GET', path: '/v1/:id' }, 39 | { method: 'GET', path: '/' }, 40 | { method: 'GET', path: '/items/:itemId' }, 41 | ]; 42 | 43 | expect(routes).toHaveLength(routeAssertions.length); 44 | 45 | for (const { method, path } of routeAssertions) { 46 | expect( 47 | routes.some((route) => { 48 | return route.method === method && route.path === path; 49 | }), 50 | ).to.be.true; 51 | } 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/core/src/utils/routes-loader.ts: -------------------------------------------------------------------------------- 1 | import { ZephyrRoute, ROUTE_METHODS } from '@zephyr-js/common'; 2 | import glob from 'glob'; 3 | import { normalize, win32, join, parse, posix } from 'path'; 4 | import { DefineRouteOptions } from '../define-route'; 5 | 6 | export function extractPath(file: string, dir: string) { 7 | let path = file.replace(dir, ''); 8 | 9 | // Convert Windows path to Unix path 10 | path = path.replaceAll(win32.sep, posix.sep); 11 | 12 | const parsed = parse(path); 13 | 14 | // Handle index path 15 | path = parsed.dir; 16 | if (parsed.name !== 'index') { 17 | path += (parsed.dir === '/' ? '' : '/') + parsed.name; 18 | } 19 | 20 | // Handle dynamic path 21 | path = path.replaceAll('[', ':').replaceAll(']', ''); 22 | 23 | return path; 24 | } 25 | 26 | type RouteExports = { 27 | [key: string]: (deps: object) => DefineRouteOptions; 28 | }; 29 | 30 | interface LoadRoutesOptions { 31 | dir?: string; 32 | dependencies?: object; 33 | } 34 | 35 | export async function loadRoutes({ 36 | dir = join(process.cwd(), 'src', 'routes'), 37 | dependencies = {}, 38 | }: LoadRoutesOptions = {}): Promise { 39 | const pattern = '**/*.ts'; 40 | 41 | const files = glob 42 | .sync(pattern, { 43 | cwd: dir, 44 | absolute: true, 45 | }) 46 | .map(normalize); 47 | 48 | const routes: ZephyrRoute[] = []; 49 | 50 | await Promise.all( 51 | files.map(async (file) => { 52 | const exports: RouteExports = await import(file); 53 | 54 | for (const method of ROUTE_METHODS) { 55 | const exported = exports[method]; 56 | 57 | if (!exported || typeof exported !== 'function') { 58 | continue; 59 | } 60 | 61 | routes.push({ 62 | ...exported(dependencies), 63 | path: extractPath(file, dir), 64 | method, 65 | }); 66 | } 67 | }), 68 | ); 69 | 70 | return routes; 71 | } 72 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "dist", 6 | "baseUrl": "src", 7 | "paths": { 8 | "@/*": [ 9 | "*" 10 | ] 11 | }, 12 | }, 13 | "include": [ 14 | "src" 15 | ], 16 | "exclude": [ 17 | "node_modules", 18 | "dist", 19 | "src/__mocks__", 20 | "**/*.spec.ts", 21 | "**/*.spec-d.ts" 22 | ] 23 | } -------------------------------------------------------------------------------- /packages/core/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ['text', 'json', 'html'], 7 | exclude: ['src/__mocks__'], 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: custom 3 | ignorePatterns: 4 | - 'src/lib/cli-kit/**/*.js' -------------------------------------------------------------------------------- /packages/create-zephyr-app/README.md: -------------------------------------------------------------------------------- 1 | # Create Zephyr App 2 | 3 | `create-zephyr-app` bootstraps a Zephyr.js app for you with Typescript, ESLint, Prettier, Nodemon. 4 | 5 | **npm** 6 | 7 | ```bash 8 | npm create zephyr-app 9 | ``` 10 | 11 | **yarn** 12 | 13 | ```bash 14 | yarn create zephyr-app 15 | ``` 16 | 17 | **pnpm** 18 | 19 | ```bash 20 | pnpm create zephyr-app 21 | ``` 22 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-zephyr-app", 3 | "version": "0.3.2", 4 | "description": "Create Zephyr powered Express TS applications with one command", 5 | "author": { 6 | "name": "KaKeng Loh", 7 | "email": "kakengloh@gmail.com" 8 | }, 9 | "homepage": "https://github.com/zephyr-js/zephyr", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/zephyr-js/zephyr", 13 | "directory": "packages/create-zephyr-app" 14 | }, 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "license": "MIT", 19 | "keywords": [ 20 | "node", 21 | "express", 22 | "zephyr", 23 | "zephyr.js" 24 | ], 25 | "bin": { 26 | "create-zephyr-app": "dist/index.js" 27 | }, 28 | "files": [ 29 | "dist" 30 | ], 31 | "scripts": { 32 | "lint": "eslint --fix src", 33 | "dev": "ncc build src/index.ts -w -o dist", 34 | "build": "ncc build src/index.ts -o dist --minify --no-cache --no-source-map-register" 35 | }, 36 | "dependencies": { 37 | "chalk": "^5.0.1", 38 | "comment-json": "^4.2.3", 39 | "execa": "^6.1.0", 40 | "giget": "^0.1.7", 41 | "kleur": "^4.1.4", 42 | "ora": "^6.1.0", 43 | "prompts": "^2.4.2", 44 | "strip-ansi": "^7.0.1", 45 | "which-pm-runs": "^1.1.0", 46 | "yargs-parser": "^21.0.1", 47 | "log-update": "^5.0.1", 48 | "sisteransi": "^1.0.5" 49 | }, 50 | "devDependencies": { 51 | "@vercel/ncc": "^0.34.0", 52 | "@types/node": "^18.11.9", 53 | "tsconfig": "workspace:*", 54 | "eslint-config-custom": "workspace:*", 55 | "@types/prompts": "^2.0.14", 56 | "@types/which-pm-runs": "^1.0.0", 57 | "@types/yargs-parser": "^21.0.0", 58 | "tsconfig-paths": "^4.1.0" 59 | } 60 | } -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/gradient.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import type { Ora } from 'ora'; 3 | import ora from 'ora'; 4 | 5 | const gradientColors = [ 6 | '#8693AB', 7 | '#7D97B0', 8 | '#7799B3', 9 | '#6F9CB8', 10 | '#679FBC', 11 | '#5DA3C1', 12 | '#56A6C5', 13 | '#4EA8C8', 14 | '#48AACA', 15 | '#3EADCF', 16 | ]; 17 | 18 | export const rocketAscii = '■■▶'; 19 | 20 | // get a reference to scroll through while loading 21 | // visual representation of what this generates: 22 | // gradientColors: "..xxXX" 23 | // referenceGradient: "..xxXXXXxx....xxXX" 24 | const referenceGradient = [ 25 | ...gradientColors, 26 | // draw the reverse of the gradient without 27 | // accidentally mutating the gradient (ugh, reverse()) 28 | ...[...gradientColors].reverse(), 29 | ...gradientColors, 30 | ]; 31 | 32 | // async-friendly setTimeout 33 | const sleep = (time: number) => 34 | new Promise((resolve) => { 35 | setTimeout(resolve, time); 36 | }); 37 | 38 | function getGradientAnimFrames() { 39 | const frames = []; 40 | for (let start = 0; start < gradientColors.length * 2; start++) { 41 | const end = start + gradientColors.length - 1; 42 | frames.push( 43 | referenceGradient 44 | .slice(start, end) 45 | .map((g) => chalk.bgHex(g)(' ')) 46 | .join(''), 47 | ); 48 | } 49 | return frames; 50 | } 51 | 52 | function getIntroAnimFrames() { 53 | const frames = []; 54 | for (let end = 1; end <= gradientColors.length; end++) { 55 | const leadingSpacesArr = Array.from( 56 | new Array(Math.abs(gradientColors.length - end - 1)), 57 | () => ' ', 58 | ); 59 | const gradientArr = gradientColors 60 | .slice(0, end) 61 | .map((g) => chalk.bgHex(g)(' ')); 62 | frames.push([...leadingSpacesArr, ...gradientArr].join('')); 63 | } 64 | return frames; 65 | } 66 | 67 | /** 68 | * Generate loading spinner with rocket flames! 69 | * @param text display text next to rocket 70 | * @returns Ora spinner for running .stop() 71 | */ 72 | export async function loadWithRocketGradient(text: string): Promise { 73 | const frames = getIntroAnimFrames(); 74 | const intro = ora({ 75 | spinner: { 76 | interval: 30, 77 | frames, 78 | }, 79 | text: `${rocketAscii} ${text}`, 80 | }); 81 | intro.start(); 82 | await sleep((frames.length - 1) * intro.interval); 83 | intro.stop(); 84 | const spinner = ora({ 85 | spinner: { 86 | interval: 80, 87 | frames: getGradientAnimFrames(), 88 | }, 89 | text: `${rocketAscii} ${text}`, 90 | }).start(); 91 | 92 | return spinner; 93 | } 94 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/index.ts: -------------------------------------------------------------------------------- 1 | import { color, generateProjectName } from '@/lib/cli-kit'; 2 | import { forceUnicode } from '@/lib/cli-kit/utils'; 3 | import { execa, execaCommand } from 'execa'; 4 | import fs from 'fs'; 5 | import { downloadTemplate } from 'giget'; 6 | import { bold, dim, green, reset } from 'kleur/colors'; 7 | import ora from 'ora'; 8 | import path from 'path'; 9 | import prompts from 'prompts'; 10 | import detectPackageManager from 'which-pm-runs'; 11 | import yargs from 'yargs-parser'; 12 | import { loadWithRocketGradient, rocketAscii } from './gradient'; 13 | import { logger } from './logger'; 14 | import { info, nextSteps } from './messages'; 15 | 16 | // NOTE: In the v7.x version of npm, the default behavior of `npm init` was changed 17 | // to no longer require `--` to pass args and instead pass `--` directly to us. This 18 | // broke our arg parser, since `--` is a special kind of flag. Filtering for `--` here 19 | // fixes the issue so that create-astro now works on all npm version. 20 | const cleanArgv = process.argv.filter((arg) => arg !== '--'); 21 | const args = yargs(cleanArgv, { boolean: ['fancy'] }); 22 | prompts.override(args); 23 | 24 | // Enable full unicode support if the `--fancy` flag is passed 25 | if (args.fancy) { 26 | forceUnicode(); 27 | } 28 | 29 | export function mkdirp(dir: string) { 30 | try { 31 | fs.mkdirSync(dir, { recursive: true }); 32 | } catch (e: any) { 33 | if (e.code === 'EEXIST') return; 34 | throw e; 35 | } 36 | } 37 | 38 | // Some existing files and directories can be safely ignored when checking if a directory is a valid project directory. 39 | // https://github.com/facebook/create-react-app/blob/d960b9e38c062584ff6cfb1a70e1512509a966e7/packages/create-react-app/createReactApp.js#L907-L934 40 | const VALID_PROJECT_DIRECTORY_SAFE_LIST = [ 41 | '.DS_Store', 42 | '.git', 43 | '.gitattributes', 44 | '.gitignore', 45 | '.gitlab-ci.yml', 46 | '.hg', 47 | '.hgcheck', 48 | '.hgignore', 49 | '.idea', 50 | '.npmignore', 51 | '.travis.yml', 52 | '.yarn', 53 | '.yarnrc.yml', 54 | 'docs', 55 | 'LICENSE', 56 | 'mkdocs.yml', 57 | 'Thumbs.db', 58 | /\.iml$/, 59 | /^npm-debug\.log/, 60 | /^yarn-debug\.log/, 61 | /^yarn-error\.log/, 62 | ]; 63 | 64 | function isValidProjectDirectory(dirPath: string) { 65 | if (!fs.existsSync(dirPath)) { 66 | return true; 67 | } 68 | 69 | const conflicts = fs.readdirSync(dirPath).filter((content) => { 70 | return !VALID_PROJECT_DIRECTORY_SAFE_LIST.some((safeContent) => { 71 | return typeof safeContent === 'string' 72 | ? content === safeContent 73 | : safeContent.test(content); 74 | }); 75 | }); 76 | 77 | return conflicts.length === 0; 78 | } 79 | 80 | const FILES_TO_REMOVE = [ 81 | '.stackblitzrc', 82 | 'sandbox.config.json', 83 | 'CHANGELOG.md', 84 | ]; // some files are only needed for online editors when using astro.new. Remove for create-astro installs. 85 | 86 | async function main() { 87 | const pkgManager = detectPackageManager()?.name || 'npm'; 88 | 89 | logger.debug('Verbose logging turned on'); 90 | 91 | let cwd = args['_'][2] as string; 92 | 93 | if (cwd && isValidProjectDirectory(cwd)) { 94 | const acknowledgeProjectDir = ora({ 95 | color: 'green', 96 | text: `Using ${bold(cwd)} as project directory.`, 97 | }); 98 | acknowledgeProjectDir.succeed(); 99 | } 100 | 101 | if (!cwd || !isValidProjectDirectory(cwd)) { 102 | const notEmptyMsg = (dirPath: string) => `"${bold(dirPath)}" is not empty!`; 103 | 104 | if (!isValidProjectDirectory(cwd)) { 105 | const rejectProjectDir = ora({ color: 'red', text: notEmptyMsg(cwd) }); 106 | rejectProjectDir.fail(); 107 | } 108 | const dirResponse = await prompts( 109 | { 110 | type: 'text', 111 | name: 'directory', 112 | message: 'Where would you like to create your new project?', 113 | initial: generateProjectName(), 114 | validate(value) { 115 | if (!isValidProjectDirectory(value)) { 116 | return notEmptyMsg(value); 117 | } 118 | return true; 119 | }, 120 | }, 121 | { 122 | onCancel: () => ora().info(dim('Operation cancelled. See you later!')), 123 | }, 124 | ); 125 | cwd = dirResponse.directory; 126 | } 127 | 128 | if (!cwd) { 129 | ora().info(dim('No directory provided. See you later!')); 130 | process.exit(1); 131 | } 132 | 133 | // const options = await prompts( 134 | // [ 135 | // { 136 | // type: 'select', 137 | // name: 'template', 138 | // message: 'How would you like to setup your new project?', 139 | // choices: TEMPLATES, 140 | // }, 141 | // ], 142 | // { 143 | // onCancel: () => ora().info(dim('Operation cancelled. See you later!')), 144 | // }, 145 | // ); 146 | 147 | // if (!options.template || options.template === true) { 148 | // ora().info(dim('No template provided. See you later!')); 149 | // process.exit(1); 150 | // } 151 | 152 | const templateSpinner = await loadWithRocketGradient( 153 | 'Copying project files...', 154 | ); 155 | 156 | const hash = args.commit ? `#${args.commit}` : ''; 157 | 158 | // const isThirdParty = options.template.includes('/'); 159 | // const templateTarget = isThirdParty 160 | // ? options.template 161 | // : `zephyr-js/zephyr/examples/${options.template}#latest`; 162 | 163 | // Copy 164 | if (!args.dryRun) { 165 | const templateTarget = 'zephyr-js/zephyr/examples/basic'; 166 | 167 | try { 168 | await downloadTemplate(`${templateTarget}${hash}`, { 169 | force: true, 170 | provider: 'github', 171 | cwd, 172 | dir: '.', 173 | }); 174 | } catch (err: any) { 175 | fs.rmdirSync(cwd); 176 | // if (err.message.includes('404')) { 177 | // console.error( 178 | // `Template ${color.underline(options.template)} does not exist!`, 179 | // ); 180 | // } else { 181 | // console.error(err.message); 182 | // } 183 | console.error(err.message); 184 | process.exit(1); 185 | } 186 | 187 | // Post-process in parallel 188 | await Promise.all( 189 | FILES_TO_REMOVE.map(async (file) => { 190 | const fileLoc = path.resolve(path.join(cwd, file)); 191 | if (fs.existsSync(fileLoc)) { 192 | return fs.promises.rm(fileLoc, {}); 193 | } 194 | }), 195 | ); 196 | } 197 | 198 | templateSpinner.text = green('Template copied!'); 199 | templateSpinner.succeed(); 200 | 201 | const installResponse = await prompts( 202 | { 203 | type: 'confirm', 204 | name: 'install', 205 | message: `Would you like to install ${pkgManager} dependencies? ${reset( 206 | dim('(recommended)'), 207 | )}`, 208 | initial: true, 209 | }, 210 | { 211 | onCancel: () => { 212 | ora().info( 213 | dim( 214 | 'Operation cancelled. Your project folder has already been created, however no dependencies have been installed', 215 | ), 216 | ); 217 | process.exit(1); 218 | }, 219 | }, 220 | ); 221 | 222 | if (args.dryRun) { 223 | ora().info(dim('--dry-run enabled, skipping.')); 224 | } else if (installResponse.install) { 225 | const installExec = execa(pkgManager, ['install'], { cwd }); 226 | const installingPackagesMsg = `Installing packages${emojiWithFallback( 227 | ' 📦', 228 | '...', 229 | )}`; 230 | const installSpinner = await loadWithRocketGradient(installingPackagesMsg); 231 | await new Promise((resolve, reject) => { 232 | installExec.stdout?.on('data', function (data) { 233 | installSpinner.text = `${rocketAscii} ${installingPackagesMsg}\n${bold( 234 | `[${pkgManager}]`, 235 | )} ${data}`; 236 | }); 237 | installExec.on('error', (error) => reject(error)); 238 | installExec.on('close', () => resolve()); 239 | }); 240 | installSpinner.text = green('Packages installed!'); 241 | installSpinner.succeed(); 242 | } else { 243 | await info('No problem!', 'Remember to install dependencies after setup.'); 244 | } 245 | 246 | const gitResponse = await prompts( 247 | { 248 | type: 'confirm', 249 | name: 'git', 250 | message: `Would you like to initialize a new git repository? ${reset( 251 | dim('(optional)'), 252 | )}`, 253 | initial: true, 254 | }, 255 | { 256 | onCancel: () => { 257 | ora().info( 258 | dim( 259 | 'Operation cancelled. No worries, your project folder has already been created', 260 | ), 261 | ); 262 | process.exit(1); 263 | }, 264 | }, 265 | ); 266 | 267 | if (args.dryRun) { 268 | ora().info(dim('--dry-run enabled, skipping.')); 269 | } else if (gitResponse.git) { 270 | await execaCommand('git init', { cwd }); 271 | ora().succeed('Git repository created!'); 272 | } else { 273 | await info( 274 | 'Sounds good!', 275 | `You can come back and run ${color.reset('git init')}${color.dim( 276 | ' later.', 277 | )}`, 278 | ); 279 | } 280 | 281 | if (args.dryRun) { 282 | ora().info(dim('--dry-run enabled, skipping.')); 283 | } 284 | 285 | const projectDir = path.relative(process.cwd(), cwd); 286 | const devCmd = pkgManager === 'npm' ? 'npm run dev' : `${pkgManager} dev`; 287 | await nextSteps({ projectDir, devCmd }); 288 | } 289 | 290 | function emojiWithFallback(char: string, fallback: string) { 291 | return process.platform !== 'win32' ? char : fallback; 292 | } 293 | 294 | main().catch((err) => { 295 | console.error(err); 296 | process.exit(1); 297 | }); 298 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/index.ts: -------------------------------------------------------------------------------- 1 | export { default as color } from 'chalk'; 2 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 3 | // @ts-ignore 4 | export { default as prompt } from './prompt/prompt'; 5 | export * from './spinner/index'; 6 | export * from './messages/index'; 7 | export * from './project/index'; 8 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/messages/index.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | 3 | export const label = ( 4 | text: string, 5 | c = color.bgHex('#883AE2'), 6 | t = color.whiteBright, 7 | ) => c(` ${t(text)} `); 8 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/project/index.ts: -------------------------------------------------------------------------------- 1 | import { nouns, adjectives } from './words'; 2 | import { random } from '../utils'; 3 | 4 | export function generateProjectName() { 5 | const adjective = random(adjectives); 6 | const validNouns = nouns.filter((n) => n[0] === adjective[0]); 7 | const noun = random(validNouns.length > 0 ? validNouns : nouns); 8 | return `${adjective}-${noun}`; 9 | } 10 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/project/words.ts: -------------------------------------------------------------------------------- 1 | export const nouns = [ 2 | 'ablation', 3 | 'accretion', 4 | 'altitude', 5 | 'antimatter', 6 | 'aperture', 7 | 'apogee', 8 | 'ascension', 9 | 'asteroid', 10 | 'atmosphere', 11 | 'aurora', 12 | 'axis', 13 | 'azimuth', 14 | 'bar', 15 | 'belt', 16 | 'binary', 17 | 'chaos', 18 | 'chasm', 19 | 'chroma', 20 | 'cloud', 21 | 'cluster', 22 | 'comet', 23 | 'conjunction', 24 | 'crater', 25 | 'cycle', 26 | 'debris', 27 | 'disk', 28 | 'doppler', 29 | 'dwarf', 30 | 'eclipse', 31 | 'ellipse', 32 | 'ephemera', 33 | 'equator', 34 | 'equinox', 35 | 'escape', 36 | 'event', 37 | 'field', 38 | 'filament', 39 | 'fireball', 40 | 'flare', 41 | 'force', 42 | 'fusion', 43 | 'galaxy', 44 | 'gamma', 45 | 'giant', 46 | 'gravity', 47 | 'group', 48 | 'halo', 49 | 'heliosphere', 50 | 'horizon', 51 | 'hubble', 52 | 'ice', 53 | 'inclination', 54 | 'iron', 55 | 'jet', 56 | 'kelvin', 57 | 'kuiper', 58 | 'light', 59 | 'limb', 60 | 'limit', 61 | 'luminosity', 62 | 'magnitude', 63 | 'main', 64 | 'mass', 65 | 'matter', 66 | 'meridian', 67 | 'metal', 68 | 'meteor', 69 | 'meteorite', 70 | 'moon', 71 | 'motion', 72 | 'nadir', 73 | 'nebula', 74 | 'neutron', 75 | 'nova', 76 | 'orbit', 77 | 'parallax', 78 | 'parsec', 79 | 'phase', 80 | 'photon', 81 | 'planet', 82 | 'plasma', 83 | 'point', 84 | 'pulsar', 85 | 'radiation', 86 | 'remnant', 87 | 'resonance', 88 | 'ring', 89 | 'rotation', 90 | 'satellite', 91 | 'series', 92 | 'shell', 93 | 'shepherd', 94 | 'singularity', 95 | 'solstice', 96 | 'spectrum', 97 | 'sphere', 98 | 'spiral', 99 | 'star', 100 | 'telescope', 101 | 'transit', 102 | 'tower', 103 | 'velocity', 104 | 'virgo', 105 | 'visual', 106 | 'wavelength', 107 | 'wind', 108 | 'zenith', 109 | 'zero', 110 | ]; 111 | 112 | export const adjectives = [ 113 | 'absent', 114 | 'absolute', 115 | 'adorable', 116 | 'afraid', 117 | 'agreeable', 118 | 'apparent', 119 | 'awesome', 120 | 'beneficial', 121 | 'better', 122 | 'bizarre', 123 | 'bustling', 124 | 'callous', 125 | 'capricious', 126 | 'celestial', 127 | 'certain', 128 | 'civil', 129 | 'cosmic', 130 | 'curved', 131 | 'dangerous', 132 | 'dark', 133 | 'deeply', 134 | 'density', 135 | 'dreary', 136 | 'eccentric', 137 | 'ecliptic', 138 | 'electrical', 139 | 'eminent', 140 | 'evolved', 141 | 'exotic', 142 | 'extinct', 143 | 'extra', 144 | 'faithful', 145 | 'far', 146 | 'flaky', 147 | 'former', 148 | 'fumbling', 149 | 'growing', 150 | 'grubby', 151 | 'gullible', 152 | 'helpless', 153 | 'hideous', 154 | 'hilarious', 155 | 'inferior', 156 | 'interstellar', 157 | 'irregular', 158 | 'laughable', 159 | 'lonely', 160 | 'loose', 161 | 'lunar', 162 | 'mad', 163 | 'magical', 164 | 'majestic', 165 | 'major', 166 | 'minor', 167 | 'mixed', 168 | 'molecular', 169 | 'nasty', 170 | 'nebulous', 171 | 'nuclear', 172 | 'opposite', 173 | 'ossified', 174 | 'pale', 175 | 'popular', 176 | 'proper', 177 | 'proto', 178 | 'peaceful', 179 | 'proud', 180 | 'puffy', 181 | 'radiant', 182 | 'receptive', 183 | 'regular', 184 | 'retrograde', 185 | 'second', 186 | 'shaggy', 187 | 'sleepy', 188 | 'shaky', 189 | 'short', 190 | 'smoggy', 191 | 'solar', 192 | 'spiffy', 193 | 'squalid', 194 | 'square', 195 | 'squealing', 196 | 'stale', 197 | 'steadfast', 198 | 'steadfast', 199 | 'stellar', 200 | 'strong', 201 | 'subsequent', 202 | 'super', 203 | 'superior', 204 | 'tasty', 205 | 'tender', 206 | 'terrestrial', 207 | 'tested', 208 | 'tidal', 209 | 'tremendous', 210 | 'ultraviolet', 211 | 'united', 212 | 'useful', 213 | 'useless', 214 | 'utter', 215 | 'verdant', 216 | 'vigorous', 217 | 'violet', 218 | 'visible', 219 | 'wandering', 220 | 'whole', 221 | 'wretched', 222 | 'zany', 223 | 'zapping', 224 | ]; 225 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/prompt/elements/confirm.js: -------------------------------------------------------------------------------- 1 | import Prompt from './prompt'; 2 | import { erase, cursor } from 'sisteransi'; 3 | import color from 'chalk'; 4 | import clear, { strip } from '../util/clear'; 5 | 6 | /** 7 | * ConfirmPrompt Base Element 8 | * @param {Object} opts Options 9 | * @param {String} opts.message Message 10 | * @param {Boolean} [opts.initial] Default value (true/false) 11 | * @param {Stream} [opts.stdin] The Readable stream to listen to 12 | * @param {Stream} [opts.stdout] The Writable stream to write readline data to 13 | * @param {String} [opts.yes] The "Yes" label 14 | * @param {String} [opts.yesOption] The "Yes" option when choosing between yes/no 15 | * @param {String} [opts.no] The "No" label 16 | * @param {String} [opts.noOption] The "No" option when choosing between yes/no 17 | */ 18 | export default class ConfirmPrompt extends Prompt { 19 | constructor(opts = {}) { 20 | super(opts); 21 | this.label = opts.label; 22 | this.hint = opts.hint ?? ''; 23 | this.msg = opts.message; 24 | this.value = opts.initial; 25 | this.initialValue = !!opts.initial; 26 | this.choices = [ 27 | { value: true, label: 'Yes' }, 28 | { value: false, label: 'No' }, 29 | ]; 30 | this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); 31 | this.render(); 32 | } 33 | 34 | reset() { 35 | this.value = this.initialValue; 36 | this.fire(); 37 | this.render(); 38 | } 39 | 40 | exit() { 41 | this.abort(); 42 | } 43 | 44 | abort() { 45 | this.done = this.aborted = true; 46 | this.fire(); 47 | this.render(); 48 | this.out.write('\n'); 49 | this.close(); 50 | } 51 | 52 | submit() { 53 | this.value = this.value || false; 54 | this.cursor = this.choices.findIndex((c) => c.value === this.value); 55 | this.done = true; 56 | this.aborted = false; 57 | this.fire(); 58 | this.render(); 59 | this.out.write('\n'); 60 | this.close(); 61 | } 62 | 63 | moveCursor(n) { 64 | this.cursor = n; 65 | this.value = this.choices[n].value; 66 | this.fire(); 67 | } 68 | 69 | reset() { 70 | this.moveCursor(0); 71 | this.fire(); 72 | this.render(); 73 | } 74 | 75 | first() { 76 | this.moveCursor(0); 77 | this.render(); 78 | } 79 | 80 | last() { 81 | this.moveCursor(this.choices.length - 1); 82 | this.render(); 83 | } 84 | 85 | left() { 86 | if (this.cursor === 0) { 87 | this.moveCursor(this.choices.length - 1); 88 | } else { 89 | this.moveCursor(this.cursor - 1); 90 | } 91 | this.render(); 92 | } 93 | 94 | right() { 95 | if (this.cursor === this.choices.length - 1) { 96 | this.moveCursor(0); 97 | } else { 98 | this.moveCursor(this.cursor + 1); 99 | } 100 | this.render(); 101 | } 102 | 103 | _(c, key) { 104 | if (!Number.isNaN(Number.parseInt(c))) { 105 | const n = Number.parseInt(c) - 1; 106 | this.moveCursor(n); 107 | this.render(); 108 | return this.submit(); 109 | } 110 | if (c.toLowerCase() === 'y') { 111 | this.value = true; 112 | return this.submit(); 113 | } 114 | if (c.toLowerCase() === 'n') { 115 | this.value = false; 116 | return this.submit(); 117 | } 118 | return; 119 | } 120 | 121 | render() { 122 | if (this.closed) return; 123 | if (this.firstRender) this.out.write(cursor.hide); 124 | else this.out.write(clear(this.outputText, this.out.columns)); 125 | super.render(); 126 | 127 | this.outputText = [ 128 | '\n', 129 | this.label, 130 | ' ', 131 | this.msg, 132 | this.done ? '' : this.hint ? color.dim(` (${this.hint})`) : '', 133 | '\n', 134 | ]; 135 | 136 | this.outputText.push(' '.repeat(strip(this.label).length)); 137 | 138 | if (this.done) { 139 | this.outputText.push( 140 | ' ', 141 | color.dim(`${this.choices[this.cursor].label}`), 142 | ); 143 | } else { 144 | this.outputText.push( 145 | ' ', 146 | this.choices 147 | .map((choice, i) => 148 | i === this.cursor 149 | ? `${color.green('●')} ${choice.label} ` 150 | : color.dim(`○ ${choice.label} `), 151 | ) 152 | .join(color.dim(' ')), 153 | ); 154 | } 155 | this.outputText = this.outputText.join(''); 156 | 157 | this.out.write(erase.line + cursor.to(0) + this.outputText); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/prompt/elements/index.js: -------------------------------------------------------------------------------- 1 | export { default as TextPrompt } from './text'; 2 | export { default as ConfirmPrompt } from './confirm'; 3 | export { default as SelectPrompt } from './select'; 4 | export { default as MultiselectPrompt } from './multiselect'; 5 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/prompt/elements/multiselect.js: -------------------------------------------------------------------------------- 1 | import Prompt from './prompt'; 2 | import { erase, cursor } from 'sisteransi'; 3 | import color from 'chalk'; 4 | import clear, { strip } from '../util/clear'; 5 | 6 | /** 7 | * ConfirmPrompt Base Element 8 | * @param {Object} opts Options 9 | * @param {String} opts.message Message 10 | * @param {Boolean} [opts.initial] Default value (true/false) 11 | * @param {Stream} [opts.stdin] The Readable stream to listen to 12 | * @param {Stream} [opts.stdout] The Writable stream to write readline data to 13 | * @param {String} [opts.yes] The "Yes" label 14 | * @param {String} [opts.yesOption] The "Yes" option when choosing between yes/no 15 | * @param {String} [opts.no] The "No" label 16 | * @param {String} [opts.noOption] The "No" option when choosing between yes/no 17 | */ 18 | export default class MultiselectPrompt extends Prompt { 19 | constructor(opts = {}) { 20 | super(opts); 21 | this.label = opts.label; 22 | this.msg = opts.message; 23 | this.value = []; 24 | this.choices = opts.choices || []; 25 | this.initialValue = opts.initial || this.choices[0].value; 26 | this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); 27 | this.render(); 28 | } 29 | 30 | reset() { 31 | this.value = []; 32 | this.fire(); 33 | this.render(); 34 | } 35 | 36 | exit() { 37 | this.abort(); 38 | } 39 | 40 | abort() { 41 | this.done = this.aborted = true; 42 | this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); 43 | this.fire(); 44 | this.render(); 45 | this.out.write('\n'); 46 | this.close(); 47 | } 48 | 49 | submit() { 50 | return this.toggle(); 51 | } 52 | 53 | finish() { 54 | this.value = this.value; 55 | this.done = true; 56 | this.aborted = false; 57 | this.fire(); 58 | this.render(); 59 | this.out.write('\n'); 60 | this.close(); 61 | } 62 | 63 | moveCursor(n) { 64 | this.cursor = n; 65 | this.fire(); 66 | } 67 | 68 | toggle() { 69 | const choice = this.choices[this.cursor]; 70 | if (!choice) return; 71 | choice.selected = !choice.selected; 72 | this.render(); 73 | } 74 | 75 | _(c, key) { 76 | if (c === ' ') { 77 | return this.toggle(); 78 | } 79 | if (c.toLowerCase() === 'c') { 80 | return this.finish(); 81 | } 82 | return; 83 | } 84 | 85 | reset() { 86 | this.moveCursor(0); 87 | this.fire(); 88 | this.render(); 89 | } 90 | 91 | first() { 92 | this.moveCursor(0); 93 | this.render(); 94 | } 95 | 96 | last() { 97 | this.moveCursor(this.choices.length - 1); 98 | this.render(); 99 | } 100 | 101 | up() { 102 | if (this.cursor === 0) { 103 | this.moveCursor(this.choices.length - 1); 104 | } else { 105 | this.moveCursor(this.cursor - 1); 106 | } 107 | this.render(); 108 | } 109 | 110 | down() { 111 | if (this.cursor === this.choices.length - 1) { 112 | this.moveCursor(0); 113 | } else { 114 | this.moveCursor(this.cursor + 1); 115 | } 116 | this.render(); 117 | } 118 | 119 | render() { 120 | if (this.closed) return; 121 | if (this.firstRender) this.out.write(cursor.hide); 122 | else this.out.write(clear(this.outputText, this.out.columns)); 123 | super.render(); 124 | 125 | this.outputText = ['\n', this.label, ' ', this.msg, '\n']; 126 | 127 | const prefix = ' '.repeat(strip(this.label).length); 128 | 129 | if (this.done) { 130 | this.outputText.push( 131 | this.choices 132 | .map((choice) => 133 | choice.selected 134 | ? `${prefix} ${color.dim(`${choice.label}`)}\n` 135 | : '', 136 | ) 137 | .join('') 138 | .trimEnd(), 139 | ); 140 | } else { 141 | this.outputText.push( 142 | this.choices 143 | .map((choice, i) => 144 | i === this.cursor 145 | ? `${prefix.slice(0, -2)}${color.cyanBright('▶')} ${ 146 | choice.selected ? color.green('■') : color.whiteBright('□') 147 | } ${color.underline(choice.label)} ${ 148 | choice.hint ? color.dim(choice.hint) : '' 149 | }` 150 | : color[choice.selected ? 'reset' : 'dim']( 151 | `${prefix} ${choice.selected ? color.green('■') : '□'} ${ 152 | choice.label 153 | } `, 154 | ), 155 | ) 156 | .join('\n'), 157 | ); 158 | this.outputText.push( 159 | `\n\n${prefix} Press ${color.inverse(' C ')} to continue`, 160 | ); 161 | } 162 | this.outputText = this.outputText.join(''); 163 | 164 | this.out.write(erase.line + cursor.to(0) + this.outputText); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/prompt/elements/prompt.js: -------------------------------------------------------------------------------- 1 | import readline from 'node:readline'; 2 | import EventEmitter from 'node:events'; 3 | 4 | import { action } from '../util/action'; 5 | import { beep, cursor } from 'sisteransi'; 6 | import color from 'chalk'; 7 | import { strip } from '../util/clear'; 8 | 9 | /** 10 | * Base prompt skeleton 11 | * @param {Stream} [opts.stdin] The Readable stream to listen to 12 | * @param {Stream} [opts.stdout] The Writable stream to write readline data to 13 | */ 14 | export default class Prompt extends EventEmitter { 15 | constructor(opts = {}) { 16 | super(); 17 | 18 | this.firstRender = true; 19 | this.in = opts.stdin || process.stdin; 20 | this.out = opts.stdout || process.stdout; 21 | this.onRender = (opts.onRender || (() => void 0)).bind(this); 22 | const rl = readline.createInterface({ 23 | input: this.in, 24 | escapeCodeTimeout: 50, 25 | }); 26 | readline.emitKeypressEvents(this.in, rl); 27 | 28 | if (this.in.isTTY) this.in.setRawMode(true); 29 | const isSelect = 30 | ['SelectPrompt', 'MultiselectPrompt'].indexOf(this.constructor.name) > -1; 31 | const keypress = (str, key) => { 32 | if (this.in.isTTY) this.in.setRawMode(true); 33 | let a = action(key, isSelect); 34 | if (a === false) { 35 | this._ && this._(str, key); 36 | } else if (typeof this[a] === 'function') { 37 | this[a](key); 38 | } 39 | }; 40 | 41 | this.close = () => { 42 | this.out.write(cursor.show); 43 | this.in.removeListener('keypress', keypress); 44 | if (this.in.isTTY) this.in.setRawMode(false); 45 | rl.close(); 46 | this.emit( 47 | this.aborted ? 'abort' : this.exited ? 'exit' : 'submit', 48 | this.value, 49 | ); 50 | this.closed = true; 51 | }; 52 | 53 | this.in.on('keypress', keypress); 54 | } 55 | 56 | bell() { 57 | this.out.write(beep); 58 | } 59 | 60 | fire() { 61 | this.emit('state', { 62 | value: this.value, 63 | aborted: !!this.aborted, 64 | exited: !!this.exited, 65 | }); 66 | } 67 | 68 | render() { 69 | this.onRender(color); 70 | if (this.firstRender) this.firstRender = false; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/prompt/elements/select.js: -------------------------------------------------------------------------------- 1 | import Prompt from './prompt'; 2 | import { erase, cursor } from 'sisteransi'; 3 | import color from 'chalk'; 4 | import { useAscii } from '../../utils/index'; 5 | import clear, { strip } from '../util/clear'; 6 | 7 | export default class SelectPrompt extends Prompt { 8 | constructor(opts = {}) { 9 | super(opts); 10 | this.label = opts.label; 11 | this.hint = opts.hint ?? ''; 12 | this.msg = opts.message; 13 | this.value = opts.initial; 14 | this.choices = opts.choices || []; 15 | this.initialValue = opts.initial || this.choices[0].value; 16 | this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); 17 | this.search = null; 18 | this.render(); 19 | } 20 | 21 | reset() { 22 | this.value = this.initialValue; 23 | this.fire(); 24 | this.render(); 25 | } 26 | 27 | exit() { 28 | this.abort(); 29 | } 30 | 31 | abort() { 32 | this.done = this.aborted = true; 33 | this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); 34 | this.fire(); 35 | this.render(); 36 | this.out.write('\n'); 37 | this.close(); 38 | } 39 | 40 | submit() { 41 | this.value = this.value || false; 42 | this.cursor = this.choices.findIndex((c) => c.value === this.value); 43 | this.done = true; 44 | this.aborted = false; 45 | this.fire(); 46 | this.render(); 47 | this.out.write('\n'); 48 | this.close(); 49 | } 50 | 51 | delete() { 52 | this.search = null; 53 | this.render(); 54 | } 55 | 56 | _(c, key) { 57 | if (this.timeout) clearTimeout(this.timeout); 58 | if (!Number.isNaN(Number.parseInt(c))) { 59 | const n = Number.parseInt(c) - 1; 60 | this.moveCursor(n); 61 | this.render(); 62 | return this.submit(); 63 | } 64 | this.search = this.search || ''; 65 | this.search += c.toLowerCase(); 66 | const choices = !this.search 67 | ? this.choices.slice(this.cursor) 68 | : this.choices; 69 | const n = choices.findIndex((c) => 70 | c.label.toLowerCase().includes(this.search), 71 | ); 72 | if (n > -1) { 73 | this.moveCursor(n); 74 | this.render(); 75 | } 76 | this.timeout = setTimeout(() => { 77 | this.search = null; 78 | }, 500); 79 | } 80 | 81 | moveCursor(n) { 82 | this.cursor = n; 83 | this.value = this.choices[n].value; 84 | this.fire(); 85 | } 86 | 87 | reset() { 88 | this.moveCursor(0); 89 | this.fire(); 90 | this.render(); 91 | } 92 | 93 | first() { 94 | this.moveCursor(0); 95 | this.render(); 96 | } 97 | 98 | last() { 99 | this.moveCursor(this.choices.length - 1); 100 | this.render(); 101 | } 102 | 103 | up() { 104 | if (this.cursor === 0) { 105 | this.moveCursor(this.choices.length - 1); 106 | } else { 107 | this.moveCursor(this.cursor - 1); 108 | } 109 | this.render(); 110 | } 111 | 112 | down() { 113 | if (this.cursor === this.choices.length - 1) { 114 | this.moveCursor(0); 115 | } else { 116 | this.moveCursor(this.cursor + 1); 117 | } 118 | this.render(); 119 | } 120 | 121 | highlight(label) { 122 | if (!this.search) return label; 123 | const n = label.toLowerCase().indexOf(this.search.toLowerCase()); 124 | if (n === -1) return label; 125 | return [ 126 | label.slice(0, n), 127 | color.underline(label.slice(n, n + this.search.length)), 128 | label.slice(n + this.search.length), 129 | ].join(''); 130 | } 131 | 132 | render() { 133 | if (this.closed) return; 134 | if (this.firstRender) this.out.write(cursor.hide); 135 | else this.out.write(clear(this.outputText, this.out.columns)); 136 | super.render(); 137 | 138 | this.outputText = [ 139 | '\n', 140 | this.label, 141 | ' ', 142 | this.msg, 143 | this.done 144 | ? '' 145 | : this.hint 146 | ? (this.out.columns < 80 ? '\n' + ' '.repeat(8) : '') + 147 | color.dim(` (${this.hint})`) 148 | : '', 149 | '\n', 150 | ]; 151 | 152 | const prefix = ' '.repeat(strip(this.label).length); 153 | 154 | if (this.done) { 155 | this.outputText.push( 156 | `${prefix} `, 157 | color.dim(`${this.choices[this.cursor]?.label}`), 158 | ); 159 | } else { 160 | this.outputText.push( 161 | this.choices 162 | .map((choice, i) => 163 | i === this.cursor 164 | ? `${prefix} ${color.green( 165 | useAscii() ? '>' : '●', 166 | )} ${this.highlight(choice.label)} ${ 167 | choice.hint ? color.dim(choice.hint) : '' 168 | }` 169 | : color.dim( 170 | `${prefix} ${useAscii() ? '—' : '○'} ${choice.label} `, 171 | ), 172 | ) 173 | .join('\n'), 174 | ); 175 | } 176 | this.outputText = this.outputText.join(''); 177 | 178 | this.out.write(erase.line + cursor.to(0) + this.outputText); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/prompt/elements/text.js: -------------------------------------------------------------------------------- 1 | import Prompt from './prompt'; 2 | import { erase, cursor } from 'sisteransi'; 3 | import color from 'chalk'; 4 | import { useAscii } from '../../utils'; 5 | import clear, { lines, strip } from '../util/clear'; 6 | 7 | /** 8 | * TextPrompt Base Element 9 | * @param {Object} opts Options 10 | * @param {String} opts.message Message 11 | * @param {String} [opts.style='default'] Render style 12 | * @param {String} [opts.initial] Default value 13 | * @param {Function} [opts.validate] Validate function 14 | * @param {Stream} [opts.stdin] The Readable stream to listen to 15 | * @param {Stream} [opts.stdout] The Writable stream to write readline data to 16 | * @param {String} [opts.error] The invalid error label 17 | */ 18 | export default class TextPrompt extends Prompt { 19 | constructor(opts = {}) { 20 | super(opts); 21 | this.transform = { render: (v) => v, scale: 1 }; 22 | this.label = opts.label || ''; 23 | this.scale = this.transform.scale; 24 | this.msg = opts.message; 25 | this.initial = opts.initial || ``; 26 | this.validator = opts.validate || (() => true); 27 | this.value = ``; 28 | this.errorMsg = opts.error || `Please Enter A Valid Value`; 29 | this.cursor = Number(!!this.initial); 30 | this.cursorOffset = 0; 31 | this.clear = clear(``, this.out.columns); 32 | this.render(); 33 | } 34 | 35 | set value(v) { 36 | if (!v && this.initial) { 37 | this.placeholder = true; 38 | this.rendered = color.dim(this.initial); 39 | } else { 40 | this.placeholder = false; 41 | this.rendered = this.transform.render(v); 42 | } 43 | this._value = v; 44 | this.fire(); 45 | } 46 | 47 | get value() { 48 | return this._value; 49 | } 50 | 51 | reset() { 52 | this.value = ``; 53 | this.cursor = Number(!!this.initial); 54 | this.cursorOffset = 0; 55 | this.fire(); 56 | this.render(); 57 | } 58 | 59 | exit() { 60 | this.abort(); 61 | } 62 | 63 | abort() { 64 | this.value = this.value || this.initial; 65 | this.done = this.aborted = true; 66 | this.error = false; 67 | this.red = false; 68 | this.fire(); 69 | this.render(); 70 | this.out.write('\n'); 71 | this.close(); 72 | } 73 | 74 | async validate() { 75 | let valid = await this.validator(this.value); 76 | if (typeof valid === `string`) { 77 | this.errorMsg = valid; 78 | valid = false; 79 | } 80 | this.error = !valid; 81 | } 82 | 83 | async submit() { 84 | this.value = this.value || this.initial; 85 | this.cursorOffset = 0; 86 | this.cursor = this.rendered.length; 87 | await this.validate(); 88 | if (this.error) { 89 | this.red = true; 90 | this.fire(); 91 | this.render(); 92 | return; 93 | } 94 | this.done = true; 95 | this.aborted = false; 96 | this.fire(); 97 | this.render(); 98 | this.out.write('\n'); 99 | this.close(); 100 | } 101 | 102 | next() { 103 | if (!this.placeholder) return this.bell(); 104 | this.value = this.initial; 105 | this.cursor = this.rendered.length; 106 | this.fire(); 107 | this.render(); 108 | } 109 | 110 | moveCursor(n) { 111 | if (this.placeholder) return; 112 | this.cursor = this.cursor + n; 113 | this.cursorOffset += n; 114 | } 115 | 116 | _(c, key) { 117 | let s1 = this.value.slice(0, this.cursor); 118 | let s2 = this.value.slice(this.cursor); 119 | this.value = `${s1}${c}${s2}`; 120 | this.red = false; 121 | this.cursor = this.placeholder ? 0 : s1.length + 1; 122 | this.render(); 123 | } 124 | 125 | delete() { 126 | if (this.isCursorAtStart()) return this.bell(); 127 | let s1 = this.value.slice(0, this.cursor - 1); 128 | let s2 = this.value.slice(this.cursor); 129 | this.value = `${s1}${s2}`; 130 | this.red = false; 131 | this.outputError = ''; 132 | this.error = false; 133 | if (this.isCursorAtStart()) { 134 | this.cursorOffset = 0; 135 | } else { 136 | this.cursorOffset++; 137 | this.moveCursor(-1); 138 | } 139 | this.render(); 140 | } 141 | 142 | deleteForward() { 143 | if (this.cursor * this.scale >= this.rendered.length || this.placeholder) 144 | return this.bell(); 145 | let s1 = this.value.slice(0, this.cursor); 146 | let s2 = this.value.slice(this.cursor + 1); 147 | this.value = `${s1}${s2}`; 148 | this.red = false; 149 | this.outputError = ''; 150 | this.error = false; 151 | if (this.isCursorAtEnd()) { 152 | this.cursorOffset = 0; 153 | } else { 154 | this.cursorOffset++; 155 | } 156 | this.render(); 157 | } 158 | 159 | first() { 160 | this.cursor = 0; 161 | this.render(); 162 | } 163 | 164 | last() { 165 | this.cursor = this.value.length; 166 | this.render(); 167 | } 168 | 169 | left() { 170 | if (this.cursor <= 0 || this.placeholder) return this.bell(); 171 | this.moveCursor(-1); 172 | this.render(); 173 | } 174 | 175 | right() { 176 | if (this.cursor * this.scale >= this.rendered.length || this.placeholder) 177 | return this.bell(); 178 | this.moveCursor(1); 179 | this.render(); 180 | } 181 | 182 | isCursorAtStart() { 183 | return this.cursor === 0 || (this.placeholder && this.cursor === 1); 184 | } 185 | 186 | isCursorAtEnd() { 187 | return ( 188 | this.cursor === this.rendered.length || 189 | (this.placeholder && this.cursor === this.rendered.length + 1) 190 | ); 191 | } 192 | 193 | render() { 194 | if (this.closed) return; 195 | if (!this.firstRender) { 196 | if (this.outputError) 197 | this.out.write( 198 | cursor.down(lines(this.outputError, this.out.columns) - 1) + 199 | clear(this.outputError, this.out.columns), 200 | ); 201 | this.out.write(clear(this.outputText, this.out.columns)); 202 | } 203 | super.render(); 204 | this.outputError = ''; 205 | 206 | const prefix = ' '.repeat(strip(this.label).length); 207 | 208 | this.outputText = [ 209 | '\n', 210 | this.label, 211 | ' ', 212 | this.msg, 213 | '\n' + prefix, 214 | ' ', 215 | this.done 216 | ? color.dim( 217 | this.rendered.startsWith('.') 218 | ? this.rendered 219 | : `./${this.rendered}`, 220 | ) 221 | : this.rendered, 222 | ].join(''); 223 | 224 | if (this.error) { 225 | this.outputError += ` ${color.redBright( 226 | (useAscii() ? '> ' : '▶ ') + this.errorMsg, 227 | )}`; 228 | } 229 | 230 | this.out.write( 231 | erase.line + 232 | cursor.to(0) + 233 | this.outputText + 234 | cursor.save + 235 | this.outputError + 236 | cursor.restore + 237 | cursor.move(this.cursorOffset, 0), 238 | ); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/prompt/prompt.js: -------------------------------------------------------------------------------- 1 | import * as el from './elements'; 2 | const noop = (v) => v; 3 | 4 | function toPrompt(type, args, opts = {}) { 5 | return new Promise((res, rej) => { 6 | const p = new el[type](args); 7 | const onAbort = opts.onAbort || noop; 8 | const onSubmit = opts.onSubmit || noop; 9 | const onExit = opts.onExit || noop; 10 | p.on('state', args.onState || noop); 11 | p.on('submit', (x) => res(onSubmit(x))); 12 | p.on('exit', (x) => res(onExit(x))); 13 | p.on('abort', (x) => rej(onAbort(x))); 14 | }); 15 | } 16 | 17 | const prompts = { 18 | text: (args) => toPrompt('TextPrompt', args), 19 | confirm: (args) => toPrompt('ConfirmPrompt', args), 20 | select: (args) => toPrompt('SelectPrompt', args), 21 | multiselect: (args) => toPrompt('MultiselectPrompt', args), 22 | }; 23 | 24 | /** @type {import('../../types').default} */ 25 | export default async function prompt( 26 | questions = [], 27 | { onSubmit = noop, onCancel = () => process.exit(0) } = {}, 28 | ) { 29 | const answers = {}; 30 | questions = [].concat(questions); 31 | let answer, question, quit, name, type, lastPrompt; 32 | 33 | for (question of questions) { 34 | ({ name, type } = question); 35 | 36 | try { 37 | // Get the injected answer if there is one or prompt the user 38 | answer = await prompts[type](question); 39 | answers[name] = answer; 40 | quit = await onSubmit(question, answer, answers); 41 | } catch (err) { 42 | quit = !(await onCancel(question, answers)); 43 | } 44 | 45 | if (quit) return answers; 46 | } 47 | return answers; 48 | } 49 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/prompt/util/action.ts: -------------------------------------------------------------------------------- 1 | import type { Key } from 'node:readline'; 2 | 3 | export const action = (key: Key, isSelect: boolean) => { 4 | if (key.meta && key.name !== 'escape') return; 5 | 6 | if (key.ctrl) { 7 | if (key.name === 'a') return 'first'; 8 | if (key.name === 'c') return 'abort'; 9 | if (key.name === 'd') return 'abort'; 10 | if (key.name === 'e') return 'last'; 11 | if (key.name === 'g') return 'reset'; 12 | } 13 | 14 | if (isSelect) { 15 | if (key.name === 'j') return 'down'; 16 | if (key.name === 'k') return 'up'; 17 | } 18 | 19 | if (key.name === 'return') return 'submit'; 20 | if (key.name === 'enter') return 'submit'; // ctrl + J 21 | if (key.name === 'backspace') return 'delete'; 22 | if (key.name === 'delete') return 'deleteForward'; 23 | if (key.name === 'abort') return 'abort'; 24 | if (key.name === 'escape') return 'exit'; 25 | if (key.name === 'tab') return 'next'; 26 | if (key.name === 'pagedown') return 'nextPage'; 27 | if (key.name === 'pageup') return 'prevPage'; 28 | // TODO create home() in prompt types (e.g. TextPrompt) 29 | if (key.name === 'home') return 'home'; 30 | // TODO create end() in prompt types (e.g. TextPrompt) 31 | if (key.name === 'end') return 'end'; 32 | 33 | if (key.name === 'up') return 'up'; 34 | if (key.name === 'down') return 'down'; 35 | if (key.name === 'right') return 'right'; 36 | if (key.name === 'left') return 'left'; 37 | 38 | return false; 39 | }; 40 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/prompt/util/clear.ts: -------------------------------------------------------------------------------- 1 | import { erase, cursor } from 'sisteransi'; 2 | export const strip = (str: string) => { 3 | const pattern = [ 4 | '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', 5 | '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))', 6 | ].join('|'); 7 | 8 | const RGX = new RegExp(pattern, 'g'); 9 | return typeof str === 'string' ? str.replace(RGX, '') : str; 10 | }; 11 | 12 | export const breakIntoWords = (str: string) => { 13 | const wordRE = /\b(\w+)\b/g; 14 | const parts = []; 15 | let match; 16 | let lastIndex = 0; 17 | while ((match = wordRE.exec(str))) { 18 | const index = match.index; 19 | parts.push(str.slice(lastIndex, index)); 20 | lastIndex = index; 21 | } 22 | parts.push(str.slice(lastIndex)); 23 | return parts; 24 | }; 25 | 26 | export const wrap = ( 27 | str: string, 28 | indent = '', 29 | max = process.stdout.columns, 30 | ) => { 31 | const words = breakIntoWords(str); 32 | let i = 0; 33 | const lines = []; 34 | for (const raw of words) { 35 | const len = strip(raw).length; 36 | if (i + len > max) { 37 | i = 0; 38 | lines.push('\n' + indent, raw); 39 | } else { 40 | lines.push(raw); 41 | } 42 | i += len; 43 | } 44 | return lines.join(''); 45 | }; 46 | 47 | export interface Part { 48 | raw: string; 49 | prefix: string; 50 | text: string; 51 | words: string[]; 52 | } 53 | 54 | export const split = (str: string) => { 55 | const pattern = [ 56 | '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', 57 | '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))', 58 | ].join('|'); 59 | 60 | const ansiRE = new RegExp(pattern, 'g'); 61 | const parts: Part[] = []; 62 | let match; 63 | let lastIndex = 0; 64 | function push(index = Infinity) { 65 | const raw = str.slice(lastIndex, index); 66 | const text = strip(raw); 67 | const prefix = raw.slice(0, raw.length - text.length); 68 | parts.push({ raw, prefix, text, words: breakIntoWords(text) }); 69 | } 70 | while ((match = ansiRE.exec(str))) { 71 | const index = match.index; 72 | push(index); 73 | lastIndex = index; 74 | } 75 | push(); 76 | 77 | return parts; 78 | }; 79 | 80 | export function lines(msg: string, perLine: number) { 81 | const lines = String(strip(msg) || '').split(/\r?\n/); 82 | 83 | if (!perLine) return lines.length; 84 | return lines 85 | .map((l) => Math.ceil(l.length / perLine)) 86 | .reduce((a, b) => a + b); 87 | } 88 | 89 | export default function (prompt: string, perLine: number) { 90 | if (!perLine) return erase.line + cursor.to(0); 91 | 92 | let rows = 0; 93 | const lines = prompt.split(/\r?\n/); 94 | for (const line of lines) { 95 | rows += 1 + Math.floor(Math.max(strip(line).length - 1, 0) / perLine); 96 | } 97 | 98 | return erase.lines(rows); 99 | } 100 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/spinner/index.ts: -------------------------------------------------------------------------------- 1 | import readline from 'node:readline'; 2 | import chalk from 'chalk'; 3 | import logUpdate from 'log-update'; 4 | import { erase, cursor } from 'sisteransi'; 5 | import { sleep } from '../utils/index.js'; 6 | 7 | const COLORS = [ 8 | '#883AE3', 9 | '#7B30E7', 10 | '#6B22EF', 11 | '#5711F8', 12 | '#3640FC', 13 | '#2387F1', 14 | '#3DA9A3', 15 | '#47DA93', 16 | ].reverse(); 17 | 18 | const FULL_FRAMES = [ 19 | ...Array.from({ length: COLORS.length - 1 }, () => COLORS[0]), 20 | ...COLORS, 21 | ...Array.from({ length: COLORS.length - 1 }, () => COLORS[COLORS.length - 1]), 22 | ...[...COLORS].reverse(), 23 | ]; 24 | 25 | const frame = (offset = 0) => { 26 | const frames = FULL_FRAMES.slice(offset, offset + (COLORS.length - 2)); 27 | if (frames.length < COLORS.length - 2) { 28 | const filled = new Array(COLORS.length - frames.length - 2).fill(COLORS[0]); 29 | frames.push(...filled); 30 | } 31 | return frames; 32 | }; 33 | 34 | // get a reference to scroll through while loading 35 | // visual representation of what this generates: 36 | // gradientColors: "..xxXX" 37 | // referenceGradient: "..xxXXXXxx....xxXX" 38 | const GRADIENT = [...FULL_FRAMES.map((_, i) => frame(i))].reverse(); 39 | 40 | function getGradientAnimFrames() { 41 | return GRADIENT.map( 42 | (colors) => ' ' + colors.map((g) => chalk.hex(g)('█')).join(''), 43 | ); 44 | } 45 | 46 | /** 47 | * Generate loading spinner with rocket flames! 48 | * @param text display text next to rocket 49 | * @returns Ora spinner for running .stop() 50 | */ 51 | async function gradient(text: string) { 52 | let i = 0; 53 | const frames = getGradientAnimFrames(); 54 | let interval: NodeJS.Timeout; 55 | 56 | const rl = readline.createInterface({ 57 | input: process.stdin, 58 | escapeCodeTimeout: 50, 59 | }); 60 | readline.emitKeypressEvents(process.stdin, rl); 61 | 62 | if (process.stdin.isTTY) process.stdin.setRawMode(true); 63 | const keypress = () => { 64 | if (process.stdin.isTTY) process.stdin.setRawMode(true); 65 | process.stdout.write(cursor.hide + erase.lines(2)); 66 | }; 67 | 68 | let done = false; 69 | const spinner = { 70 | start() { 71 | process.stdout.write(cursor.hide); 72 | process.stdin.on('keypress', keypress); 73 | logUpdate(`${frames[0]} ${text}`); 74 | 75 | const loop = async () => { 76 | if (done) return; 77 | if (i < frames.length - 1) { 78 | i++; 79 | } else { 80 | i = 0; 81 | } 82 | const frame = frames[i]; 83 | logUpdate(`${frame} ${text}`); 84 | if (!done) await sleep(90); 85 | loop(); 86 | }; 87 | 88 | loop(); 89 | }, 90 | stop() { 91 | done = true; 92 | process.stdin.removeListener('keypress', keypress); 93 | clearInterval(interval); 94 | logUpdate.clear(); 95 | }, 96 | }; 97 | spinner.start(); 98 | return spinner; 99 | } 100 | 101 | export async function spinner({ 102 | start, 103 | end, 104 | while: update = () => sleep(100), 105 | }: { 106 | start: string; 107 | end: string; 108 | while: (...args: any) => Promise; 109 | }) { 110 | const act = update(); 111 | const tooslow = Object.create(null); 112 | const result = await Promise.race([sleep(500).then(() => tooslow), act]); 113 | if (result === tooslow) { 114 | const loading = await gradient(chalk.green(start)); 115 | await act; 116 | loading.stop(); 117 | } 118 | console.log(`${' '.repeat(5)} ${chalk.green('✔')} ${chalk.green(end)}`); 119 | } 120 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/lib/cli-kit/utils/index.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import { get } from 'node:https'; 3 | import { exec } from 'node:child_process'; 4 | import { platform } from 'node:os'; 5 | import { strip } from '../prompt/util/clear.js'; 6 | 7 | const unicode = { enabled: platform() !== 'win32' }; 8 | export function forceUnicode() { 9 | unicode.enabled = true; 10 | } 11 | export const useAscii = () => !unicode.enabled; 12 | 13 | export const hookExit = () => { 14 | const onExit = (code: number) => { 15 | if (code === 0) { 16 | console.log( 17 | `\n ${color.bgCyan(color.black(' done '))} ${color.bold( 18 | 'Operation cancelled.', 19 | )}`, 20 | ); 21 | } 22 | }; 23 | process.on('beforeExit', onExit); 24 | return () => process.off('beforeExit', onExit); 25 | }; 26 | 27 | export const sleep = (ms: number) => 28 | new Promise((resolve) => setTimeout(resolve, ms)); 29 | 30 | export const random = (...arr: any[]) => { 31 | arr = arr.flat(1); 32 | return arr[Math.floor(arr.length * Math.random())]; 33 | }; 34 | 35 | export const randomBetween = (min: number, max: number) => 36 | Math.floor(Math.random() * (max - min + 1) + min); 37 | 38 | let v: string; 39 | export const getAstroVersion = () => 40 | new Promise((resolve) => { 41 | if (v) return resolve(v); 42 | get('https://registry.npmjs.org/astro/latest', (res) => { 43 | let body = ''; 44 | res.on('data', (chunk) => (body += chunk)); 45 | res.on('end', () => { 46 | const { version } = JSON.parse(body); 47 | v = version; 48 | resolve(version); 49 | }); 50 | }); 51 | }); 52 | 53 | export const getUserName = () => 54 | new Promise((resolve) => { 55 | exec( 56 | 'git config user.name', 57 | { encoding: 'utf-8' }, 58 | (err, stdout, stderr) => { 59 | if (stdout.trim()) { 60 | return resolve(stdout.split(' ')[0].trim()); 61 | } 62 | exec('whoami', { encoding: 'utf-8' }, (err, stdout, stderr) => { 63 | if (stdout.trim()) { 64 | return resolve(stdout.split(' ')[0].trim()); 65 | } 66 | 67 | return resolve('astronaut'); 68 | }); 69 | }, 70 | ); 71 | }); 72 | 73 | export const align = ( 74 | text: string, 75 | dir: 'start' | 'end' | 'center', 76 | len: number, 77 | ) => { 78 | const pad = Math.max(len - strip(text).length, 0); 79 | switch (dir) { 80 | case 'start': 81 | return text + ' '.repeat(pad); 82 | case 'end': 83 | return ' '.repeat(pad) + text; 84 | case 'center': 85 | return ( 86 | ' '.repeat(Math.floor(pad / 2)) + text + ' '.repeat(Math.floor(pad / 2)) 87 | ); 88 | default: 89 | return text; 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/logger.ts: -------------------------------------------------------------------------------- 1 | import { blue, bold, dim, red, yellow } from 'kleur/colors'; 2 | import { Writable } from 'stream'; 3 | import { format as utilFormat } from 'util'; 4 | 5 | type ConsoleStream = Writable & { 6 | fd: 1 | 2; 7 | }; 8 | 9 | // Hey, locales are pretty complicated! Be careful modifying this logic... 10 | // If we throw at the top-level, international users can't use Astro. 11 | // 12 | // Using `[]` sets the default locale properly from the system! 13 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#parameters 14 | // 15 | // Here be the dragons we've slain: 16 | // https://github.com/withastro/astro/issues/2625 17 | // https://github.com/withastro/astro/issues/3309 18 | const dt = new Intl.DateTimeFormat([], { 19 | hour: '2-digit', 20 | minute: '2-digit', 21 | second: '2-digit', 22 | }); 23 | 24 | export const defaultLogDestination = new Writable({ 25 | objectMode: true, 26 | write(event: LogMessage, _, callback) { 27 | let dest: Writable = process.stderr; 28 | if (levels[event.level] < levels['error']) dest = process.stdout; 29 | 30 | dest.write(dim(dt.format(new Date()) + ' ')); 31 | 32 | let type = event.type; 33 | if (type) { 34 | switch (event.level) { 35 | case 'info': 36 | type = bold(blue(type)); 37 | break; 38 | case 'warn': 39 | type = bold(yellow(type)); 40 | break; 41 | case 'error': 42 | type = bold(red(type)); 43 | break; 44 | } 45 | 46 | dest.write(`[${type}] `); 47 | } 48 | 49 | dest.write(utilFormat(...event.args)); 50 | dest.write('\n'); 51 | 52 | callback(); 53 | }, 54 | }); 55 | 56 | interface LogWritable extends Writable { 57 | write: (chunk: T) => boolean; 58 | } 59 | 60 | export type LoggerLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; // same as Pino 61 | export type LoggerEvent = 'debug' | 'info' | 'warn' | 'error'; 62 | 63 | export let defaultLogLevel: LoggerLevel; 64 | if (process.argv.includes('--verbose')) { 65 | defaultLogLevel = 'debug'; 66 | } else if (process.argv.includes('--silent')) { 67 | defaultLogLevel = 'silent'; 68 | } else { 69 | defaultLogLevel = 'info'; 70 | } 71 | 72 | export interface LogOptions { 73 | dest?: LogWritable; 74 | level?: LoggerLevel; 75 | } 76 | 77 | export const defaultLogOptions: Required = { 78 | dest: defaultLogDestination, 79 | level: defaultLogLevel, 80 | }; 81 | 82 | export interface LogMessage { 83 | type: string | null; 84 | level: LoggerLevel; 85 | message: string; 86 | args: Array; 87 | } 88 | 89 | export const levels: Record = { 90 | debug: 20, 91 | info: 30, 92 | warn: 40, 93 | error: 50, 94 | silent: 90, 95 | }; 96 | 97 | /** Full logging API */ 98 | export function log( 99 | opts: LogOptions = {}, 100 | level: LoggerLevel, 101 | type: string | null, 102 | ...args: Array 103 | ) { 104 | const logLevel = opts.level ?? defaultLogOptions.level; 105 | const dest = opts.dest ?? defaultLogOptions.dest; 106 | const event: LogMessage = { 107 | type, 108 | level, 109 | args, 110 | message: '', 111 | }; 112 | 113 | // test if this level is enabled or not 114 | if (levels[logLevel] > levels[level]) { 115 | return; // do nothing 116 | } 117 | 118 | dest.write(event); 119 | } 120 | 121 | /** Emit a message only shown in debug mode */ 122 | export function debug( 123 | opts: LogOptions, 124 | type: string | null, 125 | ...messages: Array 126 | ) { 127 | return log(opts, 'debug', type, ...messages); 128 | } 129 | 130 | /** Emit a general info message (be careful using this too much!) */ 131 | export function info( 132 | opts: LogOptions, 133 | type: string | null, 134 | ...messages: Array 135 | ) { 136 | return log(opts, 'info', type, ...messages); 137 | } 138 | 139 | /** Emit a warning a user should be aware of */ 140 | export function warn( 141 | opts: LogOptions, 142 | type: string | null, 143 | ...messages: Array 144 | ) { 145 | return log(opts, 'warn', type, ...messages); 146 | } 147 | 148 | /** Emit a fatal error message the user should address. */ 149 | export function error( 150 | opts: LogOptions, 151 | type: string | null, 152 | ...messages: Array 153 | ) { 154 | return log(opts, 'error', type, ...messages); 155 | } 156 | 157 | // A default logger for when too lazy to pass LogOptions around. 158 | export const logger = { 159 | debug: debug.bind(null, defaultLogOptions, 'debug'), 160 | info: info.bind(null, defaultLogOptions, 'info'), 161 | warn: warn.bind(null, defaultLogOptions, 'warn'), 162 | error: error.bind(null, defaultLogOptions, 'error'), 163 | }; 164 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/messages.ts: -------------------------------------------------------------------------------- 1 | import { color, label } from '@/lib/cli-kit'; 2 | import { sleep } from '@/lib/cli-kit/utils'; 3 | import { exec } from 'node:child_process'; 4 | import { get } from 'node:https'; 5 | import stripAnsi from 'strip-ansi'; 6 | 7 | export const welcome = [ 8 | 'Let\'s claim your corner of the internet.', 9 | 'I\'ll be your assistant today.', 10 | 'Let\'s build something awesome!', 11 | 'Let\'s build something great!', 12 | 'Let\'s build something fast!', 13 | 'Let\'s create a new project!', 14 | 'Let\'s create something unique!', 15 | 'Time to build a new API.', 16 | 'Time to build a faster API.', 17 | 'Time to build a sweet new API.', 18 | 'We\'re glad to have you on board.', 19 | 'Initiating launch sequence...', 20 | 'Initiating launch sequence... right... now!', 21 | 'Awaiting further instructions.', 22 | ]; 23 | 24 | export function getName() { 25 | return new Promise((resolve) => { 26 | exec('git config user.name', { encoding: 'utf-8' }, (_1, gitName, _2) => { 27 | if (gitName.trim()) { 28 | return resolve(gitName.split(' ')[0].trim()); 29 | } 30 | exec('whoami', { encoding: 'utf-8' }, (_3, whoami, _4) => { 31 | if (whoami.trim()) { 32 | return resolve(whoami.split(' ')[0].trim()); 33 | } 34 | return resolve('developer'); 35 | }); 36 | }); 37 | }); 38 | } 39 | 40 | let v: string; 41 | export function getVersion() { 42 | return new Promise((resolve) => { 43 | if (v) return resolve(v); 44 | get('https://registry.npmjs.org/@zephyr-js/core/latest', (res) => { 45 | let body = ''; 46 | res.on('data', (chunk) => (body += chunk)); 47 | res.on('end', () => { 48 | const { version } = JSON.parse(body); 49 | v = version; 50 | resolve(version); 51 | }); 52 | }); 53 | }); 54 | } 55 | 56 | export async function banner(version: string) { 57 | return console.log( 58 | `\n${label('zephyr', color.bgGreen, color.black)} ${color.green( 59 | color.bold(`v${version}`), 60 | )} ${color.bold('Launch sequence initiated.')}\n`, 61 | ); 62 | } 63 | 64 | export async function info(prefix: string, text: string) { 65 | await sleep(100); 66 | if (process.stdout.columns < 80) { 67 | console.log(`${color.cyan('◼')} ${color.cyan(prefix)}`); 68 | console.log(`${' '.repeat(3)}${color.dim(text)}\n`); 69 | } else { 70 | console.log( 71 | `${color.cyan('◼')} ${color.cyan(prefix)} ${color.dim(text)}\n`, 72 | ); 73 | } 74 | } 75 | 76 | export async function error(prefix: string, text: string) { 77 | if (process.stdout.columns < 80) { 78 | console.log(`${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)}`); 79 | console.log(`${' '.repeat(9)}${color.dim(text)}`); 80 | } else { 81 | console.log( 82 | `${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)} ${color.dim( 83 | text, 84 | )}`, 85 | ); 86 | } 87 | } 88 | 89 | export async function typescriptByDefault() { 90 | await info( 91 | 'Cool!', 92 | 'Zephyr comes with TypeScript support enabled by default.', 93 | ); 94 | console.log( 95 | `${' '.repeat(3)}${color.dim( 96 | 'We\'ll default to the most relaxed settings for you.', 97 | )}`, 98 | ); 99 | await sleep(300); 100 | } 101 | 102 | export async function nextSteps({ 103 | projectDir, 104 | devCmd, 105 | }: { 106 | projectDir: string; 107 | devCmd: string; 108 | }) { 109 | const max = process.stdout.columns; 110 | const prefix = max < 80 ? ' ' : ' '.repeat(9); 111 | await sleep(200); 112 | console.log( 113 | `\n ${color.bgCyan(` ${color.black('next')} `)} ${color.bold( 114 | 'Liftoff confirmed. Explore your project!', 115 | )}`, 116 | ); 117 | 118 | await sleep(100); 119 | if (projectDir !== '') { 120 | const enter = [ 121 | `\n${prefix}Enter your project directory using`, 122 | color.cyan(`cd ./${projectDir}`, ''), 123 | ]; 124 | const len = enter[0].length + stripAnsi(enter[1]).length; 125 | console.log(enter.join(len > max ? '\n' + prefix : ' ')); 126 | } 127 | console.log( 128 | `${prefix}Run ${color.cyan(devCmd)} to start the dev server. ${color.cyan( 129 | 'CTRL+C', 130 | )} to stop.`, 131 | ); 132 | await sleep(100); 133 | } 134 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/src/templates.ts: -------------------------------------------------------------------------------- 1 | export const TEMPLATES = [ 2 | { value: 'basics', title: 'a few best practices (recommended)' }, 3 | { value: 'blog', title: 'a personal website starter kit' }, 4 | { value: 'minimal', title: 'an empty project' }, 5 | ]; 6 | -------------------------------------------------------------------------------- /packages/create-zephyr-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "outDir": "dist", 6 | "baseUrl": "src", 7 | "paths": { 8 | "@/*": [ 9 | "*" 10 | ] 11 | } 12 | }, 13 | "exclude": [ 14 | "node_modules", 15 | "dist", 16 | ] 17 | } -------------------------------------------------------------------------------- /packages/di/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: custom -------------------------------------------------------------------------------- /packages/di/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Zephyr.js logo 5 | 6 |

Zephyr.js

7 |

8 |

Build Typesafe Node API in minutes

9 |

10 | 11 | Zephyr.js CI workflow 12 | 13 | 14 | 15 | 16 | 17 | CodeFactor 18 | 19 |

20 |

21 | 22 |

23 | 24 | ## Description 25 | 26 | **Zephyr** is a Typescript server-side meta framework that is inspired by Next.js for its **file-based routing**. 27 | It is built on top of Express.js and uses Zod in request / response validation as well as providing typesafe API. 28 | 29 | **Zephyr** places a high value on **FP (Functional Programming)**. Instead of using classes as API controllers, declare and export API routes with functions. 30 | 31 | ## Philosophy 32 | 33 | The established server-side web frameworks for Node.js at the moment are [Nest.js](https://nestjs.com/) and [Adonis.js](https://adonisjs.com/), both of which are fantastic and rely on controllers and decorators in OOP. However, some programmers prefer functional programming to object-oriented programming (OOP). As a result, Zephyr seeks to let programmers to define API routes with functions and provides file-based routing out of the box. 34 | 35 | ## Getting started 36 | 37 | Kindly visit our documentation on [zephyrjs.com](https://zephyrjs.com/) 38 | 39 | ## Overview 40 | 41 | **Bootstrapping Project** 42 | 43 | ```bash 44 | npm create zephyr-app 45 | yarn create zephyr-app 46 | pnpm create zephyr-app 47 | ``` 48 | 49 | **Defining API Route** 50 | 51 | ```ts 52 | // src/routes/login.ts 53 | 54 | import { defineRoute } from '@zephyr-js/core'; 55 | 56 | // POST /login 57 | export function POST() { 58 | return defineRoute({ 59 | schema: z.object({ 60 | body: z.object({ 61 | email: z.string(), 62 | password: z.string(), 63 | }), 64 | response: z.object({ 65 | success: z.boolean(), 66 | }), 67 | }), 68 | onRequest(req) { 69 | logger.info('Request received', req.method, req.path); 70 | }, 71 | async handler({ body }) { 72 | await login(body); 73 | return { success: true }; 74 | }, 75 | onErrorCaptured(req, res, err) { 76 | logger.error('Login failed', err); 77 | res.status(500); 78 | return { success: false }; 79 | }, 80 | }); 81 | } 82 | ``` 83 | 84 | ## TODO 85 | 86 | - [x] Complete `create-zephyr-app` 87 | - [x] Publish `@zephyr-js/core`, `@zephyr-js/common` and `create-zephyr-app` to [NPM](https://www.npmjs.com/) 88 | - [x] Create unit tests 89 | - [x] Supports dependency injection 90 | - [ ] Create `zephyr` cli 91 | -------------------------------------------------------------------------------- /packages/di/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "ignore": [ 7 | "node_modules", 8 | "dist" 9 | ], 10 | "exec": "tsc -b" 11 | } -------------------------------------------------------------------------------- /packages/di/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zephyr-js/di", 3 | "version": "0.2.1", 4 | "description": "Zephyr - An Express TS meta framework designed with DX in mind (@di)", 5 | "author": { 6 | "name": "KaKeng Loh", 7 | "email": "kakengloh@gmail.com" 8 | }, 9 | "homepage": "https://github.com/zephyr-js/zephyr", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/zephyr-js/zephyr", 13 | "directory": "packages/di" 14 | }, 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "license": "MIT", 19 | "keywords": [ 20 | "node", 21 | "express", 22 | "zephyr", 23 | "zephyr.js" 24 | ], 25 | "main": "dist/index.js", 26 | "types": "dist/index.d.ts", 27 | "files": [ 28 | "dist" 29 | ], 30 | "scripts": { 31 | "dev": "nodemon", 32 | "lint": "eslint --fix src", 33 | "test": "vitest --run --coverage", 34 | "build": "tsc -b", 35 | "pack": "npm pack" 36 | }, 37 | "devDependencies": { 38 | "tsconfig": "workspace:*", 39 | "eslint-config-custom": "workspace:*", 40 | "@types/node": "^18.11.9", 41 | "tsconfig-paths": "^4.1.0", 42 | "nodemon": "^2.0.20", 43 | "vitest": "^0.25.0", 44 | "@vitest/coverage-c8": "^0.24.5" 45 | } 46 | } -------------------------------------------------------------------------------- /packages/di/src/container.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, test, vi } from 'vitest'; 2 | import { container, createContainer } from './container'; 3 | 4 | describe('container', () => { 5 | beforeEach(() => { 6 | container.clear(); 7 | }); 8 | 9 | test('should create an container instance', () => { 10 | const container = createContainer(); 11 | expect(container).toHaveProperty('provide'); 12 | expect(container).toHaveProperty('provideLazy'); 13 | expect(container).toHaveProperty('provideAsync'); 14 | expect(container).toHaveProperty('inject'); 15 | expect(container).toHaveProperty('injectAsync'); 16 | expect(container).toHaveProperty('isProvided'); 17 | expect(container).toHaveProperty('clear'); 18 | }); 19 | 20 | test('should provide and inject dependency', () => { 21 | container.provide('foo', 'foo'); 22 | expect(container.inject('foo')).toEqual('foo'); 23 | }); 24 | 25 | test('should provide and inject lazy dependency', () => { 26 | container.provideLazy('foo', () => 'foo'); 27 | expect(container.inject('foo')).toEqual('foo'); 28 | }); 29 | 30 | test('should only invoke factory function once on lazy dependency', () => { 31 | const fn = vi.fn().mockReturnValue('foo'); 32 | container.provideLazy('fn', fn); 33 | expect(container.inject('fn')).toEqual('foo'); 34 | expect(container.inject('fn')).toEqual('foo'); 35 | expect(fn).toHaveBeenCalledOnce(); 36 | }); 37 | 38 | test('should provide and inject async dependency', async () => { 39 | container.provideAsync('foo', async () => 'foo'); 40 | expect(await container.injectAsync('foo')).toEqual('foo'); 41 | }); 42 | 43 | test('should only invoke async factory once on async dependency', async () => { 44 | const fn = vi.fn().mockResolvedValue('foo'); 45 | container.provideAsync('foo', fn); 46 | expect(await container.injectAsync('foo')).toEqual('foo'); 47 | expect(await container.injectAsync('foo')).toEqual('foo'); 48 | expect(fn).toHaveBeenCalledOnce(); 49 | }); 50 | 51 | test('should throw error when inject a non-existent dependency', () => { 52 | expect(() => container.inject('foo')).toThrow( 53 | 'Dependency \'foo\' is not provided', 54 | ); 55 | }); 56 | 57 | test('should inject specified default value if dependency not provided', () => { 58 | expect(container.inject('foo', 'foo')).toEqual('foo'); 59 | }); 60 | 61 | test('should inject default value with specified factory if dependency not provided', () => { 62 | expect(container.inject('foo', () => 'foo')).toEqual('foo'); 63 | }); 64 | 65 | test('should inject specified default value if async dependency not provided', async () => { 66 | expect( 67 | await container.injectAsync('foo', async () => 'foo'), 68 | ).toEqual('foo'); 69 | }); 70 | 71 | test('should throw error if async dependency not provided and no default value is provided', async () => { 72 | await expect(() => container.injectAsync('foo')).rejects.toThrow( 73 | 'Dependency \'foo\' is not provided', 74 | ); 75 | }); 76 | 77 | test('should also inject non async instance on `injectAsync` call', async () => { 78 | container.provideLazy('foo', () => 'foo'); 79 | expect(await container.injectAsync('foo')).toEqual('foo'); 80 | }); 81 | 82 | test('should return true when dependency is provided', () => { 83 | container.provide('foo', 'foo'); 84 | expect(container.isProvided('foo')).toEqual(true); 85 | }); 86 | 87 | test('should return false when dependency is not provided', () => { 88 | expect(container.isProvided('foo')).toEqual(false); 89 | }); 90 | 91 | test('should clear dependencies', () => { 92 | container.provide('foo', 'foo'); 93 | expect(container.isProvided('foo')).toEqual(true); 94 | container.clear(); 95 | expect(container.isProvided('foo')).toEqual(false); 96 | }); 97 | 98 | test('should throw error when injecting async dependency with `inject`', () => { 99 | container.provideAsync('foo', async () => 'foo'); 100 | expect(container.provideAsync('foo', async () => 'foo')); 101 | expect(() => container.inject('foo')).toThrow( 102 | 'Dependency \'foo\' has an async factory, please use `injectAsync` instead', 103 | ); 104 | }); 105 | 106 | test('should throw error when dependency has no instance nor factory', () => { 107 | container.provide('foo', undefined); 108 | expect(() => container.inject('foo')).toThrow( 109 | 'Dependency \'foo\' has no instance nor factory', 110 | ); 111 | }); 112 | 113 | test('should throw error when async dependency has no instance nor factory', async () => { 114 | container.provide('foo', undefined); 115 | await expect(() => container.injectAsync('foo')).rejects.toThrow( 116 | 'Dependency \'foo\' has no instance nor factory', 117 | ); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /packages/di/src/container.ts: -------------------------------------------------------------------------------- 1 | import constructor from './types/constructor'; 2 | import { isFunction } from './utils/is-function'; 3 | import { normalizeToken } from './utils/token'; 4 | 5 | type Dependency = 6 | | { 7 | isLazy: false; 8 | instance: T; 9 | } 10 | | { 11 | isLazy: true; 12 | isAsync: false; 13 | instance: T | null; 14 | factory: () => T; 15 | } 16 | | { 17 | isLazy: true; 18 | isAsync: true; 19 | instance: T | null; 20 | factory: () => Promise; 21 | }; 22 | 23 | export function createContainer() { 24 | const registry = new Map< 25 | constructor | string | symbol, 26 | Dependency 27 | >(); 28 | 29 | /** 30 | * Provide a dependency 31 | * @param token A unique injection token 32 | * @param instance An instance of the dependency 33 | */ 34 | function provide(token: constructor | string | symbol, instance: T) { 35 | const key = normalizeToken(token); 36 | 37 | registry.set(key, { 38 | isLazy: false, 39 | instance, 40 | }); 41 | } 42 | 43 | /** 44 | * Provide a dependency lazily, the factory will be run only on the first inject 45 | * @param token A unique injection token 46 | * @param factory A factory function that resolves the instance 47 | */ 48 | function provideLazy( 49 | token: constructor | string | symbol, 50 | factory: () => T, 51 | ) { 52 | const key = normalizeToken(token); 53 | 54 | registry.set(key, { 55 | isLazy: true, 56 | isAsync: false, 57 | instance: null, 58 | factory, 59 | }); 60 | } 61 | 62 | /** 63 | * Provide a dependency with async factory, the factory will be run only on the first inject 64 | * @param token A unique injection token 65 | * @param factory An async factory function that resolves the instance 66 | */ 67 | function provideAsync( 68 | token: constructor | string | symbol, 69 | factory: () => Promise, 70 | ) { 71 | const key = normalizeToken(token); 72 | 73 | registry.set(key, { 74 | isLazy: true, 75 | isAsync: true, 76 | instance: null, 77 | factory, 78 | }); 79 | } 80 | 81 | /** 82 | * Inject a dependency with the specified key 83 | * @param token A unique injection token 84 | * @param defaultValue A default value that will be returned if dependency is not provided 85 | * @returns Instance of the dependency 86 | */ 87 | function inject( 88 | token: constructor | string | symbol, 89 | defaultValue?: T | (() => T), 90 | ): T { 91 | const key = normalizeToken(token); 92 | 93 | if (!registry.has(key)) { 94 | if (defaultValue) { 95 | return isFunction(defaultValue) ? defaultValue() : defaultValue; 96 | } else { 97 | throw new Error(`Dependency '${key}' is not provided`); 98 | } 99 | } 100 | 101 | const dependency = registry.get(key) as Dependency; 102 | 103 | if (dependency.instance) { 104 | return dependency.instance; 105 | } 106 | 107 | if (!dependency.isLazy) { 108 | throw new Error(`Dependency '${key}' has no instance nor factory`); 109 | } 110 | 111 | if (dependency.isAsync) { 112 | throw new Error( 113 | `Dependency '${key}' has an async factory, please use \`injectAsync\` instead`, 114 | ); 115 | } 116 | 117 | dependency.instance = dependency.factory(); 118 | 119 | return dependency.instance; 120 | } 121 | 122 | /** 123 | * Inject an async dependency with the specified key 124 | * @param token A unique injection token 125 | * @param defaultValue A default factory that will be invoked if dependency is not provided 126 | * @returns Promise of the dependency instance 127 | */ 128 | async function injectAsync( 129 | token: constructor | string | symbol, 130 | defaultValue?: () => Promise, 131 | ): Promise { 132 | const key = normalizeToken(token); 133 | 134 | if (!registry.has(key)) { 135 | if (defaultValue) { 136 | return defaultValue(); 137 | } else { 138 | throw new Error(`Dependency '${key.toString()}' is not provided`); 139 | } 140 | } 141 | 142 | const dependency = registry.get(key) as Dependency; 143 | 144 | if (dependency.instance) { 145 | return dependency.instance; 146 | } 147 | 148 | if (!dependency.isLazy) { 149 | throw new Error( 150 | `Dependency '${key.toString()}' has no instance nor factory`, 151 | ); 152 | } 153 | 154 | if (dependency.isAsync) { 155 | dependency.instance = await dependency.factory(); 156 | } else { 157 | dependency.instance = dependency.factory(); 158 | } 159 | 160 | return dependency.instance; 161 | } 162 | 163 | /** 164 | * Check if a dependency is provided 165 | * @param token A unique injection token 166 | * @returns `true` if dependency is provided, else `false` 167 | */ 168 | function isProvided(key: constructor | string | symbol): boolean { 169 | return registry.has(key); 170 | } 171 | 172 | /** 173 | * Clear all the existing dependencies 174 | */ 175 | function clear() { 176 | registry.clear(); 177 | } 178 | 179 | return { 180 | provide, 181 | provideLazy, 182 | provideAsync, 183 | inject, 184 | injectAsync, 185 | isProvided, 186 | clear, 187 | }; 188 | } 189 | 190 | export const container = createContainer(); 191 | -------------------------------------------------------------------------------- /packages/di/src/index.ts: -------------------------------------------------------------------------------- 1 | export { container } from './container'; 2 | -------------------------------------------------------------------------------- /packages/di/src/types/constructor.ts: -------------------------------------------------------------------------------- 1 | type constructor = { 2 | new (...args: any[]): T; 3 | }; 4 | 5 | export default constructor; 6 | -------------------------------------------------------------------------------- /packages/di/src/utils/is-function.ts: -------------------------------------------------------------------------------- 1 | export function isFunction( 2 | instance: unknown, 3 | ): instance is (...args: any[]) => any { 4 | return typeof instance === 'function'; 5 | } 6 | -------------------------------------------------------------------------------- /packages/di/src/utils/token.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { isConstructorToken, normalizeToken } from './token'; 3 | 4 | describe('isConstructorToken()', () => { 5 | test('should return true if a constructor is passed', () => { 6 | class Service {} 7 | const entries = [Number, String, Service]; 8 | 9 | for (const entry of entries) { 10 | expect(isConstructorToken(entry)).toBe(true); 11 | } 12 | }); 13 | 14 | test('should return false if a non constructor is passed', () => { 15 | const entries = ['service', 1, true]; 16 | 17 | for (const entry of entries) { 18 | expect(isConstructorToken(entry)).toBe(false); 19 | } 20 | }); 21 | }); 22 | 23 | describe('normalizeToken()', () => { 24 | test('should return constructor name if constructor is passed', () => { 25 | class Service {} 26 | 27 | const entries = [ 28 | { constructor: Number, name: 'Number' }, 29 | { constructor: String, name: 'String' }, 30 | { constructor: Service, name: 'Service' }, 31 | ]; 32 | 33 | for (const entry of entries) { 34 | expect(normalizeToken(entry.constructor)).toBe(entry.name); 35 | } 36 | }); 37 | 38 | test('should return toString() value if non constructor is passed', () => { 39 | const entries = [{ constructor: 'foo', name: 'foo' }]; 40 | 41 | for (const entry of entries) { 42 | expect(normalizeToken(entry.constructor)).toBe(entry.name); 43 | } 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/di/src/utils/token.ts: -------------------------------------------------------------------------------- 1 | import constructor from '@/types/constructor'; 2 | 3 | export function normalizeToken( 4 | token: constructor | string | symbol, 5 | ): string { 6 | return isConstructorToken(token) ? token.name : token.toString(); 7 | } 8 | 9 | export function isConstructorToken( 10 | token?: constructor | string | symbol, 11 | ): token is constructor { 12 | return typeof token === 'function'; 13 | } 14 | -------------------------------------------------------------------------------- /packages/di/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "dist", 6 | "baseUrl": "src", 7 | "paths": { 8 | "@/*": [ 9 | "*" 10 | ] 11 | }, 12 | }, 13 | "include": [ 14 | "src" 15 | ], 16 | "exclude": [ 17 | "node_modules", 18 | "dist", 19 | "**/*.spec.ts", 20 | "**/*.spec-d.ts" 21 | ] 22 | } -------------------------------------------------------------------------------- /packages/di/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ['text', 'json', 'html'], 7 | }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | }, 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'eslint-config-prettier', 9 | ], 10 | overrides: [], 11 | parser: '@typescript-eslint/parser', 12 | parserOptions: { 13 | ecmaVersion: 'latest', 14 | sourceType: 'module', 15 | }, 16 | plugins: ['@typescript-eslint', 'import'], 17 | rules: { 18 | indent: ['error', 2], 19 | 'linebreak-style': ['error', 'unix'], 20 | quotes: ['error', 'single'], 21 | semi: ['error', 'always'], 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "main": "index.js", 4 | "dependencies": { 5 | "@typescript-eslint/eslint-plugin": "^5.42.0", 6 | "@typescript-eslint/parser": "^5.42.0", 7 | "eslint": "^8.26.0", 8 | "typescript": "^4.8.4", 9 | "prettier": "^2.7.1", 10 | "eslint-config-prettier": "^8.5.0", 11 | "eslint-import-resolver-typescript": "^3.5.2", 12 | "eslint-plugin-import": "^2.26.0" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016", 4 | "module": "CommonJS", 5 | "lib": [ 6 | "ESNext" 7 | ], 8 | "esModuleInterop": true, 9 | "incremental": true, 10 | "inlineSourceMap": true, 11 | "inlineSources": true, 12 | "noImplicitThis": true, 13 | "strict": true, 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "skipLibCheck": true, 17 | "pretty": true, 18 | "forceConsistentCasingInFileNames": true 19 | } 20 | } -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "files": [ 4 | "base.json" 5 | ] 6 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "dev": { 5 | "cache": false 6 | }, 7 | "lint": { 8 | "inputs": [ 9 | "**/*.{js,ts}" 10 | ] 11 | }, 12 | "test": { 13 | "dependsOn": [ 14 | "build" 15 | ], 16 | "inputs": [ 17 | "**/*.ts" 18 | ], 19 | "outputs": [ 20 | "coverage/**" 21 | ] 22 | }, 23 | "test:typecheck": { 24 | "inputs": [ 25 | "**/*.ts" 26 | ] 27 | }, 28 | "build": { 29 | "dependsOn": [ 30 | "^build" 31 | ], 32 | "inputs": [ 33 | "**/*.ts" 34 | ], 35 | "outputs": [ 36 | "dist/**", 37 | "build/**" 38 | ] 39 | }, 40 | "pack": { 41 | "cache": false 42 | } 43 | } 44 | } --------------------------------------------------------------------------------