diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 6671f9a..569efa1 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -6,6 +6,7 @@ on: jobs: + unit-test: strategy: matrix: @@ -27,8 +28,32 @@ jobs: - name: Install dependencies run: npm install - - name: Run Tests - run: npm test + - name: Run Unit Tests + run: npm run test-unit + + + endpoint-test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + name: Endpoint Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Run Endpoint Tests + run: npm run test-endpoints + linting: name: Linting diff --git a/.gitignore b/.gitignore index 1ac4fed..49124ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ **/.vercel/ **/.sherpa/ +**/.sherpa-dev/ **/sherpa.TS_VALIDATION_BUFFER.ts /node_modules /dist diff --git a/README.md b/README.md index dfafb0d..fc3f211 100644 --- a/README.md +++ b/README.md @@ -1,827 +1,41 @@ -![](./docs/assets/logos/logo-dark.png) -# SherpaJS - Serverless Web Framework -![NPM Version](https://img.shields.io/npm/v/sherpa-core) -[![Testing](https://github.com/sellersindustry/SherpaJS/actions/workflows/testing.yml/badge.svg)](https://github.com/sellersindustry/SherpaJS/actions/workflows/testing.yml) -> [!TIP] -> **The documenation is a bit of a mess right now, to get an overview really quick just checkout the [server example](https://github.com/sellersindustry/SherpaJS-template-server).** -> [!IMPORTANT] -> This project is in early development, so it is possible for you to run into issues. If you run into any issues please just create a new issue and link your code. Feel free to debug or update the code! -> -> - If you have an issue, [let us know](https://github.com/sellersindustry/SherpaJS/issues), even if you fix it. It could help us build a better linter. -> - If the documentation is confusing some place, [ask](https://github.com/sellersindustry/SherpaJS/issues). Also feel free to make a pull request with the updates. -> - Have a suggested change or feature? [Submit a Ticket](https://github.com/sellersindustry/SherpaJS/issues) -> - Need a module that isn't built? Please help us build the SherpaJS Community and built it following our [build guide](#create-a-module), then [submit your module to the community](https://github.com/sellersindustry/SherpaJS/issues). -> -> [Development Notes](#development) +

+ + +

SherpaJS

+ +

-SherpaJS empowers developers to effortlessly construct **modular and agnostic serverless applications**. Developers can easily build serverless web server using a directory-based structure, inspired by NextJS and even import pre-built modules at endpoints. SherpaJS servers can then be compiled to a variety of different web platforms including Vercel Serverless and local Server (with more to come later). +

+ + +

