├── .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 |
5 |
6 | Zephyr.js
7 |
8 |
Build Typesafe Node API in minutes
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
5 |
6 | Zephyr.js
7 |
8 |
Build Typesafe Node API in minutes
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
5 |
6 | Zephyr.js
7 |
8 |
Build Typesafe Node API in minutes
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
5 |
6 | Zephyr.js
7 |
8 |
Build Typesafe Node API in minutes
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
5 |
6 | Zephyr.js
7 |
8 |
Build Typesafe Node API in minutes
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 | }
--------------------------------------------------------------------------------