├── .github └── workflows │ ├── create-api-test.yml │ └── shuttle-unit-test.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── packages ├── create │ ├── .env.sample │ ├── .gitignore │ ├── README.md │ ├── babel.config.js │ ├── docker-compose.yml │ ├── gitignore │ ├── package-lock.json │ ├── package.json │ ├── prisma │ │ ├── .gitignore │ │ └── schema.prisma │ ├── setup.sh │ ├── src │ │ ├── index.ts │ │ ├── persistence │ │ │ ├── db.ts │ │ │ └── todos.ts │ │ └── routes │ │ │ ├── status │ │ │ └── get.ts │ │ │ └── todos │ │ │ ├── [id] │ │ │ └── get.ts │ │ │ ├── get.ts │ │ │ └── post.ts │ ├── tests │ │ ├── api │ │ │ ├── create-todo.ts │ │ │ ├── get-todo.ts │ │ │ └── get-todos.ts │ │ ├── create-a-todo.test.ts │ │ ├── get-all-todos.test.ts │ │ └── get-todo-by-id.test.ts │ └── tsconfig.json ├── liftoff │ ├── index.ts │ ├── package.json │ └── scripts │ │ ├── clean-up.sh │ │ ├── compile-project.sh │ │ ├── deploy-lambda.sh │ │ ├── deploy.sh │ │ ├── setup-api-gateway.sh │ │ └── setup-s3.sh └── shuttle │ ├── babel.config.js │ ├── package.json │ ├── src │ ├── getHandlerFiles.ts │ ├── index.test.ts │ ├── index.ts │ ├── mapRouteToFile.test.ts │ ├── mapRouteToFile.ts │ └── runRoute.ts │ └── tsconfig.json ├── replace.sh └── revert.sh /.github/workflows/create-api-test.yml: -------------------------------------------------------------------------------- 1 | name: Create - API Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | services: 10 | postgres: 11 | image: postgres:latest 12 | env: 13 | POSTGRES_PASSWORD: postgres 14 | ports: 15 | - 5432:5432 16 | options: >- 17 | --health-cmd pg_isready 18 | --health-interval 10s 19 | --health-timeout 5s 20 | --health-retries 5 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v3 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: "18" 30 | 31 | - name: Install dependencies 32 | run: npm install 33 | 34 | - name: Prisma generate 35 | run: | 36 | cp packages/create/.env.sample packages/create/.env 37 | cd packages/create 38 | npx prisma generate 39 | npx prisma db push 40 | 41 | - name: Run create api tests 42 | run: | 43 | npm run -w @webdevcody/create-launchpad dev & 44 | sleep 5 45 | npm run -w @webdevcody/create-launchpad test 46 | -------------------------------------------------------------------------------- /.github/workflows/shuttle-unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Shuttle - Unit Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v3 12 | 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: "18" 17 | 18 | - name: Install dependencies 19 | run: npm install 20 | 21 | - name: Run shuttle unit tests 22 | run: npm run -w @webdevcody/shuttle test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Web Dev Cody 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **This is a prototype, please don't use this in production.** 2 | 3 | # Todo 4 | 5 | - route params inside the file based routing approach 6 | - fix the janky prisma schema duplication I had to do to get this working on aws 7 | - fix the duplicate prisma binary I had to do to get this working on aws 8 | - maybe consider authentication / authorization 9 | - consider exposing middlewares so people can use existing express middlewares 10 | - host the lambda behind api gateway 11 | - ability to destroy all created resources 12 | - ability to easily scope resource by environment (dev, stg, prod) 13 | - remove color logs when deployed to production 14 | 15 | ## Overview 16 | 17 | This is a prototype of a file based routing REST framework that uses express.js under the hood. The goal of this framework is to provide a single CLI command to deploy the REST api to AWS serverless / api gateway. I want to provide built in mechanisms for dependecy injection and provide a standard structure one can follow to build a "production ready api". This includes 18 | 19 | - node 18 compatible 20 | - fully typesafe 21 | - file based routing 22 | - logger included 23 | - baked in dependency injection solution 24 | - a one command deployment into an AWS mono lambda 25 | - ...more to come maybe 26 | 27 | ## Getting Started 28 | 29 | Launchpad is a collection of libraries and tools to help you build and deploy a production ready api to AWS serverless. You can also run the server as a stand alone service inside a container or VM, but those are not our main goals. 30 | 31 | The Launchpad package used for running this api is called [shuttle](./packages/shuttle/). You can build a simple API using shuttle by doing the following: 32 | 33 | To setup a launchpad project, run the following in a blank directory: 34 | 35 | `npm create @webdevcody/launchpad@latest` 36 | 37 | And here is an example of setting up your shuttle server: 38 | 39 | Running your server using a `npm run dev` should host your server on http://localhost:8080 40 | 41 | ## Deployment to AWS 42 | 43 | This project is setup to be easily deployed to an AWS Lambda and hosted behind api gateway. After setting up your launchpad project, you can deploy it to aws like so: 44 | 45 | 1. you should update prisma/schema.prisma to use something other than sqlite - sqlite will not work on serverless 46 | 2. `npx @webdevcody/liftoff` 47 | 3. login to aws and update your lambda's environment variables 48 | 49 | ## Tetsing 50 | 51 | The create package comes with a couple of api tests written using jest. You can run this using: 52 | 53 | 1. `npm test` 54 | 55 | ## Entry Point 56 | 57 | After setting up the project using the `npm create` script, the `src/index.ts` file will call the `shuttle` to setup the api server. 58 | 59 | ```ts 60 | // src/index.ts 61 | import shuttle from "@webdevcody/shuttle"; 62 | 63 | export const { createHandler, app } = shuttle({ 64 | providers: { 65 | // any providers you want to inject into your handlers go here 66 | }, 67 | env({ str }) { 68 | return { 69 | // any custom env variables would go here 70 | MY_ENV: str({ 71 | choices: ["have", "fun"], 72 | }), 73 | }; 74 | }, 75 | }); 76 | ``` 77 | 78 | **you must export app for this to work in a deploy aws environment.** 79 | 80 | You will use the `createHandler` function inside your api endpoints. 81 | 82 | ## File Based Routing 83 | 84 | Shuttle uses file based routing for defining api endpoints, and it'll search your project for a `src/routes` directory and dynamically register endpoints via files it finds. The supported file names are: 85 | 86 | - get.ts 87 | - post.ts 88 | - patch.ts 89 | - put.ts 90 | - delete.ts 91 | - head.ts 92 | - options.ts 93 | 94 | The location of the the endpoint file will determine the rest API endpont. For example, if you want an endpoint that accepts http GET requests at a path of /api/todos, you'd create a file called `src/routes/api/todos/get.ts`. This would make an endpoint accessible at `http://localhost:8080/api/todos`. 95 | 96 | To start processing the request, your file must export a default handler which looks like this: 97 | 98 | ```ts 99 | // src/routes/get.ts 100 | import { createHandler } from "../.."; 101 | 102 | export default createHandler({ 103 | // return a zod object to define input validation 104 | input(z) { 105 | return z.object({ 106 | text: z.string(), 107 | }); 108 | }, 109 | // return a zod object to define output validation, any undefined properties will be stripped from the response 110 | output(z) { 111 | return z.object({ 112 | id: z.string(), 113 | text: z.string(), 114 | }); 115 | }, 116 | async handler({ input, providers, logger, env }) { 117 | // input: to get the combined incoming req.query, req.params, req.body 118 | // logger: used for logging with different levels 119 | // env: use to access the environment variables 120 | // providers: an object containing all your providers you defined in your src/index.ts 121 | 122 | const { createTodo } = providers; 123 | const todo = await createTodo({ 124 | text: input.text, 125 | }); 126 | return todo; 127 | }, 128 | }); 129 | ``` 130 | 131 | Like mentioned, the handler is a normal express handler with additional context parameters passed into the first parameter. The context parameter will contain various helper functions provided by the shuttle library. 132 | 133 | ## Logger 134 | 135 | Shuttle uses winston for the logging. The logger is provided inside the context parameter which you can use to log errors, warnings, debug, or info logs. The following methods exist on the logger by default: 136 | 137 | - error 138 | - warn 139 | - info 140 | - http 141 | - verbose 142 | - debug 143 | - silly 144 | 145 | You can set the LOG_LEVEL in your .env file. Depending on the log level you set, you will filter out less important messages your api logs. For example, setting LOG_LEVEL="error" will only show logs created via a `logger.error` call. LOG_LEVEL="info" will print debug and all levels above it, including warn and error. 146 | 147 | ## Providers 148 | 149 | The purpose of the providers is to help you build a more maintainble code base by using dependency injection. Instead of your handlers directly importing lower level implementations such as prisma, mongoose, aws sdk calls such as s3.putObject, the idea is your handler should put one layer of abstraction between your business logic and the lower level details. We suggest you have clearly defined interfaces on your providers object. This also helps with unit testing your handlers by simply injecting mocks as function arguments. 150 | 151 | ## Environment Variables 152 | 153 | Shuttle uses `envalid` and `dotenv` to load in your `.env` and verify your environment variables are setup as needed. In order to achieve typesafe env variables, you'll need to add the following code to your main `src/index.ts` file and export the type so you can use it in your handlers. 154 | 155 | ```ts 156 | // src/index.ts 157 | import shuttle from "@webdevcody/shuttle"; 158 | 159 | const { createHandler } = shuttle({ 160 | env({ str }) { 161 | return { 162 | MY_ENV: str({ 163 | choices: ["have", "fun"], 164 | }), 165 | }; 166 | }, 167 | }); 168 | ``` 169 | 170 | ### Final Considerations 171 | 172 | This is a prototype and more of an experiment to practice my knowledge in building typesafe libraries. Do not use this in production. You're welcome to contribute if you find this project interesting. 173 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webdevcody/launchpad", 3 | "version": "0.0.7", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "description": "", 8 | "scripts": { 9 | "publish:all": "npm run publish:create && npm run publish:shuttle && npm run publish:liftoff", 10 | "publish:create:beta": "./replace.sh ./packages/create/package.json && npm -w @webdevcody/create-launchpad version patch && npm -w @webdevcody/create-launchpad publish --tag beta && ./revert.sh packages/create/package.json", 11 | "publish:create": "./replace.sh ./packages/create/package.json && npm -w @webdevcody/create-launchpad version patch && npm -w @webdevcody/create-launchpad publish && ./revert.sh packages/create/package.json", 12 | "publish:liftoff": "npm -w @webdevcody/liftoff version patch && npm -w @webdevcody/liftoff publish", 13 | "publish:shuttle": "npm -w @webdevcody/shuttle version patch && npm -w @webdevcody/shuttle publish", 14 | "publish:shuttle:beta": "npm -w @webdevcody/shuttle version patch && npm -w @webdevcody/shuttle publish --tag beta" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "MIT", 19 | "devDependencies": {}, 20 | "dependencies": {}, 21 | "workspaces": [ 22 | "packages/*" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /packages/create/.env.sample: -------------------------------------------------------------------------------- 1 | LOG_LEVEL="info" 2 | NODE_ENV="development" 3 | PORT=8080 4 | MY_ENV="fun" 5 | DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres 6 | IS_LAMBDA=false -------------------------------------------------------------------------------- /packages/create/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.prod 3 | /node_modules 4 | /dist 5 | /out 6 | /tmp -------------------------------------------------------------------------------- /packages/create/README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | This is a project setup using launchpad. The default setup requires a postgres database which we've provided a docker-compose file which will host one for you. 4 | 5 | ## Running the Database 6 | 7 | Assuming you have [docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) setup, you can easily start your postgres database with the following command from the root directory of this repo. You can host your own postgres database without needing docker if you want, but docker will make your life much easier. 8 | 9 | `docker-compose up` 10 | 11 | ## Running Locally 12 | 13 | 1. `npm i` 14 | 2. `npx prisma db push` 15 | 3. `npm run dev` 16 | 17 | ## Running Tests 18 | 19 | 1. `npm run dev` 20 | 2. `npm test` 21 | -------------------------------------------------------------------------------- /packages/create/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/create/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | db: 4 | image: postgres 5 | restart: always 6 | environment: 7 | POSTGRES_PASSWORD: postgres 8 | 9 | ports: 10 | - "5432:5432" 11 | volumes: 12 | - db:/var/lib/postgresql 13 | 14 | volumes: 15 | db: 16 | -------------------------------------------------------------------------------- /packages/create/gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.prod 3 | /node_modules 4 | /dist 5 | /out 6 | /tmp -------------------------------------------------------------------------------- /packages/create/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webdevcody/create-launchpad", 3 | "version": "0.0.34", 4 | "description": "", 5 | "main": "src/index.ts", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "scripts": { 10 | "dev": "tsx watch src/index.ts", 11 | "test": "jest" 12 | }, 13 | "bin": { 14 | "create-launchpad": "./setup.sh" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@babel/preset-env": "^7.21.4", 21 | "@babel/preset-typescript": "^7.21.4", 22 | "@types/express": "^4.17.17", 23 | "@types/uuid": "^9.0.1", 24 | "@types/jest": "^29.5.0", 25 | "@types/node": "^18.15.11", 26 | "cross-fetch": "^3.1.5", 27 | "prisma": "^4.12.0", 28 | "ts-node": "^10.9.1", 29 | "tsx": "^3.12.6", 30 | "typescript": "^5.0.2" 31 | }, 32 | "dependencies": { 33 | "@prisma/client": "^4.12.0", 34 | "@webdevcody/shuttle": "file:../shuttle", 35 | "express": "^4.18.2", 36 | "jest": "^29.5.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/create/prisma/.gitignore: -------------------------------------------------------------------------------- 1 | sqlite.db -------------------------------------------------------------------------------- /packages/create/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | binaryTargets = ["native", "rhel-openssl-1.0.x"] 7 | } 8 | 9 | datasource db { 10 | provider = "postgresql" 11 | url = env("DATABASE_URL") 12 | } 13 | 14 | model Todo { 15 | id String @id @default(cuid()) 16 | text String 17 | } 18 | -------------------------------------------------------------------------------- /packages/create/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npm init -f 4 | npm i @webdevcody/create-launchpad 5 | cp -R ./node_modules/@webdevcody/create-launchpad/* . 6 | cp ./node_modules/@webdevcody/create-launchpad/.env.sample . 7 | cp ./node_modules/@webdevcody/create-launchpad/gitignore .gitignore 8 | cp .env.sample .env 9 | npm i --save @webdevcody/shuttle 10 | npm ci 11 | npx prisma db push 12 | rm setup.sh -------------------------------------------------------------------------------- /packages/create/src/index.ts: -------------------------------------------------------------------------------- 1 | import shuttle from "@webdevcody/shuttle"; 2 | import { createTodo, getTodos, getTodo } from "./persistence/todos"; 3 | 4 | export const { createHandler, app } = shuttle({ 5 | providers: { 6 | createTodo, 7 | getTodos, 8 | getTodo, 9 | }, 10 | env({ str }) { 11 | return { 12 | MY_ENV: str({ 13 | choices: ["have", "fun"], 14 | }), 15 | }; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /packages/create/src/persistence/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | export const db = new PrismaClient(); 4 | -------------------------------------------------------------------------------- /packages/create/src/persistence/todos.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | 3 | export type Todo = { 4 | id: string; 5 | text: string; 6 | }; 7 | 8 | export async function createTodo(todo: Omit) { 9 | const newTodo = await db.todo.create({ 10 | data: todo, 11 | }); 12 | return newTodo; 13 | } 14 | 15 | export async function getTodos() { 16 | return await db.todo.findMany(); 17 | } 18 | 19 | export async function getTodo(id: string) { 20 | return await db.todo.findUnique({ 21 | where: { 22 | id, 23 | }, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /packages/create/src/routes/status/get.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../.."; 2 | 3 | export default createHandler({ 4 | output(z) { 5 | return z.object({ 6 | status: z.string(), 7 | }); 8 | }, 9 | async handler() { 10 | return { status: "ok" }; 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/create/src/routes/todos/[id]/get.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../../.."; 2 | 3 | export default createHandler({ 4 | input(z) { 5 | return z.object({ 6 | id: z.string(), 7 | }); 8 | }, 9 | async handler({ input, providers }) { 10 | const { getTodo } = providers; 11 | const todos = await getTodo(input.id); 12 | return todos; 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/create/src/routes/todos/get.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../.."; 2 | 3 | export default createHandler({ 4 | output(z) { 5 | return z.array( 6 | z.object({ 7 | id: z.string(), 8 | text: z.string(), 9 | }) 10 | ); 11 | }, 12 | async handler({ providers }) { 13 | const { getTodos } = providers; 14 | const todos = await getTodos(); 15 | return todos; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /packages/create/src/routes/todos/post.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../.."; 2 | 3 | export default createHandler({ 4 | input(z) { 5 | return z.object({ 6 | text: z.string(), 7 | }); 8 | }, 9 | output(z) { 10 | return z.object({ 11 | id: z.string(), 12 | text: z.string(), 13 | }); 14 | }, 15 | async handler({ input, providers }) { 16 | const { createTodo } = providers; 17 | const todo = await createTodo({ 18 | text: input.text, 19 | }); 20 | return todo; 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /packages/create/tests/api/create-todo.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from "@prisma/client"; 2 | import fetch from "cross-fetch"; 3 | 4 | export function createTodo({ text }: { text: string }) { 5 | return fetch("http://localhost:8080/todos", { 6 | method: "POST", 7 | headers: { 8 | "Content-Type": "application/json", 9 | }, 10 | body: JSON.stringify({ 11 | text, 12 | }), 13 | }).then((response) => response.json()) as Promise; 14 | } 15 | -------------------------------------------------------------------------------- /packages/create/tests/api/get-todo.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from "@prisma/client"; 2 | import fetch from "cross-fetch"; 3 | 4 | export function getTodo(todoId: string) { 5 | return fetch(`http://localhost:8080/todos/${todoId}`).then((response) => 6 | response.json() 7 | ) as Promise; 8 | } 9 | -------------------------------------------------------------------------------- /packages/create/tests/api/get-todos.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from "@prisma/client"; 2 | import fetch from "cross-fetch"; 3 | 4 | export function getTodos() { 5 | return fetch("http://localhost:8080/todos").then((response) => 6 | response.json() 7 | ) as Promise; 8 | } 9 | -------------------------------------------------------------------------------- /packages/create/tests/create-a-todo.test.ts: -------------------------------------------------------------------------------- 1 | import { createTodo } from "./api/create-todo"; 2 | 3 | describe("POST@/todos", () => { 4 | it("should verify the output only has an id and text", async () => { 5 | const createdTodo = await createTodo({ text: "hello world" }); 6 | 7 | expect(createdTodo).toEqual({ 8 | text: "hello world", 9 | id: expect.any(String), 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/create/tests/get-all-todos.test.ts: -------------------------------------------------------------------------------- 1 | import { createTodo } from "./api/create-todo"; 2 | import { getTodos } from "./api/get-todos"; 3 | 4 | describe("GET@/todos", () => { 5 | beforeEach(async () => { 6 | await createTodo({ 7 | text: "ligma", 8 | }); 9 | }); 10 | 11 | it("should verify the output only has an id and text", async () => { 12 | const todos = await getTodos(); 13 | expect(todos).toEqual( 14 | expect.arrayContaining([ 15 | { 16 | id: expect.any(String), 17 | text: "ligma", 18 | }, 19 | ]) 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/create/tests/get-todo-by-id.test.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from "@prisma/client"; 2 | import { createTodo } from "./api/create-todo"; 3 | import { getTodo } from "./api/get-todo"; 4 | 5 | describe("GET@/todos/:id", () => { 6 | let existingTodo: Todo; 7 | 8 | beforeEach(async () => { 9 | existingTodo = await createTodo({ 10 | text: "ligma", 11 | }); 12 | }); 13 | 14 | it("the existing todo should be send back when hitting the GET endpoint", async () => { 15 | const todo = await getTodo(existingTodo.id); 16 | expect(todo).toEqual(existingTodo); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/create/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2022"], 4 | "module": "commonjs", 5 | "target": "es2022", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "moduleResolution": "node" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/liftoff/index.ts: -------------------------------------------------------------------------------- 1 | import serverlessExpress from "@vendia/serverless-express"; 2 | import { app } from "./src"; 3 | exports.handler = serverlessExpress({ app }); 4 | -------------------------------------------------------------------------------- /packages/liftoff/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webdevcody/liftoff", 3 | "version": "0.0.5", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "bin": { 8 | "liftoff": "./scripts/deploy.sh" 9 | }, 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "MIT" 16 | } 17 | -------------------------------------------------------------------------------- /packages/liftoff/scripts/clean-up.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | rm -rf out 4 | rm -rf tmp 5 | rm -rf dist -------------------------------------------------------------------------------- /packages/liftoff/scripts/compile-project.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | SCRIPT_DIR=$(dirname "$(readlink -f "$0")") 4 | 5 | # Run TypeScript build 6 | rm -rf tmp 7 | mkdir -p tmp 8 | mkdir -p tmp/prisma 9 | 10 | cp -R src tmp/src 11 | cp prisma/schema.prisma tmp/prisma 12 | cp package.json tmp 13 | cp $SCRIPT_DIR/../index.ts tmp 14 | 15 | pushd tmp 16 | npm i @vendia/serverless-express 17 | npm i --production 18 | npx prisma generate 19 | 20 | mkdir -p out/src/routes 21 | npx esbuild index.ts --bundle --outdir=out --platform=node 22 | 23 | mkdir -p out/.prisma/client 24 | cp -R ./node_modules/.prisma/client/libquery_engine-rhel-* out 25 | cp -R ./node_modules/.prisma/client/libquery_engine-rhel-* out/.prisma/client 26 | 27 | find src/routes -name '*.ts' | while read filepath; do 28 | outdir="out/$(dirname "$filepath")"; 29 | mkdir -p $outdir; 30 | echo "outdir $outdir" 31 | cp ../prisma/schema.prisma $outdir 32 | npx esbuild "$filepath" --bundle --outdir="$outdir" --platform=node; 33 | done 34 | 35 | # find out/src/routes -name '*.js' -exec sh -c 'mv "$0" "${0%.js}.mjs"' {} \; 36 | # find out -name '*.js' -exec sh -c 'mv "$0" "${0%.js}.mjs"' {} \; 37 | 38 | popd 39 | 40 | 41 | 42 | # Create the archive 43 | rm -rf out 44 | mkdir -p out 45 | cp prisma/schema.prisma tmp/out 46 | 47 | pushd tmp/out 48 | zip -r "../../out/payload.zip" . 49 | popd 50 | 51 | -------------------------------------------------------------------------------- /packages/liftoff/scripts/deploy-lambda.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Define the name of the Lambda function 4 | FUNCTION_NAME="$APP_PREFIX-lambda-function" 5 | 6 | # Define the path to the .zip file containing the function code 7 | ZIP_FILE_PATH="./out/payload.zip" 8 | 9 | # Define the name of the IAM role that the function will use 10 | IAM_ROLE_NAME="$APP_PREFIX-lambda-role" 11 | 12 | if aws iam get-role --role-name "$IAM_ROLE_NAME" &> /dev/null; then 13 | echo "IAM role '$IAM_ROLE_NAME' already exists, skipping creation." 14 | else 15 | # Create the IAM role for the Lambda function 16 | aws iam create-role \ 17 | --role-name "$IAM_ROLE_NAME" \ 18 | --assume-role-policy-document '{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Principal": {"Service": "lambda.amazonaws.com"},"Action": "sts:AssumeRole"}]}' 19 | fi 20 | 21 | # Attach the appropriate permissions to the IAM role 22 | aws iam attach-role-policy \ 23 | --role-name "$IAM_ROLE_NAME" \ 24 | --policy-arn "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 25 | 26 | # Retrieve the AWS account ID for the currently authenticated user 27 | AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 28 | 29 | if aws lambda get-function --function-name "$FUNCTION_NAME" &> /dev/null; then 30 | echo "Lambda function '$FUNCTION_NAME' already exists, updating with new zip file..." 31 | aws lambda update-function-code \ 32 | --function-name "$FUNCTION_NAME" \ 33 | --zip-file "fileb://$ZIP_FILE_PATH" \ 34 | --region "$REGION" 35 | 36 | # Publish a new version of the function 37 | while ! aws lambda publish-version --function-name "$FUNCTION_NAME" --region "$REGION" --output text --query 'Version'; do 38 | echo "Retrying publish-version command in 5 seconds..." 39 | sleep 5 40 | done 41 | echo "New version published successfully" 42 | else 43 | # Create the Lambda function 44 | aws lambda create-function \ 45 | --function-name "$FUNCTION_NAME" \ 46 | --runtime "nodejs18.x" \ 47 | --role "arn:aws:iam::$AWS_ACCOUNT_ID:role/$IAM_ROLE_NAME" \ 48 | --handler "index.handler" \ 49 | --zip-file "fileb://$ZIP_FILE_PATH" \ 50 | --region "$REGION" 51 | fi 52 | 53 | # Load environment variables from .env.prod file 54 | source .env.prod 55 | 56 | # Verify that the function was created successfully 57 | aws lambda get-function --function-name "$FUNCTION_NAME" --region "$REGION" 58 | 59 | aws lambda update-function-configuration \ 60 | --function-name $FUNCTION_NAME \ 61 | --environment Variables="{`cat .env.prod | xargs | sed 's/ /,/g'`}" 62 | 63 | -------------------------------------------------------------------------------- /packages/liftoff/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Define required environment variables 4 | REQUIRED_VARS=("APP_PREFIX" "REGION") 5 | 6 | # Loop through the required variables and check if they are defined 7 | for VAR in "${REQUIRED_VARS[@]}" 8 | do 9 | if [[ -z "${!VAR}" ]]; then 10 | echo "Error: ${VAR} environment variable is not defined" 11 | MISSING_VARS=true 12 | fi 13 | done 14 | 15 | # Exit with an error if any required variables are missing 16 | if [ "${MISSING_VARS}" = true ]; then 17 | exit 1 18 | fi 19 | 20 | SCRIPT_DIR=$(dirname "$(readlink -f "$0")") 21 | 22 | $SCRIPT_DIR/compile-project.sh 23 | $SCRIPT_DIR/deploy-lambda.sh 24 | $SCRIPT_DIR/setup-api-gateway.sh 25 | $SCRIPT_DIR/clean-up.sh 26 | -------------------------------------------------------------------------------- /packages/liftoff/scripts/setup-api-gateway.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 4 | FUNCTION_NAME="$APP_PREFIX-lambda-function" 5 | API_NAME="$APP_PREFIX-api-gateway" 6 | 7 | FUNCTION_ARN="arn:aws:lambda:$REGION:$AWS_ACCOUNT_ID:function:$APP_PREFIX-lambda-function" 8 | 9 | 10 | # Check if API Gateway already exists 11 | EXISTING_API=$(aws apigatewayv2 get-apis --query "Items[?Name=='$API_NAME']") 12 | echo "EXISTING_API $EXISTING_API" 13 | 14 | if [ "$EXISTING_API" == "[]" ]; then 15 | # Create an API Gateway REST API 16 | API_ID=$(aws apigatewayv2 create-api --name $API_NAME --protocol-type HTTP --target $FUNCTION_ARN | jq -r '.ApiId') 17 | 18 | # Create an API Gateway Integration 19 | INTEGRATION_ID=$(aws apigatewayv2 create-integration --api-id $API_ID --integration-type AWS_PROXY --integration-uri $FUNCTION_ARN --integration-method ANY --payload-format-version 2.0 | jq -r '.IntegrationId') 20 | 21 | # Create an API Gateway Route 22 | ROUTE_ID=$(aws apigatewayv2 create-route --api-id $API_ID --route-key "ANY /{proxy+}" --target "integrations/$INTEGRATION_ID" | jq -r '.RouteId') 23 | 24 | # Deploy the API Gateway 25 | DEPLOYMENT_ID=$(aws apigatewayv2 create-deployment --api-id $API_ID --description "Initial deployment" | jq -r '.DeploymentId') 26 | 27 | # Add permissions to Lambda function to allow API Gateway to invoke it 28 | aws lambda add-permission \ 29 | --function-name $FUNCTION_NAME \ 30 | --statement-id "apigateway-test-1" \ 31 | --action "lambda:InvokeFunction" \ 32 | --principal apigateway.amazonaws.com \ 33 | --source-arn "arn:aws:execute-api:$REGION:$AWS_ACCOUNT_ID:$API_ID/*/*/*" 34 | 35 | 36 | # Print out the API Gateway URL 37 | URL="https://$API_ID.execute-api.$REGION.amazonaws.com/$DEPLOYMENT_ID" 38 | echo "API Gateway URL: $URL" 39 | else 40 | echo "Api already exists, skipping" 41 | fi -------------------------------------------------------------------------------- /packages/liftoff/scripts/setup-s3.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | BUCKET_NAME="$APP_PREFIX-lambda-zip-bucket" 4 | 5 | # Check if the bucket exists 6 | if aws s3api head-bucket --bucket "$BUCKET_NAME" 2>/dev/null; then 7 | echo "Bucket already exists: $BUCKET_NAME" 8 | else 9 | # Create the bucket 10 | aws s3api create-bucket --bucket "$BUCKET_NAME" --region $REGION 11 | echo "Bucket created: $BUCKET_NAME" 12 | fi 13 | -------------------------------------------------------------------------------- /packages/shuttle/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/shuttle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webdevcody/shuttle", 3 | "version": "0.0.43", 4 | "description": "", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/express": "^4.17.17", 17 | "@types/node": "^18.15.11", 18 | "@types/uuid": "^9.0.1", 19 | "nodemon": "^2.0.22", 20 | "ts-node": "^10.9.1", 21 | "typescript": "^5.0.2", 22 | "jest": "^29.5.0", 23 | "@types/jest": "^29.5.0", 24 | "@babel/preset-env": "^7.21.4", 25 | "@babel/preset-typescript": "^7.21.4" 26 | }, 27 | "dependencies": { 28 | "dotenv": "^16.0.3", 29 | "envalid": "^7.3.1", 30 | "express": "^4.18.2", 31 | "uuid": "^9.0.0", 32 | "winston": "^3.8.2", 33 | "zod": "^3.21.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/shuttle/src/getHandlerFiles.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { promisify } from "util"; 3 | 4 | const readdir = promisify(fs.readdir); 5 | const stat = promisify(fs.stat); 6 | 7 | export async function getHandlerFiles(directoryPath: string) { 8 | let files: string[] = []; 9 | 10 | async function helper(helperPath: string) { 11 | const stats = await stat(helperPath); 12 | 13 | if (!stats.isDirectory()) { 14 | return files.push(helperPath); 15 | } 16 | 17 | const contents = await readdir(helperPath); 18 | await Promise.all( 19 | contents.map((item: string) => { 20 | const itemPath = `${helperPath}/${item}`; 21 | return helper(itemPath); 22 | }) 23 | ); 24 | } 25 | 26 | await helper(directoryPath); 27 | return files; 28 | } 29 | -------------------------------------------------------------------------------- /packages/shuttle/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import shuttle from "./index"; 2 | 3 | jest.mock("envalid", () => ({ 4 | ...jest.requireActual("envalid"), 5 | cleanEnv: () => ({ 6 | IS_LAMBDA: true, 7 | }), 8 | })); 9 | 10 | describe("index", () => { 11 | it("should correctly parse testQueryParms from request and return it in the handler which veries query string params are being passed around correctly", async () => { 12 | const { createHandler } = shuttle({ 13 | providers: {}, 14 | }); 15 | const handler = createHandler({ 16 | input: (z) => { 17 | return z.object({ 18 | testQueryParam: z.string(), 19 | }); 20 | }, 21 | handler: async ({ input }) => { 22 | return input.testQueryParam; 23 | }, 24 | }); 25 | const mockReq = { 26 | query: { 27 | testQueryParam: "hello world", 28 | }, 29 | }; 30 | const mockRes = { 31 | json: jest.fn(), 32 | }; 33 | await handler({} as any, {}, mockReq as any, mockRes as any); 34 | expect(mockRes.json).toBeCalledWith("hello world"); 35 | }); 36 | 37 | it("should have access to the setup providers inside the handler", async () => { 38 | const expectedMessage = "this is a message"; 39 | const { createHandler } = shuttle({ 40 | providers: { 41 | getMessage() { 42 | return expectedMessage; 43 | }, 44 | }, 45 | }); 46 | const handler = createHandler({ 47 | handler: async ({ providers }) => { 48 | return providers.getMessage(); 49 | }, 50 | }); 51 | const mockReq = { 52 | query: {}, 53 | }; 54 | const mockRes = { 55 | json: jest.fn(), 56 | }; 57 | await handler({} as any, {}, mockReq as any, mockRes as any); 58 | expect(mockRes.json).toBeCalledWith(expectedMessage); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/shuttle/src/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { runRoute } from "./runRoute"; 3 | import winston, { Logger } from "winston"; 4 | const { format } = winston; 5 | import { 6 | cleanEnv, 7 | ValidatorSpec, 8 | str, 9 | port, 10 | bool, 11 | url, 12 | host, 13 | email, 14 | json, 15 | num, 16 | } from "envalid"; 17 | import dotenv from "dotenv"; 18 | import z, { ZodTypeAny } from "zod"; 19 | dotenv.config(); 20 | 21 | type EnvValidators = { 22 | str: typeof str; 23 | port: typeof port; 24 | bool: typeof bool; 25 | url: typeof url; 26 | host: typeof host; 27 | email: typeof email; 28 | json: typeof json; 29 | num: typeof num; 30 | }; 31 | 32 | const LOG_LEVELS = [ 33 | "error", 34 | "warn", 35 | "info", 36 | "http", 37 | "verbose", 38 | "debug", 39 | "silly", 40 | ] as const; 41 | 42 | type Options = { 43 | providers: P; 44 | env?: (validators: EnvValidators) => { 45 | [K in keyof T]: ValidatorSpec; 46 | }; 47 | }; 48 | 49 | export default function app(appOptions: Options) { 50 | const env = cleanEnv(process.env, { 51 | ...(appOptions.env?.({ 52 | str, 53 | port, 54 | bool, 55 | url, 56 | host, 57 | email, 58 | json, 59 | num, 60 | }) ?? 61 | ({} as { 62 | [K in keyof T]: ValidatorSpec; 63 | })), 64 | LOG_LEVEL: str({ 65 | choices: LOG_LEVELS, 66 | }), 67 | NODE_ENV: str({ 68 | choices: ["development", "test", "production", "staging"], 69 | }), 70 | PORT: port(), 71 | IS_LAMBDA: bool(), 72 | }); 73 | const app = express(); 74 | 75 | const logger = winston.createLogger({ 76 | level: env.LOG_LEVEL, 77 | transports: [ 78 | new winston.transports.Console({ 79 | format: format.combine( 80 | format.json(), 81 | format.prettyPrint(), 82 | format.colorize({ all: true }) 83 | ), 84 | } as any) as any, 85 | ], 86 | }); 87 | 88 | app.use(express.json()); 89 | 90 | app.use("*", function handleRequest(req, res) { 91 | runRoute(app, appOptions.providers, logger, env, req, res).catch((err) => { 92 | logger.error("an internal exception occured inside of shuttle", err); 93 | res.status(500).send("something bad happened"); 94 | }); 95 | }); 96 | 97 | if (!env.IS_LAMBDA) { 98 | app.listen(env.PORT); 99 | console.log(`LaunchPad listening on port ${env.PORT}`); 100 | } 101 | 102 | type CombinedEnv = typeof env; 103 | 104 | function createHandler( 105 | options: CreateHandlerOptions 106 | ) { 107 | return async function wrappedHandler( 108 | { logger, env: CombinedEnv }: ShuttleContext, 109 | params: object, 110 | req: Request, 111 | res: Response 112 | ) { 113 | const requestPayload = { 114 | ...req.body, 115 | ...req.query, 116 | ...params, 117 | }; 118 | const input = options.input?.(z)?.parse(requestPayload) ?? requestPayload; 119 | 120 | const result = await options.handler({ 121 | input, 122 | providers: appOptions.providers, 123 | logger, 124 | env, 125 | }); 126 | 127 | const output = options.output?.(z).parse(result) ?? result; 128 | 129 | res.json(output); 130 | }; 131 | } 132 | 133 | return { env, createHandler, app }; 134 | } 135 | 136 | export type ShuttleContext = { 137 | logger: Logger; 138 | env: E; 139 | }; 140 | 141 | export type ShuttleHandler = ( 142 | context: ShuttleContext, 143 | req: Request, 144 | res: Response 145 | ) => Promise; 146 | 147 | type CreateHandlerOptions = { 148 | input?: (validate: typeof z) => I; 149 | output?: (validate: typeof z) => O; 150 | handler: ( 151 | handlerOptions: { input: z.infer; providers: P } & ShuttleContext 152 | ) => Promise>; 153 | }; 154 | -------------------------------------------------------------------------------- /packages/shuttle/src/mapRouteToFile.test.ts: -------------------------------------------------------------------------------- 1 | import { mapRouteToFile } from "./mapRouteToFile"; 2 | 3 | describe("mapRouteToFile", () => { 4 | it("should return true when a expected file path containing the matching route exists", () => { 5 | const fullPath = mapRouteToFile("/todos", "get", ".ts", ["todos/get.ts"]); 6 | expect(fullPath).toEqual({ params: {}, path: "todos/get.ts" }); 7 | }); 8 | 9 | it("should return true when a expected file path containing the matching route exists", () => { 10 | const fullPath = mapRouteToFile("/todos/123", "get", ".ts", [ 11 | "todos/[id]/get.ts", 12 | "todos/get.ts", 13 | ]); 14 | expect(fullPath).toEqual({ 15 | params: { id: "123" }, 16 | path: "todos/[id]/get.ts", 17 | }); 18 | }); 19 | 20 | it("should allow a user to provide strings containing some special characters in the path params", () => { 21 | const fullPath = mapRouteToFile("/todos/123-aBc_hello", "get", ".ts", [ 22 | "todos/[id]/get.ts", 23 | ]); 24 | expect(fullPath).toEqual({ 25 | params: { id: "123-aBc_hello" }, 26 | path: "todos/[id]/get.ts", 27 | }); 28 | }); 29 | 30 | it("should find the path with a large list of paths", () => { 31 | const paths = ["todos/get.ts", "todos/post.ts", "status/get.ts"]; 32 | 33 | const fullPath = mapRouteToFile("/status", "get", ".ts", paths); 34 | expect(fullPath).toEqual({ 35 | params: {}, 36 | path: "status/get.ts", 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /packages/shuttle/src/mapRouteToFile.ts: -------------------------------------------------------------------------------- 1 | export function mapRouteToFile( 2 | baseUrl: string, 3 | method: string, 4 | extension: string, 5 | handlerFiles: string[] 6 | ) { 7 | for (let path of handlerFiles) { 8 | const fullPathRegex = new RegExp( 9 | path 10 | .replace(/.(t|j)s$/, "") 11 | .replace(/\[[a-zA-Z0-9]+\]/, "([a-zA-Z0-9-_]+)") 12 | ); 13 | const matches = fullPathRegex.exec( 14 | `src/routes${baseUrl}/${method}${extension}` 15 | ); 16 | if (matches) { 17 | const paramsWithValues: Record = {}; 18 | for (let i = 1; i < matches.length; i++) { 19 | const match = path.match(/\[([a-zA-Z0-9\-\_]+)\]/g); 20 | if (!match) continue; 21 | const key = match[i - 1].slice(1, -1); 22 | paramsWithValues[key] = matches[i]; 23 | } 24 | return { path, params: { ...paramsWithValues } }; 25 | } 26 | } 27 | return null; 28 | } 29 | -------------------------------------------------------------------------------- /packages/shuttle/src/runRoute.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | 3 | import path from "path"; 4 | import { Request, Response, type Express } from "express"; 5 | import { Logger } from "winston"; 6 | import { v4 as uuid } from "uuid"; 7 | import { ZodError } from "zod"; 8 | import { getHandlerFiles } from "./getHandlerFiles"; 9 | 10 | export function mapRouteToFile( 11 | baseUrl: string, 12 | method: string, 13 | extension: string, 14 | handlerFiles: string[] 15 | ) { 16 | for (let path of handlerFiles) { 17 | const fullPathRegex = new RegExp( 18 | path.replace(/.(t|j)s$/, "").replace(/\[[a-z0-9]+\]/, "([a-z0-9]+)") 19 | ); 20 | const matches = fullPathRegex.exec( 21 | `src/routes${baseUrl}/${method}${extension}` 22 | ); 23 | if (matches) { 24 | const paramsWithValues: Record = {}; 25 | for (let i = 1; i < matches.length; i++) { 26 | const match = path.match(/\[([a-z0-9]+)\]/g); 27 | if (!match) continue; 28 | const key = match[i - 1].slice(1, -1); 29 | paramsWithValues[key] = matches[i]; 30 | } 31 | return { path, params: { ...paramsWithValues } }; 32 | } 33 | } 34 | return null; 35 | } 36 | 37 | export async function runRoute( 38 | app: Express, 39 | providers: P, 40 | logger: Logger, 41 | env: E, 42 | req: Request, 43 | res: Response 44 | ) { 45 | const method = req.method.toLowerCase(); 46 | 47 | const handlerFiles = await getHandlerFiles("src/routes"); 48 | 49 | if (!handlerFiles.length) { 50 | return res.status(404).send("endpoint does not exist"); 51 | } 52 | 53 | const endpointPath = mapRouteToFile( 54 | req.baseUrl, 55 | method, 56 | env.IS_LAMBDA ? ".js" : ".ts", 57 | handlerFiles 58 | ); 59 | 60 | if (!endpointPath) { 61 | return res.status(404).send("endpoint does not exist"); 62 | } 63 | 64 | let handler: any = await import(path.join(process.cwd(), endpointPath.path)); 65 | 66 | if (env.IS_LAMBDA) { 67 | handler = handler.default; 68 | } 69 | 70 | const requestId = uuid(); 71 | const start = new Date(); 72 | const startMs = Date.now(); 73 | const timeInvoked = start.toISOString(); 74 | const methodUppercase = method.toUpperCase(); 75 | const logContext = { 76 | timeInvoked, 77 | requestId, 78 | method: methodUppercase, 79 | route: endpointPath, 80 | filePath: endpointPath, 81 | }; 82 | 83 | handler 84 | .default({ providers, logger, env }, endpointPath.params, req, res) 85 | .then(function handlerDone() { 86 | const elaspedTime = Date.now() - startMs; 87 | logger.info(`request completed`, { 88 | ...logContext, 89 | elaspedTime, 90 | }); 91 | }) 92 | .catch((error: Error) => { 93 | const elaspedTime = Date.now() - startMs; 94 | logger.error(`request errored with ${error.message}`, { 95 | ...logContext, 96 | error: error.message, 97 | errorTrace: error.stack, 98 | elaspedTime, 99 | }); 100 | if (error instanceof ZodError) { 101 | res.status(400).send(error.message); 102 | } else { 103 | res.status(500).send(error.message); 104 | } 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /packages/shuttle/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2022"], 4 | "module": "commonjs", 5 | "target": "es2022", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "moduleResolution": "node" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /replace.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Replace "file:../shuttle" with "*" in the specified file 4 | if [[ $(uname) == "Darwin" ]]; then 5 | sed -i "" 's/file:..\/shuttle/*/g' $1 6 | else 7 | sed -i 's/file:..\/shuttle/*/g' $1 8 | fi -------------------------------------------------------------------------------- /revert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Replace "*" with "file:../shuttle" in the specified file 4 | if [[ $(uname) == "Darwin" ]]; then 5 | sed -i "" 's/\*/file:..\/shuttle/g' $1 6 | else 7 | sed -i 's/\*/file:..\/shuttle/g' $1 8 | fi --------------------------------------------------------------------------------