-
- - -## Table of Contents - - [Supported Platforms](#deploy-a-server) - - [Community Modules](#community-modules) - - [Installation](#installation) - - [Commands](#commands) - - [Servers](#servers) - - [Create a Server](#creating-a-server) - - [Configuration](#server-configuration) - - [Deploy a Server](#deploy-a-server) - - Routing - - [Routes](#routes) - - [Endpoints](#endpoints) - - [Static Assets](#static-assets) - - [Modules](#modules) - - [Create a Module](#creating-a-module) - - [Configuration](#module-configuration) - - [Development & Contributing](#development) - - -
-
- - -## Community Modules -| Module | Description | -|---|---| -| [Static Flags](https://github.com/sellersindustry/SherpaJS-static-flags) | Create static flags of booleans, strings, or numbers | -| [Events](https://github.com/sellersindustry/SherpaJS-events) | Create event sending endpoints for analytics platforms like PostHog using [Metadapter Events](https://github.com/sellersindustry/metadapter-event) | - -
-
- - -## Installation -To install SherpaJS, simply run the following command in your terminal: -```bash -npm install sherpa-core -g -``` -This command will globally install the SherpaJS core package, enabling you to utilize its features across your system. Once installed, you can easily run the SherpaJS command-line interface (CLI) using the following command: -```bash -sherpa -npx sherpa -``` -This command initializes the SherpaJS CLI, allowing you to efficiently manage and configure your modular microservice endpoints. [Learn about CLI Commands](#commands). - - -
-
- - -## Commands -CLI for SherpaJS - Modular Microservices Framework - -```bash -sherpa [options] [command] -``` - -#### Options: - - `-V`, `--version` output the version number - - `-h`, `--help` display help for command - -#### Commands: - - `build [options]` Build SherpaJS Server - - `clean [options]` Remove SherpaJS Build Directories - - `help [command]` display help for command - - -
- - -### Build Command -Build SherpaJS Server. -```bash -sherpa build [options] -``` - -#### Options: - - `-i`, `--input ` path to SherpaJS server, defaults to current directory - - `-o`, `--output ` path to server output, defaults to input directory - - `-b`, `--bundler ` platform bundler ("**Vercel**", "*local**", *default: "local"*) - - `-v`, `--variable [key values...]` Specify optional environment variables as key=value pairs Ex. `foo=bar test="1234 HI"` - - `--dev` enable development mode, does not minify output - - `-h`, `--help` display help for command - - -
- - -### Start Command -Start SherpaJS Server Locally. Ensure you have created a [local build](#build-command). -```bash -sherpa start [options] -``` - -#### Options: - - `-i`, `--input ` path to SherpaJS server, defaults to current directory - - `-p`, `--port ` port number (default: "3000") - - `-h`, `--help` display help for command - - -
- - -### Clean Command -Remove SherpaJS Build Directories. -```bash -sherpa clean [options] -``` - -#### Options: - - `-i`, `--input ` path to SherpaJS build directories, defaults to current directory - - `-h`, `--help` display help for command - - -
-
- - -## Servers -A SherpaJS server is a backend web server framework, akin to Flask, primarily -designed for creating serverless applications, offering developers a -lightweight and modular approach to building scalable backend services in JavaScript. - -### Creating a Server -Creating a new server is extremely easy and can be done within a couple of -minutes. Check out the [SherpaJS Server Template](https://github.com/sellersindustry/SherpaJS-template-server) -for an example of how to build your server. - -#### Step 1 -Setup a new NodeJS project with `npm init`. - -#### Step 2 -Install SherpaJS with `npm install sherpa-core`. - -#### Step 3 -Create a new server configuration file in the root directory of your server name `sherpa.server.ts`. This file will default export a [server configuration](#server-configuration). - -```typescript -// sherpa.server.ts -import { SherpaJS } from "sherpa-core"; +--- -export default SherpaJS.New.server({ - context: { // contexts are provided to endpoints, and are optional - example: "foo" - } -}); -``` -#### Step 4 -Create an your endpoints in the `/routes` directory. See an example below or [learn about endpoints](#endpoints). +## Getting Started +SherpaJS is a modular and agnostic serverless JavaScript web framework, that allows developers to easily build backend serverless web applications. -```typescript -// ./routes/index.ts -import { Request, Response, Context } from "sherpa-core"; - -export function GET(request:Request, context:Context) { - return Response.text("Hello World!"); -} -``` - -> [!NOTE] -> It's here where you can load pre-build SherpaJS modules and provide them with context. -> [Loading Modules](#module-endpoint) - - -#### Step 6 -Build the local server with `sherpa build` [command](#build-command). This -will create a NodeJS file at `./.sherpa/index.js` which you can start at using -`node ./.sherpa/index.js` see [local server platform](#local-server) for additional information. - -That's it! You're now ready to start building powerful serverless web -applications with SherpaJS. Happy coding! ⚓ - - -
- - -### Server Configuration -Sherpa servers are configured using a `sherpa.server.ts` file, where you define -the structure and behavior of your server. This configuration file serves as -the entry point for your Sherpa server. - - -#### Config File -The file must located at `sherpa.server.ts` and have a default export of the -config and use the `SherpaJS.New.server` function as follows: -```typescript -import SherpaJS from "sherpa-core"; - -export default SherpaJS.New.server(); -``` - -#### Config Structure - - **Context:** An optional property that allows you to define a context - object. Contexts are provided to endpoints and can contain any - additional data or settings needed for request processing. - -The configuration provided to the server must match the TypeScript object as follows: -```typescript -export type Context = unknown; - -export type ServerConfig = { - context: Schema; -}; -``` - -#### Example Config -```typescript -// sherpa.server.ts -import { SherpaJS } from "sherpa-core"; - -export default SherpaJS.New.server({ - context: { - serverSecret: "foo", - allowThingy: true - } -}); -``` - -```typescript -// sherpa.server.ts -import { SherpaJS } from "sherpa-core"; - -type ConfigExample = { serverSecret:string, allowThingy:boolean }; -export default SherpaJS.New.server({ - context: { - serverSecret: "foo", - allowThingy: true - } -}); -``` - -
- - -### Environment Variables -The `.env` environment file is loaded into your server when the system is -compiled. Any environment variables provided by hosting services (like Vercel) -will also automatically be included in your build. - - -
- - - -### Deploy a Server -SherpaJS can compile to various different web platforms, with more to come -later. [Want to support a new framework? Submit a Ticket](https://github.com/sellersindustry/SherpaJS/issues). -See the [build command](#build-command) to compile to each platform. - - -#### Vercel Serverless -Building to Vercel will generate a Vercel serverless server in the `.vercel` -directory relative to your output. When your SherpaJS server repository is -deployed Vercel this folder will automatically be deployed. Ensure your build -command is set to build SherpaJS with the Vercel bundler. - - -#### Local Server -Building to local server will generate a NodeJS server, that utilizes the built -in HTTP service. This server will be located at the `.sherpa/index.js` relative -to your output. By default the port number is `3000` but you can provide an -different port number with an argument `node ./.sherpa/index.js 5000`. - - -
-
- - -## Routes -Routes in SherpaJS provide a flexible and intuitive way to define -[endpoints](#endpoints) and handle incoming requests within your microservice -architecture. Drawing inspiration from Next.js, SherpaJS routes follow a -directory-based structure located in the `/routes` directory of your module. - - -### Structure of Routes -In the `/routes` directory, you can create additional directories to organize -your routes. For instance, you might have a directory like `/example`, which -contains specific endpoints related to a particular feature or functionality. -Each endpoint within a route is represented by a file named `index.ts`. - -Subroutes are located relative to the modules. For example if an endpoint is -defined in a module at `/example` and the module is loaded at `/app-1/foo` then -the example endpoint will be accessed at `/app-1/foo/example`. - -### Dynamic Routes -To define a dynamic route, simply name a directory using square brackets, such -as `[id]`. Within a dynamic route directory, you can access the parameter value -from the request object in your endpoint logic. For example, if you have a -dynamic route named `[id]`, you can access the parameter using -`request.params.path.get("id")`, to learn more see [endpoint requests](#requests). - -### Examples of Route Structures -```less -/routes -│ -├── /users -│ └── index.ts // Endpoint logic for "/users" -│ -├── /posts -│ └── index.ts // Endpoint logic for "/posts" -│ -├── /auth -│ └── index.ts // Endpoint logic for "/auth" - -``` - -```less -/routes -│ -├── /example -│ ├── index.ts // Endpoint logic for "/example" -│ ├── /subroute -│ └── index.ts // Endpoint logic for "/example/subroute" -│ -├── /[id] -│ └── index.ts // Endpoint logic for "/[id]" access "[id]" with request.params.path.get("id") -``` - -```less -/routes -│ -├── /products -│ ├── index.ts // Endpoint logic for "/products" -│ ├── /[productID] -│ │ └── index.ts // Endpoint logic for "/products/[productID]" access "[productID]" with request.params.path.get("productID") -│ │ -│ └── /category -│ └── index.ts // Endpoint logic for "/products/category" - -``` + * Check out the [documentation](https://docs.page/sellersindustry/SherpaJS) + * Check out the [server template example](https://github.com/sellersindustry/SherpaJS-template-server) + * Check out the [module template example](https://github.com/sellersindustry/SherpaJS-template-module)
-## Endpoints -Endpoints represent the individual points of access within your microservice -architecture, allowing clients to interact with specific functionalities or -resources. Endpoints are defined within route files `index.ts` and are -associated with specific HTTP methods (GET, POST, PATCH, PUT, DELETE) to -perform corresponding actions. - -### Function Endpoint -Each endpoint is defined within a route file using the corresponding -HTTP method function. These functions provide access to the incoming request -and the environment, allowing developers to customize the endpoint's behavior -based on the request data and the server environment. - -Endpoint can be defined by exporting a function with the desired method name. -The following HTTP methods are supported: `GET`, `POST`, `PATCH`, `PUT`, and -`DELETE`. - -Endpoint functions receive two parameters: the [request](#requests) which -contains the HTTP request information and the [context](#context) which is -additional properties provided to configure the endpoint. The context is either -provided by the [server configuration](#server-config), if it's the root route -or the [module loader](#module-endpoint), if it's a module route. - -A response should be returned by the function, using the -[SherpaJS Response utility](#response). - -```typescript -import { Request, Context, Response } from "sherpa-core"; - -// Example GET endpoint -export function GET(request:Request, context:Context) { - return Response.text("Hello World"); -} - -// Example POST endpoint -export function POST(request:Request, env:Environment) { - return Response.text("Example POST", { status: 201 }); -} - -// Example DELETE endpoint -export function DELETE(request:Request, env:Environment) { - return Response.JSON({ message: "DELETE request received" }, { status: 204 }); -} -``` - - -### View Endpoint -Endpoints can also render views, this include HTML _(more to come soon)_ files. -To render a view simply place the view file (named `index.html`) in the -endpoint directory. - -When rendering a view you are not allowed to also have a `GET` method in your -function endpoint. Additionally, function endpoints are not required when a view -is provided. View endpoints do not support module endpoints. - - -### Module Endpoint -SherpaJS allows endpoint modules to be loaded, which is a set of endpoints -built by the [community](#community-modules) or [your self](#creating-a-module). -By integrating these prebuilt modules which can range from authentication to -analytics into your server, you can easily extend your server's -functionality without duplicating code. This promotes code organization, -modularity, and reusability, simplifying development and accelerating -time-to-market for your web applications. - -Modules are loaded in the same endpoint file (`index.ts`) as a regular endpoint, -but instead of export HTTP methods you export a loaded module. Simply import the -module and use the `load` method, while providing the context. - -This entry point can either be a relative directory (in which case you must -specify the `sherpa.module` file) or a NPM package name. - -```typescript -// index.ts -import StaticFlags from "sherpajs-static-flags"; - -export default StaticFlags.load({ - test: "Hello World" -}); -``` - -```typescript -// index.ts -import ExampleModule from "../../modules/sherpa.module"; - -export default ExampleModule.load({ - test: "Hello World" -}); -``` - - -### Requests -The request as a typescript type. Parameters are parsed are parsed as the types -they are provided as and if multiple are provided as an array. - -```typescript -enum BodyType { - JSON = "JSON", - Text = "Text", - None = "None" -} - -type Body = Record|string|undefined; - -interface Request { - readonly url:string; - readonly params:{ path:Parameters, query:Parameters }; - readonly method:keyof typeof Method; - readonly headers:Headers; - readonly body:Body; - readonly bodyType:keyof typeof BodyType; -} -``` - -#### Request Example - - `doc/abc/def/page/2?thing1=foo,bar&thing2=true&thing2=false&thing3=4` - - `doc/[testID]/[testID]/page/[pageID]` - - `request.params.path.get("testID")` ➜ `"abc"` - - `request.params.path.getAll("testID")` ➜ `[ "abc", "def" ]` - - `request.params.path.has("testID")` ➜ `true` - - `request.params.path.keys()` ➜ `[ "testID", "pageID" ]` -```json -{ - "url": "/regular/dynamic-paths/abc/def/page/2", - "params": { - "path": { - "testID": [ "abc", "def" ], - "pageID": [ 2 ] - }, - "query": { - "thing1": [ "foo", "bar" ], - "thing2": [ true, false ], - "thing3": [ 4 ] - } - }, - "method": "POST", - "headers": { - "content-type": "application/json", - }, - "bodyType": "JSON", - "body": { - "test": "hello world" - } -} -``` - - -### Context -The [context](#context) is additional properties provided to configure the -endpoint. The context is either provided by the -[server configuration](#server-config), if it's the root route or the -[module loader](#module-endpoint), if it's a module route. If the module provides -a context schema type, the context provided will be verified during build. - - -### Response -The Response class is used to generate HTTP responses. It provides static -methods to create different types of responses, such as text, JSON, and -redirects. - -#### Blank Response -Creates a new response object with default options. Optional provide object that -specifies custom response options such as headers and status code. - -```typescript -import { Request, Headers } from "sherpa-core"; -Response.new(); -Response.new({ status: 201 }); -Response.new({ status: 201, headers: new Headers() }); -``` - -#### Text Response -Generates a text response with the specified text content. Optional provide -object that specifies custom response options such as headers and status code. - -```typescript -import { Request, Headers } from "sherpa-core"; -Response.text("hello world"); -Response.text("hello world", { status: 201 }); -Response.text("hello world", { status: 201, headers: new Headers() }); -``` - - -#### JSON Response -Generates a JSON response with the specified JSON data. Optional provide -object that specifies custom response options such as headers and status code. - -```typescript -import { Request, Headers } from "sherpa-core"; -Response.text({ test: "hello world" }); -Response.text({ foo: "bar" }, { status: 201 }); -Response.text({ num: 3 }, { status: 201, headers: new Headers() }); -``` - - -#### Redirect Response -Generates a redirect response with the specified URL. This URL can either be either... - - Absolute with Origin `https://example.com/foo` - - Absolute `/foo` - - Relative `./foo` or `../foo` -Optional provide object that specifies custom response options such as headers -and status code. - -```typescript -import { Request, Headers } from "sherpa-core"; -Response.redirect("https://example.com/foo"); -Response.redirect("/foo", { status: 201 }); -Response.redirect("../foo", { status: 201, headers: new Headers() }); -``` - - -## Static Assets -SherpaJS servers can serve static assets under the `public` folder. - -
-
- - -## Modules -Modules are self-contained units of functional endpoints. They can do various -tasks such as analytics, status updates, authentication, and more. There are -plenty of [community modules](#community-modules), but if what you need doesn't -exist, developing your own modules is very simple. - -
- -### Creating a Module -A new module can be created relatively easily in just a couple of minutes. Check -out the [SherpaJS Module Template](https://github.com/sellersindustry/SherpaJS-template-module) -for an example of how to build your module. - -#### Step 1 -Setup a new NodeJS project with `npm init`. - -> [!TIP] -> You don't have to create a repository to make a module. If you choice you can -> simply create a new directory in your server and skip to [step 3](#step-3-1). - -Ensure your main is set to `sherpa.module.ts` so your ContextSchema and other -resources are accessible by servers implementing your module. -```json -{ - "name": "sherpa-module", - "version": "0.0.2", - "main": "sherpa.module.ts", - "scripts": { - "build": "sherpa build -b Vercel", - "dev": "sherpa build -b local && node ./.sherpa/index.js" - } -} -``` - -#### Step 2 -Install SherpaJS with `npm install sherpa-core`. - -#### Step 3 -Then create a module configuration file in the root directory of your modules -named `sherpa.module.ts`. This file will default export -a [module configuration](#module-configuration). - - -```typescript -// sherpa.module.ts -import { SherpaJS, CreateModuleInterface } from "sherpa-core"; - -export default SherpaJS.New.module({ - name: "example-module", - interface: CreateModuleInterface<{ foo: boolean, bar: string }> -}); -``` - -Alteratively, you can export any `interface` class with a constructor that -takes the context as a parameter sets `this.context` within your class. You -can attach additional methods onto this, that can be used to interact with your -module. - -```typescript -// sherpa.module.ts -import { SherpaJS, ModuleInterface } from "sherpa-core"; - -export default SherpaJS.New.module({ - name: "pass-primary-1", - interface: class example implements ModuleInterface { - context:{ foo: number, bar: string }; - constructor(context:{ foo: number, bar: string }) { - this.context = context; - } - } -}); -``` - - -#### Step 4 -To create endpoints for a new module in SherpaJS, you'll create a new -`/routes` directory the module. Each path inside the route directory will -correspond to it's relative endpoint. Endpoint logic is implemented in -javascript file named `index.ts` within these route directories. - -A simple implementation of an endpoint can be seen below. For detailed -instructions on creating routes and endpoints, see the [endpoints](#endpoints) -section. - -```typescript -// ./route/example/index.ts -import { Response, Request, Environment } from "sherpa-core"; - -export function GET(request:Request, env:Environment) { - return Response({ "hello": "world" }); -} -``` - -#### Step 6 -Create a [Sherpa Server](#servers) to test your module. For more details about -creating server configs see the [creating a server](#creating-a-server) section. - -```typescript -// sherpa.server.ts -import { SherpaJS } from "sherpa-core"; - -export default SherpaJS.New.server({ - context: { - test: true - } -}); -``` - - -#### Step 7 -Share your module with the world and get it listed as a [SherpaJS Community module](#community-modules) by [submitting a new issue](https://github.com/sellersindustry/SherpaJS/issues/new/choose). - -> [!IMPORTANT] -> Ensure your module... -> - Is deployed as an NPM package -> - Contains the documentation on how to set it up, like what properties are required. -> - Link to the SherpaJS documentation, so people understand how to set it up. -> - Share your creation with the world!! Help support SherpaJS!!! - -**Thanks so much for helping support SherpaJS!!! 🥳🎉** - -
- -### Module Configuration -Sherpa modules are configured using a `sherpa.module.ts` file, where you define -the structure and behavior of your module. This configuration file serves as -the entry point for your Sherpa module. - - -#### Config File -The file must located at `sherpa.module.ts` and have a default export of the -config and use the `SherpaJS.New.module` function as follows. You can export -a class using `ContextSchema`, that acts as a wrapper for validating the -context when the module is [loaded](#module-endpoint). - -```typescript -// sherpa.module.ts -import { SherpaJS, CreateModuleInterface } from "sherpa-core"; - -export default SherpaJS.New.module({ - name: "pass-primary-1", - interface: CreateModuleInterface<{ foo: boolean, bar: }> -}); -``` - -Alteratively, you can export any `interface` class with a constructor that -takes the context as a parameter sets `this.context` within your class. You -can attach additional methods onto this, that can be used to interact with your -module. - -```typescript -// sherpa.module.ts -import { SherpaJS, ModuleInterface } from "sherpa-core"; - -export default SherpaJS.New.module({ - name: "pass-primary-1", - interface: class example implements ModuleInterface { - context:{ foo: number, bar: string }; - constructor(context:{ foo: number, bar: string }) { - this.context = context; - } - } -}); -``` - -You can also export any additional attributes that you need, because `sherpa.module.ts` -should be the main script defined in your `package.json`. - - -#### Config Structure - - **Name:** The name of the module. - - **Interface:** Class that has `constructor(context:[TYPE])` and - property `context:[TYPE]`. - -The configuration provided to the module creator must match the TypeScript object as follows: -```typescript -export type ModuleConfig = { - name: string; - interface: Class; -}; -``` - -#### Example Config -```typescript -// sherpa.module.ts -import { SherpaJS, CreateModuleInterface } from "sherpa-core"; - -export default SherpaJS.New.module({ - name: "pass-primary-1", - interface: CreateModuleInterface<{ foo: boolean, bar: }> -}); -``` - - -
-
- - -## Development -This project is in early development, so it is possible for you to run into issues. We do use this product in production, but that doesn't mean there could be issues. If you run into any issues, they will probably be at build time, just let us know. - - -
- - -### Contributing +## Contributing Any help is very much appreciated. Build some useful modules and [submit them to our community](https://github.com/sellersindustry/SherpaJS/issues/new/choose) module list. Even help with documentation or refactoring code is helpful.
-### Proposed Features - - Build Test Harness to test standard endpoint features, bug detection, (and later Vercel Deployment). - - Support more than Text and JSON body payloads - - Auto reloading development server. - - Clean Command. - - Add SherpaJS 500 Error Page. - - Add SherpaJS 404 Error Page. - - Ability to add custom 500 and 404 error pages, with HTML in `/errors`. - - Catch all dynamic routes. - - Ability to add admin portal - - Ability to interact with modules. This can allow other endpoints or code - in the system or admin portals to call special functions that are part of the - module, with the given context. - - Import the endpoint which loads the module. The default export - using SherpaJS.Load.module(path, context, interactionClass); will have - a added optional variable of a class. The sherpaJS.Load.module will return - an instatiatied version of tha class with the context. - - Public Assets - - Migrate to RUST (Start by Migrating Tooling as it probs takes the longest) - - Make a document website with [Mintlify](https://mintlify.com/preview). - - Console Development Server, Live Logs - - -
- -### Proposed Modules - - Dynamic redirect service. - - Authical Authentication Service. - - GitHub Issue Creator Form for Support - -
- ### Credits + - [Evan Sellers](https://github.com/SellersEvan) - Illustration by Icons 8 from Ouch! diff --git a/docs.json b/docs.json index a8a81a9..663b63f 100644 --- a/docs.json +++ b/docs.json @@ -2,6 +2,9 @@ "name": "SherpaJS", "description": "Module and Reusable Microservice Platform. Build and modularize custom API endpoints, inspired by NextJS APIs. Export to Vercel and ExpressJS.", "headerDepth": 5, + "logo": "./assets/logos/favicon.png", + "logoDark": "./assets/logos/favicon.png", + "theme": "#0D95DC", "sidebar": [[ "Getting Started", [ @@ -12,16 +15,18 @@ ], [ "Building Your Application", [ - ["Routes - WIP", "/build/routes"], - ["Endpoints - WIP", "/build/endpoints"], - ["Static Assets - WIP", "/build/static-assets"], - ["Server Config - WIP", "/build/server-config"], - ["Module Config - WIP", "/build/module-config"] + ["Routing", "/build/routing"], + ["Endpoints", "/build/endpoints"], + ["Static Assets", "/build/static-assets"], + ["Server Config", "/build/server-config"], + ["Module Config", "/build/module-config"], + ["Building a Module", "/build/building-a-module"], + ["Miscellaneous", "/build/miscellaneous"] ] ], [ "API Reference", [ - ["Response - WIP", [["Response - WIP", "/api/response"]]], + ["Response", "/api/components/response"], ["Request", "/api/components/request"], ["Headers", "/api/components/headers"], ["Parameters", "/api/components/parameters"], diff --git a/docs/api/cli.mdx b/docs/api/cli.mdx index bbfc427..da9b9f5 100644 --- a/docs/api/cli.mdx +++ b/docs/api/cli.mdx @@ -38,14 +38,15 @@ CLI for SherpaJS - Modular Microservices Framework sherpa [options] [command] ``` -#### Options: +**Options:** - `-V`, `--version` output the version number - `-h`, `--help` display help for command -#### Commands: - - `build [options]` Build SherpaJS Server - - `start [options]` Start SherpaJS Server Locally - - `clean [options]` Remove SherpaJS Build Directories +**Commands:** + - `build [options]` Creates a production build of your application + - `start [options]` Start a production build of your application + - `clean [options]` Removes all build directories of your application + - `dev [options]` Start a server in development mode with hot-reload - `help [command]` display help for command @@ -53,12 +54,12 @@ sherpa [options] [command] ### Build Command -Build SherpaJS Server. +Creates a production build of your application. ```bash sherpa build [options] ``` -#### Options: +**Options:** - `-i`, `--input ` path to SherpaJS server, defaults to current directory - `-o`, `--output ` path to server output, defaults to input directory - `-b`, `--bundler ` platform bundler ("**Vercel**", "*local**", *default: "local"*) @@ -72,12 +73,13 @@ sherpa build [options] ### Start Command -Start SherpaJS Server Locally. Ensure you have created a [local build](/api/cli#build-command). +Start a production build of your application. Ensure you have created a local +build, with [\"sherpa build\"](/api/cli#build-command) first. ```bash sherpa start [options] ``` -#### Options: +**Options:** - `-i`, `--input ` path to SherpaJS server, defaults to current directory - `-p`, `--port ` port number (default: "3000") - `-h`, `--help` display help for command @@ -87,12 +89,29 @@ sherpa start [options] ### Clean Command -Remove SherpaJS Build Directories. +Removes all build directories of your application. ```bash sherpa clean [options] ``` -#### Options: +**Options:** - `-i`, `--input ` path to SherpaJS build directories, defaults to current directory - `-h`, `--help` display help for command + +
+ + +### Dev Command +Start a server in development mode with hot-reload. +```bash +sherpa dev [options] +``` + +**Options:** + - `-i`, `--input ` path to SherpaJS server, defaults to current directory + - `-o`, `--output ` path to server output, defaults to input directory + - `-v`, `--variable [key values...]` Specify optional environment variables as key=value pairs Ex. `foo=bar test="1234 HI"` + - `-p`, `--port ` port number (default: "3000") + - `-h`, `--help` display help for command + diff --git a/docs/api/components/parameters.mdx b/docs/api/components/parameters.mdx index 28489ff..41ee115 100644 --- a/docs/api/components/parameters.mdx +++ b/docs/api/components/parameters.mdx @@ -6,6 +6,11 @@ includes numbers and booleans. This class is very similar to the standard [URLSearchParams class](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) used in web APIs. +When parameters come from the [`Request`](/api/components/request) class, from a +URL they are automatically parsed (booleans and numbers) and seperated by commas. +See [request examples](/api/components/request#basic-get-request), for more +information. +
diff --git a/docs/api/components/request.mdx b/docs/api/components/request.mdx index d3a824a..a434580 100644 --- a/docs/api/components/request.mdx +++ b/docs/api/components/request.mdx @@ -1,5 +1,5 @@ # Request -The `Request` interface represents an HTTP request with various properties such +The `Request` class represents an HTTP request with various properties such as URL, parameters, method, headers, body, and body type. This object is passed to an endpoint when a request is made. @@ -10,7 +10,7 @@ to an endpoint when a request is made. ## Properties ### url - * Type - `string` + * Type - `string` *(readonly)* * Description - The URL of the HTTP request. @@ -18,7 +18,7 @@ to an endpoint when a request is made. ### params - * Type - `{ path: Parameters, query: Parameters }` + * Type - `{ path: Parameters, query: Parameters }` *(readonly)* * Description - An object containing `path` and `query` parameters represented by instances of the [Parameters class](/api/components/parameters). @@ -27,7 +27,7 @@ to an endpoint when a request is made. ### method - * Type - `keyof typeof Method` + * Type - `keyof typeof Method` *(readonly)* * Description - The HTTP method of the request, represented as one of the keys of the [Method](/api/components/request#method-1) enum. @@ -36,7 +36,7 @@ to an endpoint when a request is made. ### headers - * Type - `Headers` + * Type - `Headers` *(readonly)* * Description - The headers of the request, represented by an instance of the [Headers class](/api/components/headers). @@ -45,7 +45,7 @@ to an endpoint when a request is made. ### body - * Type - `Body` + * Type - `Body` *(readonly)* * Description - The body of the request, which can be `undefined`, a string, or a JSON object. The body will be automatically parsed from the request. @@ -54,7 +54,7 @@ to an endpoint when a request is made. ### bodyType - * Type - `keyof typeof BodyType` + * Type - `keyof typeof BodyType` *(readonly)* * Description: The type of the request body, represented as one of the keys of the [BodyType](/api/components/request#bodytypes-1) enum. diff --git a/docs/api/components/response.mdx b/docs/api/components/response.mdx new file mode 100644 index 0000000..e308ab5 --- /dev/null +++ b/docs/api/components/response.mdx @@ -0,0 +1,232 @@ +# Response +The `Response` class represents an HTTP response with various properties such +as status, status text, headers, body, and body type. The object is returned +from an endpoint when a request is made. The class also has static methods for +creating creating HTTP response objects with various content types. + + +
+ + +## Properties + +### status + * Type - `number` *(readonly)* + * Description - The status code for the response. See + [HTTP response status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) + for more information. + + +
+ + +### statusText + * Type - `string` *(readonly)* + * Description - The status message for the corresponding status code. See + [HTTP response status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) + for more information. + + +
+ + +### headers + * Type - `Headers` *(readonly)* + * Description - The headers of the response, represented by an instance + of the [Headers class](/api/components/headers). + + +
+ + +### body + * Type - `Body` *(readonly)* + * Description - The body of the response, which can be `undefined`, a string, + or a JSON object. + + +
+ + +### bodyType + * Type - `keyof typeof BodyType` *(readonly)* + * Description - The type of the response body, represented as one of the keys of + the [BodyType](/api/components/response#bodytypes-1) enum. + + +
+ + +## Static Methods + + +### new +`static new(options?: Partial): Response` \ +Creates a new response with no body. + * `options` (optional): Partial configuration options for the response. + * [`headers`](/api/components/headers): The headers to include in the response. + * `status`: The HTTP status code of the response. + + +
+ + +### text +`static text(text: T, options?: Partial): Response` \ +Creates a new response with a text body. + * `text`: The text content for the response body. Can be an object with a `toString` method or a plain string. + * `options` (optional): Partial configuration options for the response. + * [`headers`](/api/components/headers): The headers to include in the response. + * `status`: The HTTP status code of the response. + + +
+ + +### JSON +`static JSON }>(JSON: T | Record, options?: Partial): Response` \ +Creates a new response with a JSON body. + * `JSON`: The JSON content for the response body. Can be an object with a `toJSON` method or a plain object. + * `options` (optional): Partial configuration options for the response. + * [`headers`](/api/components/headers): The headers to include in the response. + * `status`: The HTTP status code of the response. + + +
+ + +### HTML +`static HTML(html: string, options?: Partial): Response` \ +Creates a new response with an HTML body. + * `html`: The HTML content for the response body. + * `options` (optional): Partial configuration options for the response. + * [`headers`](/api/components/headers): The headers to include in the response. + * `status`: The HTTP status code of the response. + + +
+ + +### redirect +`static redirect(redirect: string): Response` \ +Creates a new redirect response with a `Location` header. + * `redirect`: The URL to redirect to. + + +
+ + +## Enums + +### BodyType +An enum representing the possible types of the request body: + * `JSON` - The body is in JSON format. + * `Text` - The body is in plain text format. + * `HTML` - The body is in HTML format. + * `None` - There is no body associated with the request. + + +
+ + +## Examples + +### No Body +```typescript +import { Response, Headers } from "sherpa-core"; + +export function GET() { + return new Response(); +} + +export function POST() { + return new Response({ + status: 201, + headers: { + "X-Foo": "bar" + } + }); +} + +export function PUT() { + return Response.new({ + status: 401, + headers: new Headers({ + "X-Foo": "bar" + }) + }); +} +``` + + +
+ + +### Text Body +```typescript +import { Response, Headers } from "sherpa-core"; + +export function GET() { + return Response.text("Hello World!"); +} + +export function POST() { + return Response.text("Hello World!", { + status: 201 + }); +} +``` + + +
+ + +### JSON Body +```typescript +import { Response, Headers } from "sherpa-core"; + +export function GET() { + return Response.JSON({ "foo": "bar" }); +} + +export function POST() { + return Response.JSON({ "foo": "bar" }, { + status: 201 + }); +} +``` + + +
+ + +### HTML Body +```typescript +import { Response, Headers } from "sherpa-core"; + +export function GET() { + return Response.HTML("

Hello World

"); +} + +export function POST() { + return Response.HTML("

Hello World

", { + status: 201 + }); +} +``` + + +
+ + +### Redirect +Assuming this endpoint is at route `/example/foo` the user will be redirected +to `/example/success`. + +```typescript +import { Response, Headers } from "sherpa-core"; + +export function GET() { + return Response.redirect("../success"); +} +``` diff --git a/docs/assets/logos/favicon.png b/docs/assets/logos/favicon.png new file mode 100644 index 0000000..71efe8c Binary files /dev/null and b/docs/assets/logos/favicon.png differ diff --git a/docs/assets/logos/logo-dark.png b/docs/assets/logos/logo-dark.png index 4f201cc..cc6f1ad 100644 Binary files a/docs/assets/logos/logo-dark.png and b/docs/assets/logos/logo-dark.png differ diff --git a/docs/assets/logos/logo-large-dark.png b/docs/assets/logos/logo-large-dark.png new file mode 100644 index 0000000..4f201cc Binary files /dev/null and b/docs/assets/logos/logo-large-dark.png differ diff --git a/docs/assets/logos/logo-large-light.png b/docs/assets/logos/logo-large-light.png new file mode 100644 index 0000000..a6bbf5c Binary files /dev/null and b/docs/assets/logos/logo-large-light.png differ diff --git a/docs/assets/logos/logo-light.png b/docs/assets/logos/logo-light.png index a6bbf5c..ec575bf 100644 Binary files a/docs/assets/logos/logo-light.png and b/docs/assets/logos/logo-light.png differ diff --git a/docs/build/building-a-module.mdx b/docs/build/building-a-module.mdx new file mode 100644 index 0000000..e3c098f --- /dev/null +++ b/docs/build/building-a-module.mdx @@ -0,0 +1,146 @@ +# Building a Module +Building a SherpaJS module is straightforward and can be accomplished in just a +few steps. Modules allow you to encapsulate and share functionality across +multiple SherpaJS applications. Here's how to get started: + + +
+ + +## Quick Installation +We recommend starting with the +[SherpaJS Module Template](https://github.com/sellersindustry/SherpaJS-template-module), +which provides a pre-configured structure and example endpoints. After +downloading the template, install all dependencies by running: + +```sh +npm install +``` + +You can start the development server with `npm run dev`. + +Now your project is all setup, explore around the project files and get +acquainted with the [SherpaJS Project Structure](/structure) and +[SherpaJS CLI](/api/cli). + + +
+ + +## Manual Installation +Creating a new module is extremely easy and can be done within a couple of +minutes. To manually create a new module start by create a new NodeJS project +with `npm init`. Then install the SherpaJS Core package: + +```sh +npm install sherpa-core +``` + + +
+ + + +### Updating `package.json` +Open the `package.json` file in the root directory of your project, and +update the following properties. + +```json title="package.json" +{ + "type": "module", + "exports": "./sherpa.module.ts", + "scripts": { + "build": "sherpa build -b Vercel", + "build-local": "sherpa build -b local", + "start": "sherpa start", + "dev": "npm run build-local && npm run start" + } +} +``` + + +
+ +### Creating Module Config +Create a [module config](/build/module-config) file named `sherpa.module.ts` in +the root directory of your module. This file will default export a module +configuration. + +```typescript title="sherpa.module.ts" +import { SherpaJS, CreateModuleInterface } from "sherpa-core"; + +export type Schema = { foo: boolean, bar: string }; + +export default SherpaJS.New.module({ + name: "example-module", + interface: CreateModuleInterface() +}); +``` + +[Learn more about module configs](/build/module-config). + + +
+ + +### Creating Server Config +Create a new Typescript file for your [server configuration](/build/server-config) +in the root directory of your project named `sherpa.server.ts`. *Technically a +server config is not required to create a module, but it is to test the module*. + +```typescript title="sherpa.server.ts" +import { SherpaJS } from "sherpa-core"; +import { Schema } from "../sherpa.module.ts"; + +export default SherpaJS.New.server({ + context: { + example: "foo" + } +}); +``` + +The context you provide the server should match the context schema for your module. + + +### Creating Endpoints +SherpaJS uses a directory-based structure for routing, similar to NextJS. Start +by creating a `/routes` directory and place a new enpoint `index.ts` file in the +directory. This will be processed at the root `/` of your application. + +```typescript title="routes/index.ts" +import { Request, Response } from "sherpa-core"; +import { Schema } from "../sherpa.module.ts" + +export function GET(request:Request, context:Schema) { + return Response.text("Hello World!"); +} +``` + +Learn more about [routing](/build/rounting) and [endpoints](/build/endpoints). + + +
+ + +### Details +While the routes of your module are available on a server when the module is +loaded, other assets like the `public` directory are not available. Keep this +in mind when designing your module. + + +
+ + +### Share Your Module +Share your module with the world by deploying it as an NPM package and +submitting a +[new issue](https://github.com/sellersindustry/SherpaJS/issues/new/choose) +to get it listed as a SherpaJS Community module. + +**Important Notes** + * Ensure your module is deployed as an NPM package. + * Include documentation on how to set it up, including required properties. + * Link to the SherpaJS documentation for users to understand how to set it up. + +Thank you for supporting SherpaJS! 🎉🥳 + diff --git a/docs/build/endpoints.mdx b/docs/build/endpoints.mdx index 6e5e504..993c515 100644 --- a/docs/build/endpoints.mdx +++ b/docs/build/endpoints.mdx @@ -1,7 +1,223 @@ # Endpoints +Endpoints represent the individual points of access within your microservice +architecture, allowing clients to interact with specific functionalities or +resources. Endpoints are defined within route files (`index.ts`, `module.ts`, +and `view.html`). -## Functions Endpoint -## View Endpoint +
+ + +## Function Endpoints +Function endpoints are defined using a `index.ts` file. Each endpoint is +defined within a route file using the corresponding HTTP +method (`GET`, `POST`, `PATCH`, `PUT`, `DELETE`) function. These functions provide +access to the incoming request and the environment, allowing developers +to customize the endpoint's behavior based on the request data and the +server environment. + + +
+ + +### Defining Function Endpoints +Endpoint functions receive two parameters: +1. [`request`](/api/components/request) - Contains the HTTP request information. +2. `context` - Additional properties provided to configure the endpoint. The + context is either provided by the server configuration + (if it's the root route) or the module loader (if it's a module route). + +A response should be returned by the function, using the +[SherpaJS Response utility](/api/components/response). + + +#### Supported HTTP methods + * [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) + * [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) + * [PATCH](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) + * [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) + * [DELETE](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE) + + +
+ + +### Example Function Endpoint +```typescript title="/routes/index.ts" +import { Request, Context, Response } from "sherpa-core"; + +// Example GET endpoint +export function GET(request: Request, context: Context) { + return Response.text("Hello World"); +} + +// Example POST endpoint +export function POST(request: Request, context: Context) { + return Response.text("Example POST", { status: 201 }); +} + +// Example DELETE endpoint +export function DELETE(request: Request, context: Context) { + return Response.JSON({ message: "DELETE request received" }, { status: 204 }); +} +``` + + +
+ + +## View Endpoints +Endpoints can also render views, including HTML files. To render a view, +simply place the view file (named `view.html`) in the endpoint directory. + + +
+ + +### Defining Function Endpoints +Simply create a view endpoint by creating a `view.html` in an endpoint +directory. You can use view endpoints along with function endpoints, but there +are a couple of additional rules: + +1. When rendering a view, you cannot also have a GET method in your function endpoint. +2. Function endpoints are not required when a view is provided. +3. View endpoints do not support module endpoints. + + +
+ + +### Example View Endpoint +```html title="/routes/about/view.html" + + + + + + Welcome to SherpaJS + + +

Welcome to SherpaJS

+

This is an example HTML view rendered by SherpaJS.

+

Use this template to create your own custom views and endpoints.

+

© 2024 SherpaJS. All rights reserved.

+ + +``` + + +
+ + +## Module Endpoints +SherpaJS allows endpoint modules to be loaded, which are sets of endpoints built +by the [community](/#community-modules) or [yourself](/build/building-a-module). +By integrating these prebuilt modules, which can range from authentication to +analytics, you can easily extend your server's functionality without +duplicating code. These modules are loaded with a `module.ts` endpoint file. + + +
+ + +### Defining Module Endpoints +Modules are loaded in the same endpoint file (`module.ts`) as a regular +endpoint. Instead of exporting HTTP methods, you export a loaded module. +Simply import the module and use the load method, while providing the context. + + + Additional segment routes or endpoints are not allowed where module + endpoints are defined. + + + +
+ + +### Example Module Loading +The module entry point can either be a relative directory +(in which case you must specify the `sherpa.module.ts` file)... + +```typescript title="/routes/example/module.ts" +import ExampleModule from "../../modules/sherpa.module"; + +export default ExampleModule.load({ + test: "Hello World" +}); +``` + +or an NPM package name... + +```typescript title="/routes/flags/module.ts" +// module.ts +import StaticFlags from "sherpajs-static-flags"; + +export default StaticFlags.load({ + test: "Hello World" +}); +``` + + + Module interfaces or context can be access from other places within your + code, using [interface calling](/build/module-config#module-interface-call-example). + + + +
+ + +## Example Routing + +### Basic Route Structure +```plaintext +/routes +│ +├── /users +│ └── index.ts // Function endpoint for "/users" +│ +├── /posts +│ └── index.ts // Function endpoint for "/posts" +│ +├── /auth +│ └── index.ts // Function endpoint for "/auth" +``` + + +
+ + +### Nested and Dynamic Routes +```plaintext +/routes +│ +├── /example +│ ├── index.ts // Function endpoint for "/example" +│ ├── /subroute +│ │ └── index.ts // Function endpoint for "/example/subroute" +│ +├── /[id] +│ └── index.ts // Function endpoint for "/[id]" access "[id]" with request.params.path.get("id") +│ +├── /products +│ ├── index.ts // Function endpoint for "/products" +│ ├── /[productID] +│ │ └── index.ts // Function endpoint for "/products/[productID]" access "[productID]" with request.params.path.get("productID") +│ └── /category +│ └── index.ts // Function endpoint for "/products/category" +``` + + +
+ + +### View and Module Endpoints +```plaintext +/routes +│ +├── /home +│ └── view.html // View endpoint for "/home" +│ +├── /dashboard +│ └── module.ts // Module endpoint for "/dashboard" +``` -## Module Endpoint \ No newline at end of file diff --git a/docs/build/miscellaneous.mdx b/docs/build/miscellaneous.mdx new file mode 100644 index 0000000..b5a8f77 --- /dev/null +++ b/docs/build/miscellaneous.mdx @@ -0,0 +1,60 @@ +# Miscellaneous + + +## Environment Variables +Environment variables are a key part of configuring your SherpaJS application. +They allow you to set various configuration options and secrets without +hardcoding them into your application's codebase. + + +
+ + +### Loading Environment Variables +SherpaJS uses the `.env` file to load environment variables. This file should +be placed at the root of your project. When the system is compiled, the +variables defined in this file are automatically loaded into your server's +environment. + +Any environment variables provided by hosting services (such as Vercel or AWS) +are automatically included in your build. + +Environment varibles can also be added during build with the +[build command](/api/cli#build-command). + + +
+ + +### Automatic Parsing +SherpaJS automatically parses environment variables to their appropriate types: +- Strings remain as strings +- Booleans are converted to `true` or `false` +- Numbers are converted to numeric values + + +
+ + +### Example .env File +Here is an example of a `.env` file: + +```shell title=".env" +PORT=3000 # number +DATABASE_URL=mongodb://localhost:27017/mydatabase # string +JWT_SECRET=myverysecretkey # string +ENABLE_FEATURE_X=true # boolean +MAX_CONNECTIONS=100 # number +``` + +
+ + +### Best Practices +- **Security**: Never commit your `.env` file to version control. Add it + to your `.gitignore` file. +- **Defaults**: Provide sensible default values for environment variables + in your code. +- **Validation**: Validate the presence and format of critical environment + variables at the start of your application to avoid runtime errors. + diff --git a/docs/build/module-config.mdx b/docs/build/module-config.mdx index a171803..9e3d862 100644 --- a/docs/build/module-config.mdx +++ b/docs/build/module-config.mdx @@ -1 +1,187 @@ -# Module Config \ No newline at end of file +# Module Configuration +Sherpa modules are configured using a `sherpa.module.ts` file, where you define +the structure and behavior of your module. This configuration file is located +at the root of your project and serves as the entry point for your Sherpa module. + + +## Config File +The configuration file must be located at `sherpa.module.ts` and have a +default export of the config using the `SherpaJS.New.module` function as +follows. You can export a class using `CreateModuleInterface` that acts as a +wrapper for validating the context when the module is loaded. + + +
+ + +### Basic Configuration +This basic configuration sets up a module with a simple interface +definition. The context schema is defined inline using the +`CreateModuleInterface` function. + +```typescript title="sherpa.module.ts" +import { SherpaJS, CreateModuleInterface } from "sherpa-core"; + +export default SherpaJS.New.module({ + name: "example-module", + interface: CreateModuleInterface<{ foo: boolean, bar: string }>() +}); +``` + + +
+ + +### Class-Based Configuration +Alternatively, you can export any interface class with a constructor that +takes the context as a parameter and sets `this.context` within your class. +You can attach additional methods to interact with your module. + +These interfaces can be called by other functions and endpoints inside your +codebase, see the +[calling module interfaces example](/build/module-config#module-interface-call-example). + +```typescript title="sherpa.module.ts" +import { SherpaJS, ModuleInterface } from "sherpa-core"; + +export default SherpaJS.New.module({ + name: "example-module", + interface: class Example implements ModuleInterface { + context: { foo: number, bar: string }; + constructor(context: { foo: number, bar: string }) { + this.context = context; + } + } +}); +``` + + + You can also export any additional attributes from this file as needed. The + module config file, `sherpa.module.ts` should be the main script defined + in your `package.json`. + + + +
+ + +## Config Structure + +### name +The name of the module. + + +
+ + +### interface +A class that has a constructor with `context:[TYPE]` parameter and a +property `context: [TYPE]`. You can also use `CreateModuleInterface` to +generate this class. + + +
+ + +## Module Config Type +The module configuration type is structured as: + +```typescript +{ + name:string; + interface:T; +} +``` + +where `T` is a class that implements implements `ModuleInterface`. Thus, it +has a `context` and a `constructor` that takes `context` as a parameter. + +```typescript +{ + context:Schema; + constructor(context:Schema) { this.context = context; } +} +``` + + +
+ + +## Example Config + +### Basic Example +A basic configuration using the `CreateModuleInterface` function. + +```typescript title="example-module/sherpa.module.ts" +import { SherpaJS, CreateModuleInterface } from "sherpa-core"; + +export default SherpaJS.New.module({ + name: "example-module", + interface: CreateModuleInterface<{ foo: boolean, bar: string }>() +}); +``` + +Loading the module from a server endpoints, [learn more](/build/endpoints). +```typescript title="route/module.ts" +import ExampleModule from "../example-module/sherpa.module.ts"; + +export default ExampleModule.load({ + foo: true, + bar: "hello" +}) +``` + + + +
+ + +### Module Interface Call Example + +A basic configuration using the `CreateModuleInterface` function. + +```typescript title="example-module/sherpa.module.ts" +import { SherpaJS, CreateModuleInterface } from "sherpa-core"; + +export type Schema = { foo: number, bar: string }; + +export default SherpaJS.New.module({ + name: "example-module", + interface: class Example implements ModuleInterface { + + context:Schema; + + constructor(context:Schema) { + this.context = context; + } + + getFoo():number { + return this.context.foo; + } + + } +}); +``` + +Loading the a module from a server endpoints, +[learn more](/build/endpoints). +```typescript title="route/example/module.ts" +import ExampleModule from "../../example-module/sherpa.module.ts"; + +export default ExampleModule.load({ + foo: 3, + bar: "hello" +}) +``` + +Calling the loaded module instance from another endpoint... +```typescript title="route/index.ts" +import { Request, Response } from "sherpa-core"; +import example from "./example/module.ts"; + +export function GET() { + return Response.text(example.getFoo()); // returns 3 +} + +``` + diff --git a/docs/build/routes.mdx b/docs/build/routes.mdx deleted file mode 100644 index bc51cc3..0000000 --- a/docs/build/routes.mdx +++ /dev/null @@ -1,5 +0,0 @@ -# Routes - -## Regular Route - -## Dynamic Route \ No newline at end of file diff --git a/docs/build/routing.mdx b/docs/build/routing.mdx new file mode 100644 index 0000000..b15a6de --- /dev/null +++ b/docs/build/routing.mdx @@ -0,0 +1,167 @@ +# Routing +SherpaJS provides a flexible and intuitive way to define endpoints and handle +incoming requests within your microservice architecture. Drawing inspiration +by Next.js, SherpaJS routes follow a directory-based structure located in +the `/routes` directory of your module. + + +
+ + +## Terminology + +### Route Component Tree + * **Routes**: A hierarchical structure for all the segments and endpoints + of the server. + * **Subroutes**: A subsection of the route structure. + * **Root**: The first level of segments and endpoints in a route or subroute. + + +
+ + +### URL Anatomy + * **Segment**: The directory name part of the URL path delimited by slashes. + * **URL Path**: The part of the URL that comes after the domain, composed of segments. + * **Endpoint**: The final destination in the route structure where the request + is handled, typically defined in `index.ts` files. + + +
+ + +## Structure of Routes +In the `/routes` directory, you can create directories to organize your +routes. Each endpoint within a route is represented by a file typically named +`index.ts`, [learn more about other types of endpoints](/build/endpoints). + + +
+ + +### Example Route Structure +```plaintext +/routes +│ +├── /users +│ └── index.ts // Endpoint logic for "/users" +│ +│ +├── /example +│ ├── index.ts // Endpoint logic for "/example" +│ ├── /subroute +│ │ └── index.ts // Endpoint logic for "/example/subroute" +│ +├── /[id] +│ └── index.ts // Endpoint logic for "/[id]" access "[id]" with request.params.path.get("id") +│ +├── /products +│ ├── index.ts // Endpoint logic for "/products" +│ ├── /[productID] +│ │ └── index.ts // Endpoint logic for "/products/[productID]" access "[productID]" with request.params.path.get("productID") +│ └── /category +│ └── index.ts // Endpoint logic for "/products/category" +``` + + +
+ + +## Defining Segment routes +Routes in SherpaJS are created using a file-system based router where folders +define segment routes, and files inside these folders define the endpoint logic. + + +
+ + +### Basic Segment Route +In SherpaJS, the basic segment routes are straightforward. Each folder +represents a route segment, and the `index.ts` file within the folder +contains the endpoint logic, [or other types of endpoints](/build/endpoints). + +```typescript title="routes/index.ts" +import { Request, Response } from "sherpa-core"; + +export function GET(request:Request) { + return Response.text("Hello World!"); +} +``` + + +
+ + +### Nested Segment Route +To create a nested segment route, nest folders inside each other. For example, to +create a `/dashboard/settings` route, you would nest two folders like so: + +```plaintext +/routes +│ +├── /dashboard +│ ├── index.ts // Endpoint logic for "/dashboard" +│ └── /settings +│ └── index.ts // Endpoint logic for "/dashboard/settings" +``` + +```typescript title="routes/dashboard/settings/index.ts" +import { Request, Response } from "sherpa-core"; + +export function GET(request:Request) { + return Response.text("Hello World!"); +} +``` + + +
+ + +### Dynamic Segment Route +To define a dynamic segment route, name a directory using square brackets, such +as `[id]`. Within a dynamic segment route directory, you can access the parameter +value from the request object in your endpoint logic. For example, if you have +a dynamic segement route named `[id]`, you can access the parameter +using `request.params.path.get("id")`. + +```plaintext +/routes +│ +├── /products +│ └── /[id] +│ └── index.ts // Endpoint logic for "/products/[id]" +``` + +```typescript title="routes/products/[id]/index.ts" +import { Request, Response } from "sherpa-core"; + +export function GET(request:Request) { + return Response.new(request.params.path.get("id")); +} +``` + + +
+ + +## Restrictions +The SherpaJS routing system has some restrictions when it comes to defining +routes, these restrictions help to keep routing simple, consistent, and ensure +best practices. The SherpaJS compiler will prevent you from compiling if you +violate any of these restrictions. + * Multiple [dynamic segement routes](/build/routing#dynamic-segment-route) + are not allowed in a single segement route. + * Additional segment routes or endpoints are not allowed as the segment where + a [Module Endpoint](/build/endpoints#module-endpoints) is loaded. + * [Function Endpoints](/build/endpoints#function-endpoints) cannot have a + GET method when paired with a [View Endpoint](/build/endpoints#view-endpoints). + + +
+ + +## Next Steps +Now that you understand the fundamentals of routing in SherpaJS, you can start +creating endpoints for your application. \ + +[Learn more about Endpoints](/build/endpoints) diff --git a/docs/build/server-config.mdx b/docs/build/server-config.mdx index dbfed2e..b63da57 100644 --- a/docs/build/server-config.mdx +++ b/docs/build/server-config.mdx @@ -1 +1,121 @@ -# Server Config \ No newline at end of file +# Server Configuration +Sherpa servers are configured using a `sherpa.server.ts` file, where you define +the structure and behavior of your server. This configuration file is located +at the root of your project and serves as the entry point for your Sherpa server. + + +
+ + +## Config File +The configuration file must be located at `sherpa.server.ts` and have a +default export of the config using the `SherpaJS.New.server` function as follows: + +```typescript title="sherpa.server.ts" +import SherpaJS from "sherpa-core"; + +export default SherpaJS.New.server({ + content: { + serverSecret: process.env.SERVER_SECRET, + allowThingy: true + } +}); +``` + + + When you load modules they have there own context, that is provided + by the module loader, and do not have access to your server context. + + + + +
+ + +## Config Structure + +### Context +A property that allows you to define a context object. Contexts are +provided to endpoints and can contain any additional data or settings needed +for request processing. + + +
+ + +## Server Config Type +The server configuration type is structured as: + +```typescript +{ + context: T; +} +``` + +where `T` can be any type, allowing for flexible and customizable +server configurations. + + +
+ + +## Examples + +### Basic Example +A basic configuration where the context contains simple settings: + +```typescript title="sherpa.server.ts" +import { SherpaJS } from "sherpa-core"; + +export default SherpaJS.New.server({ + context: { + serverSecret: "foo", + allowThingy: true + } +}); +``` + +The context object is then available within endpoints. +```typescript title="routes/index.ts" +import { Request, Response } from "sherpa-core"; + + +export function GET(request:Request, context:unknown) { + consoe.log(context.serverSecret); // returns "foo" + consoe.log(context.allowThingy); // returns true + return Response.new(); +} +``` + + +
+ + +### Typed Example +A more detailed configuration using a typed context for additional type +safety and clarity: + +```typescript title="sherpa.server.ts" +import { SherpaJS } from "sherpa-core"; + +type ConfigExample = { serverSecret: string; allowThingy: boolean }; + +export default SherpaJS.New.server({ + context: { + serverSecret: "foo", + allowThingy: true + } +}); +``` + +The context object is then available within endpoints. +```typescript title="routes/index.ts" +import { Request, Response } from "sherpa-core"; + + +export function GET(request:Request, context:ConfigExample) { + consoe.log(context.serverSecret); // returns "foo" + consoe.log(context.allowThingy); // returns true + return Response.new(); +} +``` diff --git a/docs/build/static-assets.mdx b/docs/build/static-assets.mdx index cb41e9e..ce69756 100644 --- a/docs/build/static-assets.mdx +++ b/docs/build/static-assets.mdx @@ -1 +1,96 @@ -# Static Assets \ No newline at end of file +# Static Assets +SherpaJS servers can serve static assets such as images, CSS, JavaScript +files, and other resources that do not change frequently. These assets are +placed in a dedicated folder within your project and are served directly to +clients when requested. + +This static asset directory is optional. Static assets are not transferred from +modules. + + +
+ + +## Defining Static Assets Directory +The default directory for static assets in a SherpaJS project is the `public` +folder. Any files placed in this folder are accessible via a URL path that +mirrors the directory structure within `public`. + + +
+ + +## Serving Static Assets +To serve static assets, simply place the files within the `public` +directory of your project. For example: + +```plaintext +/your-project +│ +├── /public +│ ├── /images +│ │ └── logo.png +│ ├── /css +│ │ └── styles.css +│ └── /js +│ └── script.js +│ +├── /routes +│ └── view.html +└── sherpa.server.ts +``` + +In this structure: + * `logo.png` will be accessible at `/images/logo.png` + * `styles.css` will be accessible at `/css/styles.css` + * `script.js` will be accessible at `/js/script.js` + * `view.html` will be accessible at `/` + + +
+ + +## Examples + + +### View Endpoints Example +If you want to reference these static assets in your view endpoint or other +files, use the appropriate URL path. For example, in an HTML file: + +```html title="/routes/view.html" + + + + + + My SherpaJS App + + + +
+ Logo +
+
+

Welcome to My SherpaJS App

+

This is a simple example of serving static assets.

+ +
+ + +``` + + +
+ + +## Security Considerations + * **Access Control** - Ensure that sensitive files are not placed in the + `public` directory as they will be publicly accessible. + + +
+ + +By organizing your static assets efficiently and using SherpaJS's built-in +capabilities, you can serve these resources effectively to enhance the +performance and usability of your web applications. \ No newline at end of file diff --git a/docs/index.mdx b/docs/index.mdx index d01d9d4..feea73b 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -3,7 +3,7 @@ title: Overview description: Instant Open Source docs with zero configuration. --- -![SherpaJS Logo](/assets/logos/logo-dark.png) +![SherpaJS Logo](/assets/logos/logo-large-dark.png) ## What is SherpaJS? SherpaJS is a **modular and agnostic serverless JavaScript web framework,** that @@ -67,3 +67,31 @@ The current platforms that SherpaJS supports, with more to come in the future! * Vercel Edge Functions * Local + +
+ + +## Deploy a Server +SherpaJS can compile to various different web platforms, with more to come +later. Want to support a new framework? [Submit a Ticket](https://github.com/sellersindustry/SherpaJS/issues). +See the [build command](/api/cli#build-command) to compile to each platform. + + +
+ + +### Vercel Edge Functions +Building to Vercel will generate a Vercel serverless server in the `.vercel` +directory relative to your output. When your SherpaJS server repository is +deployed Vercel this folder will automatically be deployed. Ensure your build +command is set to build SherpaJS with the Vercel bundler. + + +
+ + +### Local Server +Building to local server will generate a NodeJS server, that utilizes the built +in HTTP service. This server will be located at the `.sherpa/index.js` relative +to your output. You can start the server using +[`sherpa start`](/api/cli#start-command). diff --git a/docs/installation.mdx b/docs/installation.mdx index d9f0022..52d2b11 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -49,11 +49,12 @@ the following properties. ```json title="package.json" { + "type": "module", "scripts": { "build": "sherpa build -b Vercel", "build-local": "sherpa build -b local", "start": "sherpa start", - "dev": "npm run build-local && npm run start" + "dev": "sherpa dev" } } ``` @@ -85,7 +86,7 @@ SherpaJS uses a directory-based structure for routing, similar to NextJS. Start by creating a `/routes` directory and place a new enpoint `index.ts` file in the directory. This will be processed at the root `/` of your application. -```typescript title="index.ts" +```typescript title="/routes/index.ts" import { Request, Response, Context } from "sherpa-core"; export function GET(request:Request, context:Context) { @@ -99,7 +100,8 @@ Learn more about [routing](/build/rounting) and [endpoints](/build/endpoints).
-### Creating Public Assets (Optional) +### Creating Static Assets (Optional) Create a `/public` directory in the root directory of your project to store static assets such as images, stylings and more. +Learn more about [static assets](/build/static-assets). diff --git a/docs/structure.mdx b/docs/structure.mdx index 3136103..c655a11 100644 --- a/docs/structure.mdx +++ b/docs/structure.mdx @@ -27,6 +27,7 @@ Top-level files are used to configure SherpaJS and manage the environment. |---|---| | [`sherpa.server.ts`](/build/server-config) | SherpaJS server config | | [`sherpa.module.ts`](/build/module-config) | SherpaJS module config, *optional only if creating a module* | +| [`.env`](/build/miscellaneous#environment-variables) | Environment Varibles File | @@ -41,8 +42,8 @@ endpoints may persist together, but no other files should be present in your | | | | |---|---|---| -| [`index`](/build/endpoints#functions-endpoint) | `.ts` `.js` | Functions endpoint | -| [`module`](/build/endpoints#module-endpoint) | `.ts` `.js` | Module endpoint | -| [`view`](/build/endpoints#endpoint-view) | `.html` | View endpoint | +| [`index`](/build/endpoints#function-endpoints) | `.ts` `.js` | Functions endpoint | +| [`module`](/build/endpoints#module-endpoints) | `.ts` `.js` | Module endpoint | +| [`view`](/build/endpoints#view-endpoints) | `.html` | View endpoint | | [`/folder_name`](/build/routes#regular-route) | | Regular route segment, *any folder_name* | | [`/[param_name]`](/build/routes#dynamic-route) | | Dynamic route segment, *any param_name* | diff --git a/package-lock.json b/package-lock.json index 4644f40..aad2e4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "checksum": "^1.0.0", + "chokidar": "^3.6.0", "colorette": "^2.0.20", "commander": "^11.1.0", "es-module-lexer": "^1.5.0", @@ -33,7 +34,7 @@ "ts-jest": "^29.1.2" }, "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2114,7 +2115,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -2276,6 +2276,17 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2290,7 +2301,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -2431,6 +2441,40 @@ "checksum": "bin/checksum-cli.js" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -3073,7 +3117,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -3127,7 +3170,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -3384,6 +3426,17 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", @@ -3400,7 +3453,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3427,7 +3479,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -3439,7 +3490,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -4344,7 +4394,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4543,7 +4592,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -4728,6 +4776,17 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5089,7 +5148,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, diff --git a/package.json b/package.json index 32412ae..9b54929 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,9 @@ "prepare": "npm run build", "build": "tsc --build --force ./toolchain/tsconfig.json", "lint": "eslint . --ext .ts -c ./toolchain/.eslintrc.cjs", - "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js -c ./toolchain/jest.config.ts", - "test-server": "node ./dist/tests/endpoints/index.test.js" + "test": "npm run test-unit && npm run test-endpoints", + "test-unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js -c ./toolchain/jest.config.ts", + "test-endpoints": "npm run build && node ./dist/tests/endpoints/index.test.js" }, "devDependencies": { "@types/checksum": "^0.1.35", @@ -46,6 +47,7 @@ "license": "ISC", "dependencies": { "checksum": "^1.0.0", + "chokidar": "^3.6.0", "colorette": "^2.0.20", "commander": "^11.1.0", "es-module-lexer": "^1.5.0", diff --git a/src/cli/index.ts b/src/cli/index.ts index 4b0da5e..0e5cf90 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,6 +20,7 @@ import { getEnvironmentFiles, getAbsolutePath, getKeyValuePairs, getVersion } fr import { Logger } from "../compiler/utilities/logger/index.js"; import { Path } from "../compiler/utilities/path/index.js"; import { Level } from "../compiler/utilities/logger/model.js"; +import { ServerDevelopment } from "../server-development/index.js"; let CLI = new Command(); @@ -29,7 +30,7 @@ CLI.name("sherpa") CLI.command("build") - .description("Build SherpaJS Server") + .description("Creates a production build of your application.") .option("-i, --input ", "path to SherpaJS server, defaults to current directory") .option("-o, --output ", "path to server output, defaults to input directory") .option("--dev", "enable development mode, do not minify output") @@ -67,7 +68,7 @@ CLI.command("build") CLI.command("clean") - .description("Remove SherpaJS Build Directories") + .description("Removes all build directories of your application.") .option("-i, --input ", "path to SherpaJS build directories, defaults to current directory") .action((options) => { Compiler.clean(getAbsolutePath(options.input, process.cwd())); @@ -75,7 +76,7 @@ CLI.command("clean") CLI.command("start") - .description("Start SherpaJS Server Locally") + .description("Start a production build of your application. Ensure you have created a local build, with \"sherpa build\" first.") .option("-i, --input ", "path to SherpaJS build directories, defaults to current directory") .option("-p, --port ", "port number", (3000).toString()) .action((options) => { @@ -98,6 +99,42 @@ CLI.command("start") }); +CLI.command("dev") + .description("Start a server in development mode with hot-reload.") + .option("-i, --input ", "path to SherpaJS server, defaults to current directory") + .option("-o, --output ", "path to server output, defaults to input directory") + .option("-v, --variable [keyvalue...]", "Specify optional environment variables as key=value pairs") + .option("-p, --port ", "port number", (3000).toString()) + .action((options) => { + let input = getAbsolutePath(options.input, process.cwd()); + let output = getAbsolutePath(options.output, input); + let variables = getKeyValuePairs(options.variable); + + if (Logger.hasError(variables.logs)) { + Logger.display(variables.logs); + Logger.exit(); + } + + new ServerDevelopment({ + input: input, + output: output, + bundler: BundlerType.local, + developer: { + bundler: { + esbuild: { + minify: options.dev ? false : true + } + }, + environment: { + files: getEnvironmentFiles(input), + variables: variables.values + } + } + }, parseInt(options.port)); + }); + + + CLI.parse(); diff --git a/src/compiler/bundler/platforms/abstract.ts b/src/compiler/bundler/platforms/abstract.ts index a48d8a1..68f2103 100644 --- a/src/compiler/bundler/platforms/abstract.ts +++ b/src/compiler/bundler/platforms/abstract.ts @@ -37,9 +37,9 @@ export abstract class Bundler { constructor(endpoints:Structure, options:BuildOptions, errors?:Message[]) { - this.endpoints = endpoints.endpoints; - this.assets = endpoints.assets; - this.sever = endpoints.server; + this.endpoints = endpoints.endpoints as EndpointStructure; + this.assets = endpoints.assets as AssetStructure; + this.sever = endpoints.server as ServerConfigFile; this.options = options; this.errors = errors; } diff --git a/src/compiler/bundler/platforms/local/index.ts b/src/compiler/bundler/platforms/local/index.ts index da71f8a..6dd89cf 100644 --- a/src/compiler/bundler/platforms/local/index.ts +++ b/src/compiler/bundler/platforms/local/index.ts @@ -44,7 +44,8 @@ export class Local extends Bundler { const portArg = process.argv[2]; const port = portArg && !isNaN(parseInt(portArg)) ? parseInt(portArg) : 3000; - const server = new ServerLocal(port); + const silent = process.argv.includes("--silent-startup"); + const server = new ServerLocal(port, silent); const dirname = import.meta.dirname; ${this.endpoints.list.map((endpoint:Endpoint, index:number) => { return ` diff --git a/src/compiler/index.ts b/src/compiler/index.ts index 8ffb026..4bc1e03 100644 --- a/src/compiler/index.ts +++ b/src/compiler/index.ts @@ -10,11 +10,12 @@ * */ + import fs from "fs"; import { green, red } from "colorette"; import { getStructure } from "./structure/index.js"; import { Logger } from "./utilities/logger/index.js"; -import { NewBundler, clean } from "./bundler/index.js"; +import { NewBundler } from "./bundler/index.js"; import { BuildOptions, BundlerType } from "./models.js"; import { Level, Message } from "./utilities/logger/model.js"; import { Path } from "./utilities/path/index.js"; @@ -32,22 +33,24 @@ export class Compiler { if (errorsOptions.length) { return this.display({ logs: errorsOptions, verbose, success: false }); } - - let structure = await getStructure(options.input); - let logs = structure.logs; - if (!structure.endpoints || !structure.server || !structure.assets) { - logs.push({ - level: Level.ERROR, - text: "Failed to generate endpoints." - }); - return this.display({ logs, verbose, success: false }); - } - if (Logger.hasError(logs)) { - return this.display({ logs, verbose, success: false }); - } - + let logs:Message[] = []; try { + let structure = await getStructure(options.input); + logs.push(...structure.logs); + + if (!structure.endpoints || !structure.server || !structure.assets) { + logs.push({ + level: Level.ERROR, + text: "Failed to generate endpoints." + }); + return this.display({ logs, verbose, success: false }); + } + + if (Logger.hasError(logs)) { + return this.display({ logs, verbose, success: false }); + } + await NewBundler(structure, options, logs).build(); } catch (error) { logs.push({ @@ -63,7 +66,16 @@ export class Compiler { public static clean(filepath:string) { - clean(filepath); + [ + ".sherpa", + ".sherpa-dev", + ".vercel" + ].forEach(dirName => { + let dirPath = Path.join(filepath, dirName); + if (fs.existsSync(dirPath)) { + fs.rmSync(dirPath, { recursive: true, force: true }); + } + }) } diff --git a/src/compiler/models.ts b/src/compiler/models.ts index 743bca1..a8b798a 100644 --- a/src/compiler/models.ts +++ b/src/compiler/models.ts @@ -117,6 +117,7 @@ export type Endpoint = { methods:Method[]; module:ModuleConfigFile; segments:Segment[]; + path:string; } @@ -135,6 +136,7 @@ export type Asset = { filepath:string; filename:string; segments:Segment[]; + path:string; } diff --git a/src/compiler/structure/assets/index.ts b/src/compiler/structure/assets/index.ts index f10efbb..7e4752a 100644 --- a/src/compiler/structure/assets/index.ts +++ b/src/compiler/structure/assets/index.ts @@ -16,6 +16,7 @@ import { Segment, AssetTree, Asset } from "../../models.js"; import { DirectoryStructureTree } from "../../utilities/path/directory-structure/model.js"; import { Message } from "../../utilities/logger/model.js"; import { getAssetFiles } from "./files.js"; +import { RequestUtilities } from "../../../native/request/utilities.js"; export function getAssets(entry:string):{ assets:AssetTree, logs:Message[] } { @@ -38,7 +39,12 @@ export function flattenAssets(assetTree?:AssetTree):Asset[] { let segments = Object.keys(assetTree).filter(segment => segment != "."); assetList.push(...segments.map(segment => flattenAssets(assetTree[segment] as AssetTree)).flat()); - return assetList.flat(); + return assetList.flat().sort(sortAssets); +} + + +function sortAssets(assetA:Asset, assetB:Asset):number { + return RequestUtilities.compareURL(assetA.segments, assetB.segments); } @@ -49,7 +55,8 @@ function getAssetTree(assetTree:DirectoryStructureTree, segments:Segment[]=[]):A return { filepath: file.filepath.absolute, filename: Path.getFilename(file.filepath.absolute), - segments: segments + segments: segments, + path: RequestUtilities.getDynamicURL(segments) + "/" + Path.getFilename(file.filepath.absolute) } }); } diff --git a/src/compiler/structure/endpoint/files.ts b/src/compiler/structure/endpoint/files.ts index df9ae08..4e236df 100644 --- a/src/compiler/structure/endpoint/files.ts +++ b/src/compiler/structure/endpoint/files.ts @@ -108,6 +108,7 @@ function validateFile(file:DirectoryStructureFile):Message[] { function validateSegments(structure:DirectoryStructureTree, filepath:string):Message[] { let errors:Message[] = []; + errors.push(...validateMultipleDynamicSegments(Object.keys(structure.directories), filepath)); for (let segmentName of Object.keys(structure.directories)) { let _filepath = Path.join(filepath, segmentName); errors.push(...validateSegmentName(segmentName, _filepath)); @@ -129,6 +130,21 @@ function validateSegmentName(segment:string, filepath?:string):Message[] { } +function validateMultipleDynamicSegments(segments:string[], filepath:string):Message[] { + let hasMultipleDynamicPaths = segments.map((segment) => { + return segment.startsWith("[") && segment.endsWith("]"); + }).filter((segment) => segment).length > 1; + if (hasMultipleDynamicPaths) { + return [{ + level: Level.ERROR, + text: "Only one dynamic endpoint per route is allowed.", + file: { filepath: filepath } + }]; + } + return []; +} + + // Who is it that overcomes the world? Only the one who believes that Jesus // is the Son of God. // - 1 John 5:5 diff --git a/src/compiler/structure/endpoint/index.ts b/src/compiler/structure/endpoint/index.ts index df443e0..f7345c3 100644 --- a/src/compiler/structure/endpoint/index.ts +++ b/src/compiler/structure/endpoint/index.ts @@ -11,6 +11,7 @@ */ +import { RequestUtilities } from "../../../native/request/utilities.js"; import { Endpoint, EndpointTree, FILENAME, FILE_EXTENSIONS, ModuleConfigFile, Segment } from "../../models.js"; import { Level, Message } from "../../utilities/logger/model.js"; import { DirectoryStructureTree } from "../../utilities/path/directory-structure/model.js"; @@ -45,7 +46,12 @@ export function flattenEndpoints(endpointTree?:EndpointTree):Endpoint[] { let segments = Object.keys(endpointTree).filter(segment => segment != "."); endpointList.push(...segments.map(segment => flattenEndpoints(endpointTree[segment] as EndpointTree)).flat()); - return endpointList; + return endpointList.sort(sortEndpoints); +} + + +function sortEndpoints(endpointA:Endpoint, endpointB:Endpoint):number { + return RequestUtilities.compareURL(endpointA.segments, endpointB.segments); } diff --git a/src/compiler/structure/endpoint/load-functions.ts b/src/compiler/structure/endpoint/load-functions.ts index 0002072..5c47370 100644 --- a/src/compiler/structure/endpoint/load-functions.ts +++ b/src/compiler/structure/endpoint/load-functions.ts @@ -18,6 +18,7 @@ import { EXPORT_VARIABLES, EXPORT_VARIABLES_METHODS, Method, FILENAME } from "../../models.js"; +import { RequestUtilities } from "../../../native/request/utilities.js"; export async function getEndpointFunctions(module:ModuleConfigFile, functionsFilepath:string|undefined, viewFilepath:string|undefined, segments:Segment[]):Promise<{ logs:Message[], endpoints?:EndpointTree }> { @@ -45,7 +46,8 @@ export async function getEndpointFunctions(module:ModuleConfigFile, functionsFil viewFilepath: viewFilepath, methods: getExportedMethods(variables), module: module, - segments: segments + segments: segments, + path: RequestUtilities.getDynamicURL(segments) } } } diff --git a/src/internal/request/index.ts b/src/internal/request/index.ts index d1e5f99..7460ba9 100644 --- a/src/internal/request/index.ts +++ b/src/internal/request/index.ts @@ -11,7 +11,7 @@ */ -import { IRequest } from "../../native/request/interface.js"; +import { IRequest } from "../../native/request/index.js"; import { Body, BodyType } from "../../native/model.js"; import { Headers } from "../../native/headers/index.js"; import { Parameters } from "../../native/parameters/index.js"; diff --git a/src/internal/response/index.ts b/src/internal/response/index.ts index b9157a4..6ff917a 100644 --- a/src/internal/response/index.ts +++ b/src/internal/response/index.ts @@ -13,8 +13,8 @@ import { OriginURL } from "../../native/url/index.js"; import { BodyType } from "../../native/model.js"; -import { IRequest } from "../../native/request/interface.js"; -import { IResponse } from "../../native/response/interface.js"; +import { IRequest } from "../../native/request/index.js"; +import { IResponse } from "../../native/response/index.js"; import { ServerResponse as LocalResponse } from "http"; const VercelResponse = Response; type VercelResponseType = Response; diff --git a/src/native/index.ts b/src/native/index.ts index 11ce40e..5dec1c2 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -14,28 +14,27 @@ import { Parameters } from "./parameters/index.js"; import { Headers } from "./headers/index.js"; import { Body, BodyType } from "./model.js"; -import { IResponse } from "./response/interface.js"; -import { ResponseBuilder, Options as ResponseOptions } from "./response/index.js"; -import { IRequest } from "./request/interface.js"; +import { IResponse, Options as ResponseOptions } from "./response/index.js"; +import { IRequest } from "./request/index.js"; import { CreateModuleInterface, Method, ModuleInterface } from "../compiler/models.js"; export { - ResponseBuilder as Response, Headers, Parameters, Method, BodyType, CreateModuleInterface, - ModuleInterface + ModuleInterface, + IRequest as Request, + IRequest, + IResponse as Response, + IResponse } export type { Body, - IRequest as Request, - IRequest, - IResponse, ResponseOptions } diff --git a/src/native/request/interface.ts b/src/native/request/index.ts similarity index 93% rename from src/native/request/interface.ts rename to src/native/request/index.ts index 027eed9..e9562a7 100644 --- a/src/native/request/interface.ts +++ b/src/native/request/index.ts @@ -4,7 +4,7 @@ * * author: Evan Sellers * date: Tue Mar 19 2024 - * file: interface.ts + * file: index.ts * project: SherpaJS - Module Microservice Platform * purpose: Request Interface * @@ -17,7 +17,7 @@ import { Parameters } from "../parameters/index.js"; import { Method } from "../../compiler/models.js"; -export interface IRequest { +export class IRequest { readonly url:string; readonly params:{ path:Parameters, query:Parameters }; diff --git a/src/native/request/utilities.ts b/src/native/request/utilities.ts index 765f61a..fd14717 100644 --- a/src/native/request/utilities.ts +++ b/src/native/request/utilities.ts @@ -31,6 +31,23 @@ export class RequestUtilities { } + static compareURL(segmentsA:Segment[], segmentsB:Segment[]):number { + if (segmentsA.length == 0 || segmentsB.length == 0) { + return segmentsA.length - segmentsB.length; + } + if (segmentsA[0].isDynamic && !segmentsB[0].isDynamic) { + return 1; + } + if (!segmentsA[0].isDynamic && segmentsB[0].isDynamic) { + return -1; + } + if (segmentsA[0]["name"] != segmentsB[0]["name"]) { + return segmentsA[0]["name"].localeCompare(segmentsB[0]["name"]); + } + return this.compareURL(segmentsA.slice(1), segmentsB.slice(1)); + } + + } diff --git a/src/native/response/index.ts b/src/native/response/index.ts index d370451..f326eb8 100644 --- a/src/native/response/index.ts +++ b/src/native/response/index.ts @@ -11,44 +11,47 @@ */ -import { Headers } from "../headers/index.js"; -import { BodyType, CONTENT_TYPE } from "../model.js"; -import { IResponse } from "./interface.js"; +import { Headers, HeadersInit } from "../headers/index.js"; +import { Body, BodyType, CONTENT_TYPE } from "../model.js"; import { STATUS_TEXT } from "./status-text.js"; export interface Options { - headers:Headers; + headers:HeadersInit; status:number; } -const DEFAULT_OPTIONS:Options = { - headers:new Headers(), - status:200, -} +export class IResponse { + + readonly status:number; + readonly statusText:string; + readonly headers:Headers; + + readonly body:Body; + readonly bodyType:keyof typeof BodyType; -export class ResponseBuilder { + constructor(options?:Partial) { + let _options = IResponse.defaultOptions(BodyType.None, options); + this.status = _options.status; + this.statusText = IResponse.getStatusText(_options.status); + this.headers = _options.headers; + this.body = undefined; + this.bodyType = BodyType.None; + } static new(options?:Partial):IResponse { - let _options = ResponseBuilder.defaultOptions(BodyType.None, options); - return { - status: _options.status, - statusText: ResponseBuilder.getStatusText(_options.status), - headers: _options.headers, - body: undefined, - bodyType: BodyType.None - } + return new IResponse(options); } static text(text:T, options?:Partial):IResponse { - let _options = ResponseBuilder.defaultOptions(BodyType.Text, options); + let _options = IResponse.defaultOptions(BodyType.Text, options); return { status: _options.status, - statusText: ResponseBuilder.getStatusText(_options.status), + statusText: IResponse.getStatusText(_options.status), headers: _options.headers, body: text.toString(), bodyType: BodyType.Text @@ -57,11 +60,11 @@ export class ResponseBuilder { static JSON }>(JSON:T|Record, options?:Partial):IResponse { - let _options = ResponseBuilder.defaultOptions(BodyType.JSON, options); + let _options = IResponse.defaultOptions(BodyType.JSON, options); let _isCallable = JSON.toJSON && typeof (JSON as Record).toJSON === "function"; return { status: _options.status, - statusText: ResponseBuilder.getStatusText(_options.status), + statusText: IResponse.getStatusText(_options.status), headers: _options.headers, body: _isCallable ? (JSON as { toJSON():Record }).toJSON() : JSON, bodyType: BodyType.JSON @@ -70,10 +73,10 @@ export class ResponseBuilder { static HTML(html:string, options?:Partial):IResponse { - let _options = ResponseBuilder.defaultOptions(BodyType.HTML, options); + let _options = IResponse.defaultOptions(BodyType.HTML, options); return { status: _options.status, - statusText: ResponseBuilder.getStatusText(_options.status), + statusText: IResponse.getStatusText(_options.status), headers: _options.headers, body: html, bodyType: BodyType.HTML @@ -81,14 +84,14 @@ export class ResponseBuilder { } - static redirect(redirect:string, options?:Partial):IResponse { - let _options = ResponseBuilder.defaultOptions(BodyType.None, options); + static redirect(redirect:string):IResponse { + let _options = IResponse.defaultOptions(BodyType.None, {}); if (!_options.headers.has("Location")) { _options.headers.set("Location", redirect); } return { status: 302, - statusText: ResponseBuilder.getStatusText(302), + statusText: IResponse.getStatusText(302), headers: _options.headers, body: undefined, bodyType: BodyType.None @@ -96,10 +99,10 @@ export class ResponseBuilder { } - private static defaultOptions(bodyType:BodyType, options?:Partial):Options { - let _options:Options = { - ...DEFAULT_OPTIONS, - ...options + private static defaultOptions(bodyType:BodyType, options?:Partial):{ status:number, headers:Headers } { + let _options = { + status: options?.status || 200, + headers: new Headers(options?.headers || {}) }; if (!_options.headers.has("Content-Type")) { _options.headers.set("Content-Type", CONTENT_TYPE[bodyType] as string); @@ -116,7 +119,6 @@ export class ResponseBuilder { return text; } - } diff --git a/src/native/response/interface.ts b/src/native/response/interface.ts deleted file mode 100644 index 1aebc00..0000000 --- a/src/native/response/interface.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) 2024 Sellers Industries, Inc. - * distributed under the MIT License - * - * author: Evan Sellers - * date: Tue Mar 19 2024 - * file: interface.ts - * project: SherpaJS - Module Microservice Platform - * purpose: Response Interface - * - */ - - -import { Body, BodyType } from "../model.js"; -import { Headers } from "../headers/index.js"; - - -export interface IResponse { - - readonly status:number; - readonly statusText:string; - readonly headers:Headers; - - readonly body:Body; - readonly bodyType:keyof typeof BodyType; - -} - - -// Whoever believes in me, as Scripture has said, rivers of living water will -// flow from within them. -// - John 7:38 diff --git a/src/server-development/index.ts b/src/server-development/index.ts new file mode 100644 index 0000000..2de1c8c --- /dev/null +++ b/src/server-development/index.ts @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2024 Sellers Industries, Inc. + * distributed under the MIT License + * + * author: Evan Sellers + * date: Mon May 13 2024 + * file: index.ts + * project: SherpaJS - Module Microservice Platform + * purpose: Local Development Server + * + */ + + +import fs from "fs"; +import chokidar from "chokidar"; +import { BuildOptions, Compiler } from "../compiler/index.js"; +import { Path } from "../compiler/utilities/path/index.js"; +import { ChildProcessWithoutNullStreams, spawn } from "child_process"; +import { Logger } from "../compiler/utilities/logger/index.js"; +import { cyan, green, red } from "colorette"; + + +export class ServerDevelopment { + + + private readonly options:BuildOptions; + private readonly port:number|undefined; + private server:ChildProcessWithoutNullStreams; + private initial:boolean; + + + constructor (options:BuildOptions, port?:number) { + this.options = options; + this.port = port; + this.initial = true; + this.makeTempDir(); + this.start(); + } + + + private start() { + this.refresh(); + chokidar.watch(this.options.input, { + persistent: true, + ignored: /(^|[/\\])\../, // note: . files are ignored + }).on("change", (path, stats) => { + if (path && stats) { + this.refresh(); + } + }); + } + + + private async refresh() { + if (!this.initial) { + console.log(cyan("SherpaJS detected change, rebuilding...")); + } + + this.removeTempDir(); + this.makeTempDir(); + + let { success, logs } = await Compiler.build({ + ...this.options, + output: this.getTempDir() + }, false); + + if (success) { + this.copyFromTempDir(); + if (this.server) { + this.server.kill(); + } + this.server = spawn("node", [ + Path.join(this.options.output, "/.sherpa/index.js"), + this.port ? this.port.toString() : "", + !this.initial ? "--silent-startup" : "" + ]); + this.server.stdout.on("data", (data) => console.log(data.toString().replace("\n", ""))); + this.server.stderr.on("data", (data) => console.log(data.toString().replace("\n", ""))); + this.server.on("close", (data) => { if (data) console.log(data.toString().replace("\n", "") )}); + if (!this.initial) { + console.log(green("SherpaJS Server Rebuilt Successfully.")); + } else { + this.initial = false; + } + } else { + this.removeTempDir(); + Logger.display(logs); + console.log(red("SherpaJS Failed to Build Server.") + " See logs for more information.") + } + } + + + private copyFromTempDir() { + let filepath = Path.join(this.options.output, ".sherpa"); + if (fs.existsSync(filepath)) { + fs.rmSync(filepath, { recursive: true }); + } + this.copyDirectory( + Path.join(this.getTempDir(), ".sherpa"), + Path.join(this.options.output, ".sherpa") + ) + this.removeTempDir(); + } + + + private copyDirectory(source:string, destination:string) { + if (!fs.existsSync(destination)) { + fs.mkdirSync(destination, { recursive: true }); + } + + fs.readdirSync(source).forEach(file => { + let sourcePath = Path.join(source, file); + let destPath = Path.join(destination, file); + + if (fs.statSync(sourcePath).isDirectory()) { + this.copyDirectory(sourcePath, destPath); + } else { + fs.copyFileSync(sourcePath, destPath); + } + }); + } + + + private makeTempDir() { + let filepath = this.getTempDir(); + if (!fs.existsSync(filepath)) { + fs.mkdirSync(filepath); + } + } + + + private removeTempDir() { + let filepath = this.getTempDir(); + if (fs.existsSync(filepath)) { + fs.rmSync(filepath, { recursive: true }); + } + } + + + private getTempDir() { + return Path.join(this.options.output, ".sherpa-dev"); + } + + +} + + +// Teach me your way, Lord, that I may rely on your faithfulness; give me an +// undivided heart, that I may fear your name. +// - Psalm 86:11 diff --git a/src/server-local/index.ts b/src/server-local/index.ts index daf051b..cc0ed8e 100644 --- a/src/server-local/index.ts +++ b/src/server-local/index.ts @@ -29,15 +29,17 @@ type endpoint = { export class ServerLocal { - private port: number; + private readonly port: number; + private readonly silentStartup:boolean; private server: HTTPServer|null; private endpoints:endpoint[]; - constructor(port:number) { - this.endpoints = []; - this.port = port; - this.server = null; + constructor(port:number, silentStartup:boolean=false) { + this.endpoints = []; + this.port = port; + this.server = null; + this.silentStartup = silentStartup; } @@ -48,7 +50,9 @@ export class ServerLocal { this.server = createServer(this.handleRequest.bind(this)); this.server.listen(this.port, () => { - console.log(`${green("SherpaJS Server is started at")} ${cyan(`http://localhost:${bold(this.port)}`)}${green(".")}`); + if (!this.silentStartup) { + console.log(`${green("SherpaJS Server is started at")} ${cyan(`http://localhost:${bold(this.port)}`)}${green(".")}`); + } }); } diff --git a/tests/endpoints/index.test.ts b/tests/endpoints/index.test.ts index bbf167a..c3da47b 100644 --- a/tests/endpoints/index.test.ts +++ b/tests/endpoints/index.test.ts @@ -1,18 +1,735 @@ -import { Suite, BodyType, Method, equals } from "./suite/index.js"; +import { Suite, BodyType, Method, equals, includes } from "./suite/index.js"; // NOTE: Separate from Jest Tests executed by running `npm run test-server {host}` -const suite = new Suite("http://localhost:3000"); +const suite = new Suite(); +//! FIXME - check for args of host, if none provided then... +suite.bench("Local", { + host: "http://localhost:3000", + setup: [ + "%sherpa-cli% build" + ], + start: "%sherpa-cli% start", + teardown: [] +}); + +// suite.bench("Vercel", { +// host: "https://sherpajs-test.vercel.app/" +// }); -suite.test("Example 1", { + +suite.test("Basic Get - GET /regular", { method: Method.GET, path: "/regular" }).expect((response) => { - console.log(response.body); - equals(response.bodyType, BodyType.JSON); + equals(BodyType.JSON, response.bodyType); + equals("/regular", response?.body?.["request"]["url"]); + equals(Method.GET, response?.body?.["request"]["method"]); +}); + + +suite.test("Basic Post - POST /regular", { + method: Method.POST, + path: "/regular" +}).expect((response) => { + equals(BodyType.JSON, response.bodyType); + equals("/regular", response?.body?.["request"]["url"]); + equals(Method.POST, response?.body?.["request"]["method"]); +}); + + +suite.test("Basic Put - PUT /regular", { + method: Method.PUT, + path: "/regular" +}).expect((response) => { + equals(BodyType.JSON, response.bodyType); + equals("/regular", response?.body?.["request"]["url"]); + equals(Method.PUT, response?.body?.["request"]["method"]); +}); + + +suite.test("Basic Patch - PATCH /regular", { + method: Method.PATCH, + path: "/regular" +}).expect((response) => { + equals(BodyType.JSON, response.bodyType); + equals("/regular", response?.body?.["request"]["url"]); + equals(Method.PATCH, response?.body?.["request"]["method"]); +}); + + +suite.test("Basic Delete - DELETE /regular", { + method: Method.DELETE, + path: "/regular" +}).expect((response) => { + equals(BodyType.JSON, response.bodyType); + equals("/regular", response?.body?.["request"]["url"]); + equals(Method.DELETE, response?.body?.["request"]["method"]); +}); + + +suite.test("Not Found - Regular - GET /hello-world", { + method: Method.GET, + path: "/hello-world" +}).expect((response) => { + equals(404, response.status); + equals("Not Found", response.statusText); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("Not Found - 1 Deep Segment - POST /regular/foo", { + method: Method.POST, + path: "/regular/foo" +}).expect((response) => { + equals(404, response.status); + equals("Not Found", response.statusText); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("Not Found - 3 Deep Segment - PUT /regular/response/html/foo", { + method: Method.PUT, + path: "/regular/response/html/foo" +}).expect((response) => { + equals(404, response.status); + equals("Not Found", response.statusText); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("Not Found - Module - DELETE /module/m1/foo", { + method: Method.DELETE, + path: "/module/m1/foo" +}).expect((response) => { + equals(404, response.status); + equals("Not Found", response.statusText); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("Method Not Allowed - POST /regular/response/html/standalone", { + method: Method.POST, + path: "/regular/response/html/standalone" +}).expect((response) => { + equals(405, response.status); + equals("Method Not Allowed", response.statusText); + equals(BodyType.None, response.bodyType); +}); + + +suite.test("HTML Response - Regular - GET /regular/response/html/basic", { + method: Method.GET, + path: "/regular/response/html/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.HTML, response.bodyType); + includes(response?.body as string, ""); + includes(response?.body as string, "Hello"); + includes(response?.body as string, ""); +}); + + +suite.test("HTML Response - Additional Method - POST /regular/response/html/basic", { + method: Method.POST, + path: "/regular/response/html/basic" +}).expect((response) => { + equals(201, response.status); + equals("Created", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals(3, response?.body?.["number"]); + equals(true, response?.body?.["boolean"]); + equals("food", response?.body?.["string"]); + equals(3, response?.body?.["object"]["numbers"][0]); + equals(-4, response?.body?.["object"]["numbers"][1]); +}); + + +suite.test("HTML Response - Additional Method - PUT /regular/response/html/basic", { + method: Method.PUT, + path: "/regular/response/html/basic" +}).expect((response) => { + equals(201, response.status); + equals("Created", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals(3, response?.body?.["number"]); + equals(true, response?.body?.["boolean"]); + equals("food", response?.body?.["string"]); + equals(3, response?.body?.["object"]["numbers"][0]); + equals(-4, response?.body?.["object"]["numbers"][1]); +}); + + +suite.test("HTML Response - Additional Method - PATCH /regular/response/html/basic", { + method: Method.PATCH, + path: "/regular/response/html/basic" +}).expect((response) => { + equals(201, response.status); + equals("Created", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals(3, response?.body?.["number"]); + equals(true, response?.body?.["boolean"]); + equals("food", response?.body?.["string"]); + equals(3, response?.body?.["object"]["numbers"][0]); + equals(-4, response?.body?.["object"]["numbers"][1]); +}); + + +suite.test("HTML Response - Additional Method - DELETE /regular/response/html/basic", { + method: Method.DELETE, + path: "/regular/response/html/basic" +}).expect((response) => { + equals(201, response.status); + equals("Created", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals(3, response?.body?.["number"]); + equals(true, response?.body?.["boolean"]); + equals("food", response?.body?.["string"]); + equals(3, response?.body?.["object"]["numbers"][0]); + equals(-4, response?.body?.["object"]["numbers"][1]); +}); + + +suite.test("HTML Response - Standalone - GET /regular/response/html/standalone", { + method: Method.GET, + path: "/regular/response/html/standalone" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.HTML, response.bodyType); + includes(response?.body as string, ""); + includes(response?.body as string, "Hello"); + includes(response?.body as string, ""); +}); + + +suite.test("JSON Response - GET /regular/response/json/basic", { + method: Method.GET, + path: "/regular/response/json/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("food", response?.body?.["string"]); + equals(3, response?.body?.["number"]); + equals(true, response?.body?.["boolean"]); + equals(3, response?.body?.["object"]["numbers"][0]); + equals(-4, response?.body?.["object"]["numbers"][1]); +}); + + +suite.test("JSON Response - POST /regular/response/json/basic", { + method: Method.POST, + path: "/regular/response/json/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("food", response?.body?.["string"]); + equals(3, response?.body?.["number"]); + equals(true, response?.body?.["boolean"]); + equals(3, response?.body?.["object"]["numbers"][0]); + equals(-4, response?.body?.["object"]["numbers"][1]); +}); + + +suite.test("JSON Response - PUT /regular/response/json/basic", { + method: Method.PUT, + path: "/regular/response/json/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("food", response?.body?.["string"]); + equals(3, response?.body?.["number"]); + equals(true, response?.body?.["boolean"]); + equals(3, response?.body?.["object"]["numbers"][0]); + equals(-4, response?.body?.["object"]["numbers"][1]); +}); + + +suite.test("JSON Response - PATCH /regular/response/json/basic", { + method: Method.PATCH, + path: "/regular/response/json/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("food", response?.body?.["string"]); + equals(3, response?.body?.["number"]); + equals(true, response?.body?.["boolean"]); + equals(3, response?.body?.["object"]["numbers"][0]); + equals(-4, response?.body?.["object"]["numbers"][1]); +}); + + +suite.test("JSON Response - DELETE /regular/response/json/basic", { + method: Method.DELETE, + path: "/regular/response/json/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("food", response?.body?.["string"]); + equals(3, response?.body?.["number"]); + equals(true, response?.body?.["boolean"]); + equals(3, response?.body?.["object"]["numbers"][0]); + equals(-4, response?.body?.["object"]["numbers"][1]); +}); + + +suite.test("JSON Response - Custom Status + Headers - GET /regular/response/json/custom", { + method: Method.GET, + path: "/regular/response/json/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.JSON, response.bodyType); + equals("bar", response?.body?.["foo"]); +}); + + +suite.test("JSON Response - Custom Status + Headers - POST /regular/response/json/custom", { + method: Method.POST, + path: "/regular/response/json/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.JSON, response.bodyType); + equals("bar", response?.body?.["foo"]); +}); + + +suite.test("JSON Response - Custom Status + Headers - PUT /regular/response/json/custom", { + method: Method.PUT, + path: "/regular/response/json/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.JSON, response.bodyType); + equals("bar", response?.body?.["foo"]); +}); + + +suite.test("JSON Response - Custom Status + Headers - PATCH /regular/response/json/custom", { + method: Method.PATCH, + path: "/regular/response/json/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.JSON, response.bodyType); + equals("bar", response?.body?.["foo"]); +}); + + +suite.test("JSON Response - Custom Status + Headers - DELETE /regular/response/json/custom", { + method: Method.DELETE, + path: "/regular/response/json/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.JSON, response.bodyType); + equals("bar", response?.body?.["foo"]); +}); + + +suite.test("None Response - GET /regular/response/none/basic", { + method: Method.GET, + path: "/regular/response/none/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("None Response - POST /regular/response/none/basic", { + method: Method.POST, + path: "/regular/response/none/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("None Response - PUT /regular/response/none/basic", { + method: Method.PUT, + path: "/regular/response/none/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); }); +suite.test("None Response - PATCH /regular/response/none/basic", { + method: Method.PATCH, + path: "/regular/response/none/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("None Response - DELETE /regular/response/none/basic", { + method: Method.DELETE, + path: "/regular/response/none/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("None Response - Custom Status + Headers - GET /regular/response/none/custom", { + method: Method.GET, + path: "/regular/response/none/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("None Response - Custom Status + Headers - POST /regular/response/none/custom", { + method: Method.POST, + path: "/regular/response/none/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("None Response - Custom Status + Headers - PUT /regular/response/none/custom", { + method: Method.PUT, + path: "/regular/response/none/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("None Response - Custom Status + Headers - PATCH /regular/response/none/custom", { + method: Method.PATCH, + path: "/regular/response/none/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("None Response - Custom Status + Headers - DELETE /regular/response/none/custom", { + method: Method.DELETE, + path: "/regular/response/none/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.None, response.bodyType); + equals(undefined, response?.body); +}); + + +suite.test("Text Response - GET /regular/response/text/basic", { + method: Method.GET, + path: "/regular/response/text/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.Text, response.bodyType); + equals("Hello World", response?.body); +}); + + +suite.test("Text Response - POST /regular/response/text/basic", { + method: Method.POST, + path: "/regular/response/text/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.Text, response.bodyType); + equals("Hello World", response?.body); +}); + + +suite.test("Text Response - PUT /regular/response/text/basic", { + method: Method.PUT, + path: "/regular/response/text/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.Text, response.bodyType); + equals("Hello World", response?.body); +}); + + +suite.test("Text Response - PATCH /regular/response/text/basic", { + method: Method.PATCH, + path: "/regular/response/text/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.Text, response.bodyType); + equals("Hello World", response?.body); +}); + + +suite.test("Text Response - DELETE /regular/response/text/basic", { + method: Method.DELETE, + path: "/regular/response/text/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.Text, response.bodyType); + equals("Hello World", response?.body); +}); + + +suite.test("Text Response - Custom Status + Headers - GET /regular/response/text/custom", { + method: Method.GET, + path: "/regular/response/text/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.Text, response.bodyType); + equals("foo-bar", response?.body); +}); + + +suite.test("Text Response - Custom Status + Headers - POST /regular/response/text/custom", { + method: Method.POST, + path: "/regular/response/text/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.Text, response.bodyType); + equals("foo-bar", response?.body); +}); + + +suite.test("Text Response - Custom Status + Headers - PUT /regular/response/text/custom", { + method: Method.PUT, + path: "/regular/response/text/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.Text, response.bodyType); + equals("foo-bar", response?.body); +}); + + +suite.test("Text Response - Custom Status + Headers - PATCH /regular/response/text/custom", { + method: Method.PATCH, + path: "/regular/response/text/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.Text, response.bodyType); + equals("foo-bar", response?.body); +}); + + +suite.test("Text Response - Custom Status + Headers - DELETE /regular/response/text/custom", { + method: Method.DELETE, + path: "/regular/response/text/custom" +}).expect((response) => { + equals(401, response.status); + equals("Unauthorized", response.statusText); + equals("bar", response.headers.get("X-Foo")); + equals(BodyType.Text, response.bodyType); + equals("foo-bar", response?.body); +}); + + +suite.test("Redirect Response - GET /regular/response/redirect/basic", { + method: Method.GET, + path: "/regular/response/redirect/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/regular/response/redirect/success", response?.body?.["request"]["url"]); + equals(Method.GET, response?.body?.["request"]["method"]); +}); + + +suite.test("Redirect Response - POST /regular/response/redirect/basic", { + method: Method.GET, + path: "/regular/response/redirect/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/regular/response/redirect/success", response?.body?.["request"]["url"]); + equals(Method.GET, response?.body?.["request"]["method"]); +}); + + +suite.test("Redirect Response - PUT /regular/response/redirect/basic", { + method: Method.PUT, + path: "/regular/response/redirect/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/regular/response/redirect/success", response?.body?.["request"]["url"]); + equals(Method.PUT, response?.body?.["request"]["method"]); +}); + + +suite.test("Redirect Response - PATCH /regular/response/redirect/basic", { + method: Method.PATCH, + path: "/regular/response/redirect/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/regular/response/redirect/success", response?.body?.["request"]["url"]); + equals(Method.PATCH, response?.body?.["request"]["method"]); +}); + + +suite.test("Redirect Response - DELETE /regular/response/redirect/basic", { + method: Method.DELETE, + path: "/regular/response/redirect/basic" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/regular/response/redirect/success", response?.body?.["request"]["url"]); + equals(Method.DELETE, response?.body?.["request"]["method"]); +}); + + +suite.test("Module Response Get - GET /module/m1", { + method: Method.GET, + path: "/module/m1" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/module/m1", response?.body?.["request"]["url"]); + equals(Method.GET, response?.body?.["request"]["method"]); +}); + + +suite.test("Module Response Post - POST /module/m1", { + method: Method.POST, + path: "/module/m1" +}).expect((response) => { + console.log(response); + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/module/m1", response?.body?.["request"]["url"]); + equals(Method.POST, response?.body?.["request"]["method"]); +}); + + +suite.test("Module Response Put - PUT /module/m1", { + method: Method.PUT, + path: "/module/m1" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/module/m1", response?.body?.["request"]["url"]); + equals(Method.PUT, response?.body?.["request"]["method"]); +}); + + +suite.test("Module Response Patch - PATCH /module/m1", { + method: Method.PATCH, + path: "/module/m1" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/module/m1", response?.body?.["request"]["url"]); + equals(Method.PATCH, response?.body?.["request"]["method"]); +}); + + +suite.test("Module Response Delete - DELETE /module/m1", { + method: Method.DELETE, + path: "/module/m1" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.JSON, response.bodyType); + equals("/module/m1", response?.body?.["request"]["url"]); + equals(Method.DELETE, response?.body?.["request"]["method"]); +}); + + +suite.test("Module HTML Response - Regular - GET /module/m1/test", { + method: Method.GET, + path: "/module/m1/test" +}).expect((response) => { + equals(200, response.status); + equals("OK", response.statusText); + equals(BodyType.HTML, response.bodyType); + includes(response?.body as string, "

Hello, world!

"); +}); + + +suite.test("Module HTML Response - Additional Methods - POST /module/m1/test", { + method: Method.POST, + path: "/module/m1/test" +}).expect((response) => { + equals(201, response.status); + equals("Created", response.statusText); + equals(BodyType.Text, response.bodyType); + includes(response?.body as string, "Hello World"); +}); + + +// TODO: Tests to add +// - modules +// - dynamic path (modules + regular) +// - status codes - every single one - make endpoint that uses dynamic paths +// - status code that doesn't exists +// - query parameters +// - static files +// - error page +// - ENVIRONMENT VARIABLES +// - SHERPA_PLATFORM +// - CONTEXT +// - context modules + + (async () => { suite.run(); })(); diff --git a/tests/endpoints/modules/pass-primary-1/routes/index.ts b/tests/endpoints/modules/pass-primary-1/routes/index.ts index 0d860e2..495fb5a 100644 --- a/tests/endpoints/modules/pass-primary-1/routes/index.ts +++ b/tests/endpoints/modules/pass-primary-1/routes/index.ts @@ -8,3 +8,35 @@ export function GET(request:Request, context:unknown) { }); } + +export function POST(request:Request, context:unknown) { + return Response.JSON({ + request, + context + }); +} + + +export function PUT(request:Request, context:unknown) { + return Response.JSON({ + request, + context + }); +} + + +export function PATCH(request:Request, context:unknown) { + return Response.JSON({ + request, + context + }); +} + + +export function DELETE(request:Request, context:unknown) { + return Response.JSON({ + request, + context + }); +} + diff --git a/tests/endpoints/modules/pass-primary-1/routes/test/index.ts b/tests/endpoints/modules/pass-primary-1/routes/test/index.ts new file mode 100644 index 0000000..5c9094c --- /dev/null +++ b/tests/endpoints/modules/pass-primary-1/routes/test/index.ts @@ -0,0 +1,7 @@ +import { Response } from "../../../../../../index.js"; + + +export function POST() { + return Response.text("Hello World", { status: 201 }); +} + diff --git a/tests/endpoints/server/routes/regular/index.ts b/tests/endpoints/server/routes/regular/index.ts index a062d3c..c556020 100644 --- a/tests/endpoints/server/routes/regular/index.ts +++ b/tests/endpoints/server/routes/regular/index.ts @@ -2,7 +2,7 @@ import { Response } from "../../../../../index.js"; export function GET(request:Request, context:unknown) { - return Response.JSON({ + return Response.JSON({ request: request, context: context, env: process.env diff --git a/tests/endpoints/server/routes/regular/response/html/basic/index.ts b/tests/endpoints/server/routes/regular/response/html/basic/index.ts index 209668e..a97712d 100644 --- a/tests/endpoints/server/routes/regular/response/html/basic/index.ts +++ b/tests/endpoints/server/routes/regular/response/html/basic/index.ts @@ -9,6 +9,50 @@ export function POST() { "object": { "numbers": [3, -4] } + }, { + status: 201 + }); +} + + +export function PUT() { + return Response.JSON({ + "string": "food", + "number": 3, + "boolean": true, + "object": { + "numbers": [3, -4] + } + }, { + status: 201 + }); +} + + +export function PATCH() { + return Response.JSON({ + "string": "food", + "number": 3, + "boolean": true, + "object": { + "numbers": [3, -4] + } + }, { + status: 201 + }); +} + + +export function DELETE() { + return Response.JSON({ + "string": "food", + "number": 3, + "boolean": true, + "object": { + "numbers": [3, -4] + } + }, { + status: 201 }); } diff --git a/tests/endpoints/server/routes/regular/response/json/basic/index.ts b/tests/endpoints/server/routes/regular/response/json/basic/index.ts index 51d5d46..d4885c1 100644 --- a/tests/endpoints/server/routes/regular/response/json/basic/index.ts +++ b/tests/endpoints/server/routes/regular/response/json/basic/index.ts @@ -12,3 +12,51 @@ export function GET() { }); } + +export function POST() { + return Response.JSON({ + "string": "food", + "number": 3, + "boolean": true, + "object": { + "numbers": [3, -4] + } + }); +} + + +export function PUT() { + return Response.JSON({ + "string": "food", + "number": 3, + "boolean": true, + "object": { + "numbers": [3, -4] + } + }); +} + + +export function PATCH() { + return Response.JSON({ + "string": "food", + "number": 3, + "boolean": true, + "object": { + "numbers": [3, -4] + } + }); +} + + +export function DELETE() { + return Response.JSON({ + "string": "food", + "number": 3, + "boolean": true, + "object": { + "numbers": [3, -4] + } + }); +} + diff --git a/tests/endpoints/server/routes/regular/response/json/custom/index.ts b/tests/endpoints/server/routes/regular/response/json/custom/index.ts new file mode 100644 index 0000000..12bb721 --- /dev/null +++ b/tests/endpoints/server/routes/regular/response/json/custom/index.ts @@ -0,0 +1,58 @@ +import { Response, Headers as SherpaHeaders } from "../../../../../../../../index.js"; + + +export function GET() { + return Response.JSON({ + "foo": "bar" + }, { + status: 401, + headers: { + "X-Foo": "bar" + } + }); +} + + +export function POST() { + return Response.JSON({ + "foo": "bar" + }, { + status: 401, + headers: new Headers({ + "X-Foo": "bar" + }) + }); +} + + +export function PUT() { + return Response.JSON({ + "foo": "bar" + }, { + status: 401, + headers: new SherpaHeaders({ + "X-Foo": "bar" + }) + }); +} + + +export function PATCH() { + return Response.JSON({ + "foo": "bar" + }, { + status: 401, + headers: [["X-Foo", "bar"]] + }); +} + + +export function DELETE() { + return Response.JSON({ + "foo": "bar" + }, { + status: 401, + headers: new SherpaHeaders([["X-Foo", "bar"]]) + }); +} + diff --git a/tests/endpoints/server/routes/regular/response/none/basic/index.ts b/tests/endpoints/server/routes/regular/response/none/basic/index.ts index 960bbd6..34524f0 100644 --- a/tests/endpoints/server/routes/regular/response/none/basic/index.ts +++ b/tests/endpoints/server/routes/regular/response/none/basic/index.ts @@ -2,6 +2,26 @@ import { Response } from "../../../../../../../../index.js"; export function GET() { + return new Response(); +} + + +export function POST() { + return Response.new(); +} + + +export function PUT() { + return Response.new(); +} + + +export function PATCH() { + return Response.new(); +} + + +export function DELETE() { return Response.new(); } diff --git a/tests/endpoints/server/routes/regular/response/none/custom/index.ts b/tests/endpoints/server/routes/regular/response/none/custom/index.ts new file mode 100644 index 0000000..272d6a5 --- /dev/null +++ b/tests/endpoints/server/routes/regular/response/none/custom/index.ts @@ -0,0 +1,48 @@ +import { Response, Headers as SherpaHeaders } from "../../../../../../../../index.js"; + + +export function GET() { + return new Response({ + status: 401, + headers: { + "X-Foo": "bar" + } + }); +} + + +export function POST() { + return Response.new({ + status: 401, + headers: new Headers({ + "X-Foo": "bar" + }) + }); +} + + +export function PUT() { + return Response.new({ + status: 401, + headers: new SherpaHeaders({ + "X-Foo": "bar" + }) + }); +} + + +export function PATCH() { + return Response.new({ + status: 401, + headers: [["X-Foo", "bar"]] + }); +} + + +export function DELETE() { + return Response.new({ + status: 401, + headers: new SherpaHeaders([["X-Foo", "bar"]]) + }); +} + diff --git a/tests/endpoints/server/routes/regular/response/redirect/basic/index.ts b/tests/endpoints/server/routes/regular/response/redirect/basic/index.ts index c81218b..fa9490f 100644 --- a/tests/endpoints/server/routes/regular/response/redirect/basic/index.ts +++ b/tests/endpoints/server/routes/regular/response/redirect/basic/index.ts @@ -5,3 +5,23 @@ export function GET() { return Response.redirect("../success"); } + +export function POST() { + return Response.redirect("../success"); +} + + +export function PUT() { + return Response.redirect("../success"); +} + + +export function PATCH() { + return Response.redirect("../success"); +} + + +export function DELETE() { + return Response.redirect("../success"); +} + diff --git a/tests/endpoints/server/routes/regular/response/redirect/success/index.ts b/tests/endpoints/server/routes/regular/response/redirect/success/index.ts index e4f808d..ed8d016 100644 --- a/tests/endpoints/server/routes/regular/response/redirect/success/index.ts +++ b/tests/endpoints/server/routes/regular/response/redirect/success/index.ts @@ -8,3 +8,27 @@ export function GET(request:Request, context:unknown) { }); } + +export function PUT(request:Request, context:unknown) { + return Response.JSON({ + request, + context + }); +} + + +export function PATCH(request:Request, context:unknown) { + return Response.JSON({ + request, + context + }); +} + + +export function DELETE(request:Request, context:unknown) { + return Response.JSON({ + request, + context + }); +} + diff --git a/tests/endpoints/server/routes/regular/response/text/basic/index.ts b/tests/endpoints/server/routes/regular/response/text/basic/index.ts index a7d2f34..1ec0a47 100644 --- a/tests/endpoints/server/routes/regular/response/text/basic/index.ts +++ b/tests/endpoints/server/routes/regular/response/text/basic/index.ts @@ -5,3 +5,24 @@ export function GET() { return Response.text("Hello World"); } + +export function POST() { + return Response.text("Hello World"); +} + + + +export function PUT() { + return Response.text("Hello World"); +} + + +export function PATCH() { + return Response.text("Hello World"); +} + + +export function DELETE() { + return Response.text("Hello World"); +} + diff --git a/tests/endpoints/server/routes/regular/response/text/custom/index.ts b/tests/endpoints/server/routes/regular/response/text/custom/index.ts new file mode 100644 index 0000000..75d18a8 --- /dev/null +++ b/tests/endpoints/server/routes/regular/response/text/custom/index.ts @@ -0,0 +1,48 @@ +import { Response, Headers as SherpaHeaders } from "../../../../../../../../index.js"; + + +export function GET() { + return Response.text("foo-bar", { + status: 401, + headers: { + "X-Foo": "bar" + } + }); +} + + +export function POST() { + return Response.text("foo-bar", { + status: 401, + headers: new Headers({ + "X-Foo": "bar" + }) + }); +} + + +export function PUT() { + return Response.text("foo-bar", { + status: 401, + headers: new SherpaHeaders({ + "X-Foo": "bar" + }) + }); +} + + +export function PATCH() { + return Response.text("foo-bar", { + status: 401, + headers: [["X-Foo", "bar"]] + }); +} + + +export function DELETE() { + return Response.text("foo-bar", { + status: 401, + headers: new SherpaHeaders([["X-Foo", "bar"]]) + }); +} + diff --git a/tests/endpoints/server/sherpa.server.ts b/tests/endpoints/server/sherpa.server.ts index 458491e..9a4e74a 100644 --- a/tests/endpoints/server/sherpa.server.ts +++ b/tests/endpoints/server/sherpa.server.ts @@ -3,7 +3,15 @@ import { SherpaJS } from "../../../index"; export default SherpaJS.New.server({ - context: "foo" + context: { + foo: "bar", + exampleNum: 3, + exampleBool: true, + exampleArray: [1, 2, 3], + deeperNested: { + example: "foo" + } + } }); diff --git a/tests/endpoints/suite/bench.ts b/tests/endpoints/suite/bench.ts new file mode 100644 index 0000000..33b9b88 --- /dev/null +++ b/tests/endpoints/suite/bench.ts @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2024 Sellers Industries, Inc. + * distributed under the MIT License + * + * author: Evan Sellers + * date: Thu May 16 2024 + * file: bench.ts + * project: SherpaJS - Module Microservice Platform + * purpose: Endpoint Test Suite - Test Benches + * + */ + + +import { Path } from "../../../src/compiler/utilities/path/index.js"; +import { ChildProcess, spawn } from "child_process"; + + +export class Bench { + + + private name:string; + private host:string; + private setupCmds:string[]; + private teardownCmds:string[]; + private startCmd:string|undefined; + private server:ChildProcess; + + + constructor(name:string, host:string, start?:string, setup:string[]=[], teardown:string[]=[]) { + this.name = name; + this.host = host; + this.setupCmds = setup; + this.teardownCmds = teardown; + this.startCmd = start; + } + + + public getName():string { + return this.name; + } + + + public getHost():string { + return this.host; + } + + + public async setup() { + for (const command of this.setupCmds) { + await this.execute(command); + } + } + + + public async start() { + if (this.startCmd) { + let args = this.getArguments(this.startCmd); + this.server = spawn(args[0], args.slice(1), { cwd: this.getCWD() }); + await this.wait(1000); + } + } + + + public async teardown() { + if (this.server) { + this.server.kill(); + } + for (const command of this.teardownCmds) { + await this.execute(command); + } + } + + + private async execute(command:string):Promise { + return new Promise((resolve) => { + let args = this.getArguments(command); + let process = spawn(args[0], args.slice(1), { cwd: this.getCWD(), shell: true }); + + process.stderr.on("data", (data) => { + throw new Error(`Failed to execute command "${command}" for "${this.name}" test bench.\n${args.join(" ")}\n${data}`); + }); + + process.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + throw new Error(`Failed to execute command "${command}" for "${this.name}" test bench.\n${args.join(" ")}\ncode: ${code}`); + } + }); + }); + } + + + private getCWD():string { + return Path.join(Path.getDirectory(import.meta.url), "../../../../tests/endpoints/server"); + } + + + private getArguments(command:string):string[] { + return command.replace(/^%sherpa-cli%/, "node ../../../dist/src/cli/index.js").split(" "); + } + + + private async wait(ms:number):Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); + } + + +} + + +// If I give all I possess to the poor and give over my body to hardship that +// I may boast, but do not have love, I gain nothing. +// - 1 Corinthians 13:3 diff --git a/tests/endpoints/suite/helpers.ts b/tests/endpoints/suite/helpers.ts index 26081d3..1ce11c4 100644 --- a/tests/endpoints/suite/helpers.ts +++ b/tests/endpoints/suite/helpers.ts @@ -1,4 +1,15 @@ -// FIXME - Add Headers + Footers +/* + * Copyright (C) 2024 Sellers Industries, Inc. + * distributed under the MIT License + * + * author: Evan Sellers + * date: Thu May 16 2024 + * file: helpers.ts + * project: SherpaJS - Module Microservice Platform + * purpose: Endpoint Test Suite - Verification Helpers + * + */ + export class Fail extends Error { constructor(message:string) { @@ -14,3 +25,13 @@ export function equals(expect:unknown, actual:unknown) { } +export function includes(buffer:string, searchString:string) { + if (!buffer.includes(searchString)) { + throw new Fail(`Expected "${buffer.slice(0, 10)}..." to include "${searchString}"`); + } +} + + +// Therefore we do not lose heart. Though outwardly we are wasting away, yet +// inwardly we are being renewed day by day. +// - 2 Corinthians 4:16 diff --git a/tests/endpoints/suite/index.ts b/tests/endpoints/suite/index.ts index 26fc99c..f0c6ab9 100644 --- a/tests/endpoints/suite/index.ts +++ b/tests/endpoints/suite/index.ts @@ -1,11 +1,25 @@ -// FIXME - Add Headers + Footers +/* + * Copyright (C) 2024 Sellers Industries, Inc. + * distributed under the MIT License + * + * author: Evan Sellers + * date: Thu May 16 2024 + * file: index.ts + * project: SherpaJS - Module Microservice Platform + * purpose: Endpoint Test Suite + * + */ import { Suite } from "./suite.js"; import { Method, BodyType, Body } from "../../../index.js"; -import { equals } from "./helpers.js"; +import { equals, includes } from "./helpers.js"; export type { Body }; -export { Suite, equals, Method, BodyType }; +export { Suite, equals, includes, Method, BodyType }; + +// And he took bread, gave thanks and broke it, and gave it to them, saying, +// "This is my body given for you; do this in remembrance of me." +// - Luke 22:19 diff --git a/tests/endpoints/suite/model.ts b/tests/endpoints/suite/model.ts index 9e3f54a..f6fbc33 100644 --- a/tests/endpoints/suite/model.ts +++ b/tests/endpoints/suite/model.ts @@ -1,15 +1,34 @@ -// FIXME - Add Headers + Footers +/* + * Copyright (C) 2024 Sellers Industries, Inc. + * distributed under the MIT License + * + * author: Evan Sellers + * date: Thu May 16 2024 + * file: model.ts + * project: SherpaJS - Module Microservice Platform + * purpose: Endpoint Test Suite - Models + * + */ + import { Method, Body } from "../../../index.js"; -export type Options = { +export type TestOptions = { method:Method, path:string, body?:Body } +export type BenchOptions = { + host:string; + start?:string; + setup?:string[]; + teardown?:string[]; +} + + export type TestResults = { name:string, success:boolean, @@ -17,3 +36,6 @@ export type TestResults = { stack?:string }; + +// Whoever eats my flesh and drinks my blood remains in me, and I in them. +// - John 6:56 diff --git a/tests/endpoints/suite/suite.ts b/tests/endpoints/suite/suite.ts index 3f4488b..99d093c 100644 --- a/tests/endpoints/suite/suite.ts +++ b/tests/endpoints/suite/suite.ts @@ -1,28 +1,35 @@ -// FIXME - Add Headers + Footers +/* + * Copyright (C) 2024 Sellers Industries, Inc. + * distributed under the MIT License + * + * author: Evan Sellers + * date: Thu May 16 2024 + * file: suite.ts + * project: SherpaJS - Module Microservice Platform + * purpose: Endpoint Test Suite - Suite + * + */ + import { Tester } from "./tester.js"; -import { Options, TestResults } from "./model.js"; +import { BenchOptions, TestOptions, TestResults } from "./model.js"; import { bold, green, red, gray } from "colorette"; +import { Bench } from "./bench.js"; export class Suite { - private host:string; private tests:Tester[] = []; - private results:TestResults[] = []; - + private benches:Bench[] = []; + private results:{ [name:string]:TestResults[] } = {}; - constructor(host:string) { - this.host = host; - } - - test(name:string, options:Options):Tester { + test(name:string, options:TestOptions):Tester { let test = new Tester( name, options.method, - new URL(options.path, this.host).toString(), + options.path, options.body ); this.tests.push(test); @@ -30,39 +37,91 @@ export class Suite { } + bench(name:string, options:BenchOptions):Bench { + let bench = new Bench( + name, + options.host, + options.start, + options.setup || [], + options.teardown || [] + ); + this.benches.push(bench); + return bench; + } + + async run() { - for (let test of this.tests) { - this.results.push(await test.invoke()); + for await (const bench of this.benches) { + if (this.results[bench.getName()]) { + throw new Error(`Test bench name duplicate: "${bench.getName()}"`); + } + this.results[bench.getName()] = []; + + await bench.setup(); + await bench.start(); + for await (const test of this.tests) { + this.results[bench.getName()].push(await test.invoke(bench.getHost())); + } + await bench.teardown(); } + this.display(); } private display() { - let passed = this.results.filter((result) => result.success).length; - let failed = this.results.filter((result) => !result.success).length; - let total = this.results.length; - - for (let test of this.results) { - if (test.success) { - console.log(`${green("√")} ${gray(test.name)}`); - } else { - console.log(`${red("×")} ${gray(test.name)}`); - if (test.message) { - console.log(` ${red(test.message)}\n`); - } - if (test.stack) { - for (let line of test.stack.split("\n")) { - console.log(` ${gray(line)}`); + for (let bench of this.benches) { + let _passed = this.results[bench.getName()].filter((result) => result.success).length; + let _failed = this.results[bench.getName()].filter((result) => !result.success).length; + let _total = this.results[bench.getName()].length; + + console.log("\n============ " + bold(bench.getName()) + " ============="); + + for (let test of this.results[bench.getName()]) { + if (test.success) { + console.log(`${green("√")} ${gray(test.name)}`); + } else { + console.log(`${red("×")} ${gray(test.name)}`); + if (test.message) { + console.log(` ${red(test.message)}\n`); } + if (test.stack) { + for (let line of test.stack.split("\n")) { + console.log(` ${gray(line)}`); + } + } + console.log(""); } - console.log(""); } + + console.log(`${bold("Tests:")} ${green(`${_passed} passed,`)} ${red(`${_failed} failed,`)} total ${_total}`); } - console.log(`${bold("Tests:")} ${green(`${passed} passed,`)} ${red(`${failed} failed,`)} total ${total}`); + let passed = 0; + let failed = 0; + let total = 0; + + console.log("\n============ " + bold("Overview") + " ============="); + for (let bench of this.benches) { + let _passed = this.results[bench.getName()].filter((result) => result.success).length; + let _failed = this.results[bench.getName()].filter((result) => !result.success).length; + let _total = this.results[bench.getName()].length; + passed += _passed; + failed += _failed; + total += _total; + console.log(`${bold(`${bench.getName()}:`)} ${green(`${_passed} passed,`)} ${red(`${_failed} failed,`)} total ${_total}`); + } + console.log(`${bold("All Tests:")} ${green(`${passed} passed,`)} ${red(`${failed} failed,`)} total ${total}`); + if (failed > 0) { + process.exit(1); + } } } + +// Wives, submit yourselves to your own husbands as you do to the Lord. For the +// husband is the head of the wife as Christ is the head of the church, his +// body, of which he is the Savior. +// - Ephesians 5:22-23 diff --git a/tests/endpoints/suite/tester.ts b/tests/endpoints/suite/tester.ts index 7917d44..d7944f0 100644 --- a/tests/endpoints/suite/tester.ts +++ b/tests/endpoints/suite/tester.ts @@ -1,4 +1,15 @@ -// FIXME - Add Headers + Footers +/* + * Copyright (C) 2024 Sellers Industries, Inc. + * distributed under the MIT License + * + * author: Evan Sellers + * date: Thu May 16 2024 + * file: tester.ts + * project: SherpaJS - Module Microservice Platform + * purpose: Endpoint Test Suite - Test + * + */ + import StackTracey from "stacktracey"; import { @@ -32,11 +43,12 @@ export class Tester { } - async invoke():Promise { + async invoke(host:string):Promise { try { - this.handler!(await this.getResponse()); + this.handler!(await this.getResponse(host)); return { name: this.name, success: true }; } catch (error) { + console.log(error); let stack = new StackTracey(error.stack).items.map((e) => e.beforeParse).join("\n"); if (error instanceof Fail) { return { @@ -56,9 +68,9 @@ export class Tester { } - private async getResponse():Promise { + private async getResponse(host:string):Promise { let { body, contentType } = this.getRequestBody(this.body); - return await this.cast(await fetch(this.url, { + return await this.cast(await fetch(new URL(this.url, host).toString(), { method: this.method, body: body, headers: { @@ -107,17 +119,17 @@ export class Tester { body: undefined, bodyType: BodyType.None }; - } else if (contentType == "application/json") { + } else if (contentType.startsWith("application/json")) { return { body: JSON.parse(body as string), bodyType: BodyType.JSON }; - } else if (contentType == "text/html") { + } else if (contentType.startsWith("text/html")) { return { body: body, bodyType: BodyType.HTML }; - } else if (contentType == "text/plain") { + } else if (contentType.startsWith("text/plain")) { return { body: body, bodyType: BodyType.Text @@ -130,3 +142,7 @@ export class Tester { } + +// You, God, are my God, earnestly I seek you; I thirst for you, my whole being +// longs for you, in a dry and parched land where there is no water. +// - Psalm 63:1