Initial commit

This commit is contained in:
2025-06-21 23:09:28 -05:00
commit 5351a94626
23 changed files with 6121 additions and 0 deletions

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
# Ignore dependencies
node_modules
# Ignore build output
dist
# Ignore Git and VSCode configuration
.git
.vscode
# Ignore tests
tests
# Ignore Docker files
Dockerfile
docker-compose.yml
.dockerignore
# Ignore development-specific configuration
.prettierignore
cspell.json
eslint.config.ts
jest.config.js
pnpm-workspace.yaml

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Dependencies
/node_modules
# Build output
/dist
/build
# Log files
*.log
# Local environment variables
.env*
!.env.example
# OS-specific files
.DS_Store
Thumbs.db
# Test reports and coverage
/coverage
/jest-stare/

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
*.*

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": [
"source.fixAll.eslint"
],
"eslint.validate": ["javascript", "typescript"]
}

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# Use the official Node.js image.
FROM node:22-slim
# Create and change to the app directory.
WORKDIR /usr/src/app
# Copy package.json and pnpm-lock.yaml
COPY package.json pnpm-lock.yaml ./
# Install pnpm
RUN npm install -g pnpm
# Install dependencies
RUN pnpm install
# Copy the rest of the application's source code.
COPY . .
# Build the project
RUN pnpm run build
# Expose the port the app runs on
EXPOSE 3000
# Serve the app
CMD [ "pnpm", "start" ]

308
README.md Normal file
View File

@@ -0,0 +1,308 @@
# Minimal Express.js Scaffolding
This project offers a streamlined and minimalistic scaffolding for Express.js applications. Unlike many generators that require extensive cleanup before you can begin, this boilerplate strikes a balance between simplicity and completeness. It provides just the essentials, allowing you to pull the repository, customize the project name, and start building your API immediately.
## Support the Project
If you find this project useful, consider supporting its development:
[![Donate using Liberapay][liberapay-logo]][liberapay-link]
[liberapay-logo]: https://liberapay.com/assets/widgets/donate.svg "Liberapay Logo"
[liberapay-link]: https://liberapay.com/sfiorini/donate
## Installation
To install the project dependencies, run the following command:
```bash
pnpm install
```
## Running the App
To run the application in development mode with live reloading, use:
```bash
pnpm dev
```
To build and run the application for production, use:
```bash
pnpm start
```
## Customization
To rename the project, modify the `"name"` field in the `package.json` file.
```json
{
"name": "your-project-name"
}
```
## Technologies Used
* **Express:** Fast, unopinionated, minimalist web framework for Node.js.
* **TypeScript:** Typed superset of JavaScript that compiles to plain JavaScript.
* **Jest:** A delightful JavaScript Testing Framework with a focus on simplicity.
* **Knip:** A tool to find unused files, dependencies, and exports in your JavaScript and TypeScript projects.
* **ESLint:** A pluggable and configurable linter tool for identifying and reporting on patterns in JavaScript.
* **Docker:** A platform for developing, shipping, and running applications in containers.
* **tsx:** A CLI to seamlessly execute TypeScript and ESM.
* **tsdown:** A tool for building TypeScript projects.
## Scripts
* `pnpm start`: Starts the production server after building the project.
* `pnpm dev`: Runs the application in development mode with live reloading.
* `pnpm build`: Builds the TypeScript project into JavaScript.
* `pnpm test`: Runs the test suite using Jest and generates a coverage report.
* `pnpm test:watch`: Runs the tests in watch mode, re-running them on file changes.
* `pnpm knip`: Finds unused files, dependencies, and exports.
* `pnpm lint`: Lints the codebase using ESLint and automatically fixes issues.
## Building This Scaffold From Scratch
Here is a detailed, step-by-step guide to creating this scaffold from the ground up.
### Requirements
* [Node.js](https://nodejs.org/)
* [pnpm](https://pnpm.io/)
### Step-by-Step Guide
1. **Initialize the project:**
```bash
pnpm init
```
2. **Install Express:**
```bash
pnpm install express
```
3. **Set up TypeScript:**
```bash
pnpm install --save-dev typescript @types/node @types/express
npx tsc --init
```
4. **Create the main application file:**
Create a file at `src/index.ts`.
5. **Install `tsx` for development and `tsdown` for building:**
```bash
pnpm install --save-dev tsx tsdown
```
6. **Configure `tsdown`:**
Create a `tsdown.config.ts` file:
```typescript
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: 'src/index.ts',
format: ["esm"],
target: "ESNext",
platform: "node"
});
```
7. **Configure `tsconfig.json`:**
Modify your `tsconfig.json` to look like this:
```json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "baseUrl": "src",
    "paths": {              
      "@*": ["*"]
    },
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}
```
8. **Set up Jest for testing:**
```bash
pnpm install --save-dev jest ts-jest @types/jest @types/supertest supertest
npx ts-jest config:init
```
This will create a `jest.config.js` file.
9. **Refactor the application for testability:**
* Move all your Express app definition logic from `src/index.ts` into a new `src/server.ts` file.
* The `src/index.ts` file should only contain the `server.listen()` part, which starts the server.
* Create a `tests` folder and mirror the structure of your `src` folder for your test files.
10. **Install Knip to keep the project clean:**
```bash
pnpm add -D knip
```
11. **Set up ESLint for code linting:**
```bash
pnpm add --save-dev eslint jiti @eslint/js typescript-eslint @stylistic/eslint-plugin eslint-plugin-n
```
Create an `eslint.config.ts` file with the following settings (modify as needed):
```typescript
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import stylistic from '@stylistic/eslint-plugin';
import nodePlugin from 'eslint-plugin-n';
export default tseslint.config(
eslint.configs.recommended,
nodePlugin.configs['flat/recommended-script'],
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
ignores: [
'**/node_modules/*',
'**/*.mjs',
'**/*.js',
],
},
{
languageOptions: {
parserOptions: {
project: './tsconfig.json',
warnOnUnsupportedTypeScriptVersion: false,
},
},
},
{
plugins: {
'@stylistic/js': stylistic,
'@stylistic/ts': stylistic,
},
},
{
files: ['**/*.ts'],
},
{
rules: {
'@typescript-eslint/explicit-member-accessibility': 'warn',
'@typescript-eslint/no-misused-promises': 0,
'@typescript-eslint/no-floating-promises': 0,
'@typescript-eslint/no-confusing-void-expression': 0,
'@typescript-eslint/no-unnecessary-condition': 0,
'@typescript-eslint/restrict-template-expressions': [
'error', { allowNumber: true },
],
'@typescript-eslint/restrict-plus-operands': [
'warn', { allowNumberAndString: true },
],
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 0,
'@typescript-eslint/no-unnecessary-type-parameters': 0,
'@stylistic/js/no-extra-semi': 'warn',
'max-len': [
'warn',
{
'code': 80,
},
],
'@stylistic/ts/semi': ['warn', 'always'],
'@stylistic/ts/member-delimiter-style': ['warn', {
'multiline': {
'delimiter': 'comma',
'requireLast': true,
},
'singleline': {
'delimiter': 'comma',
'requireLast': false,
},
'overrides': {
'interface': {
'singleline': {
'delimiter': 'semi',
'requireLast': false,
},
'multiline': {
'delimiter': 'semi',
'requireLast': true,
},
},
},
}],
'@typescript-eslint/no-non-null-assertion': 0,
'@typescript-eslint/no-unused-expressions': 'warn',
'comma-dangle': ['warn', 'always-multiline'],
'no-console': 1,
'no-extra-boolean-cast': 0,
'indent': ['warn', 2],
'quotes': ['warn', 'single'],
'n/no-process-env': 1,
'n/no-missing-import': 0,
'n/no-unpublished-import': 0,
'prefer-const': 'warn',
},
},
);
```
12. **Set up Docker:**
Create a `Dockerfile` file:
```text
# Use the official Node.js image.
FROM node:22-slim
# Create and change to the app directory.
WORKDIR /usr/src/app
# Copy package.json and pnpm-lock.yaml
COPY package.json pnpm-lock.yaml ./
# Install pnpm
RUN npm install -g pnpm
# Install dependencies
RUN pnpm install
# Copy the rest of the application's source code.
COPY . .
# Build the project
RUN pnpm run build
# Expose the port the app runs on
EXPOSE 3000
# Serve the app
CMD [ "pnpm", "start" ]
```
Create a `docker-compose.yml` file:
```yaml
services:
exp-min:
build: .
container_name: "exp-min"
ports:
- "3000:3000"
```

9
cspell.json Normal file
View File

@@ -0,0 +1,9 @@
{
"version": "0.2",
"ignorePaths": ["src","dist", "node_modules", "coverage", "test", "tests", "package.json", "pnpm-lock.yaml", "pnpm-lock.yaml", "pnpm-lock.json", "pnpm-workspace.yaml", "cspell.json", "tsconfig.json", "tsconfig.build.json", "tsconfig.node.json", "tsconfig.test.json"],
"dictionaryDefinitions": [],
"dictionaries": [],
"words": [],
"ignoreWords": [],
"import": []
}

6
docker-compose.yml Normal file
View File

@@ -0,0 +1,6 @@
services:
exp-min:
build: .
container_name: "exp-min"
ports:
- "3000:3000"

94
eslint.config.ts Normal file
View File

@@ -0,0 +1,94 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import stylistic from '@stylistic/eslint-plugin';
import nodePlugin from 'eslint-plugin-n';
export default tseslint.config(
eslint.configs.recommended,
nodePlugin.configs['flat/recommended-script'],
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
ignores: [
'**/node_modules/*',
'**/*.mjs',
'**/*.js',
],
},
{
languageOptions: {
parserOptions: {
project: './tsconfig.json',
warnOnUnsupportedTypeScriptVersion: false,
},
},
},
{
plugins: {
'@stylistic/js': stylistic,
'@stylistic/ts': stylistic,
},
},
{
files: ['**/*.ts'],
},
{
rules: {
'@typescript-eslint/explicit-member-accessibility': 'warn',
'@typescript-eslint/no-misused-promises': 0,
'@typescript-eslint/no-floating-promises': 0,
'@typescript-eslint/no-confusing-void-expression': 0,
'@typescript-eslint/no-unnecessary-condition': 0,
'@typescript-eslint/restrict-template-expressions': [
'error', { allowNumber: true },
],
'@typescript-eslint/restrict-plus-operands': [
'warn', { allowNumberAndString: true },
],
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 0,
'@typescript-eslint/no-unnecessary-type-parameters': 0,
'@stylistic/js/no-extra-semi': 'warn',
'max-len': [
'warn',
{
'code': 80,
},
],
'@stylistic/ts/semi': ['warn', 'always'],
'@stylistic/ts/member-delimiter-style': ['warn', {
'multiline': {
'delimiter': 'comma',
'requireLast': true,
},
'singleline': {
'delimiter': 'comma',
'requireLast': false,
},
'overrides': {
'interface': {
'singleline': {
'delimiter': 'semi',
'requireLast': false,
},
'multiline': {
'delimiter': 'semi',
'requireLast': true,
},
},
},
}],
'@typescript-eslint/no-non-null-assertion': 0,
'@typescript-eslint/no-unused-expressions': 'warn',
'comma-dangle': ['warn', 'always-multiline'],
'no-console': 1,
'no-extra-boolean-cast': 0,
'indent': ['warn', 2],
'quotes': ['warn', 'single'],
'n/no-process-env': 1,
'n/no-missing-import': 0,
'n/no-unpublished-import': 0,
'prefer-const': 'warn',
},
},
);

33
jest.config.js Normal file
View File

@@ -0,0 +1,33 @@
import { createDefaultPreset } from "ts-jest";
const tsJestTransformCfg = createDefaultPreset().transform;
/** @type {import("jest").Config} **/
export const testEnvironment = "node";
export const transform = {
...tsJestTransformCfg,
'^.+\\.jsx?$': [
'ts-jest',
{
tsconfig: {
// Overrides the tsconfig.json module setting to allow for CommonJS modules in tests
allowJs: true,
},
},
]
};
export const moduleNameMapper = {
// Handle module aliases
'^@routers/(.*)$': '<rootDir>/src/routers/$1',
}
export const coverageThreshold = {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: -10,
},
};
export const coverageReporters = ['text'];

42
package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "exp-min",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node ./dist/index.mjs",
"dev": "tsx --watch ./src/index.ts",
"build": "tsdown",
"prestart": "pnpm run build",
"test": "jest --coverage",
"test:watch": "jest --coverage --watchAll",
"knip": "knip",
"lint": "eslint --fix ."
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.12.1",
"dependencies": {
"consola": "^3.4.2",
"express": "^5.1.0"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@stylistic/eslint-plugin": "^4.4.1",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.3",
"@types/supertest": "^6.0.3",
"eslint": "^9.29.0",
"eslint-plugin-n": "^17.20.0",
"jest": "^30.0.2",
"knip": "^5.61.2",
"supertest": "^7.1.1",
"ts-jest": "^29.4.0",
"tsdown": "^0.12.8",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.1"
}
}

5428
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- esbuild
- unrs-resolver

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

13
src/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { consola } from 'consola';
import server from './server';
const port = 3000;
// Start the server
const httpServer = server.listen(port, () => {
consola.info(`Example app listening on port ${port}`);
});
httpServer.on('error', (err: Error) => {
consola.error(err.message);
});

View File

@@ -0,0 +1,14 @@
import { Router, Request, Response } from 'express';
const firstRouter: Router = Router();
firstRouter.get('/', (req: Request, res: Response) => {
res.send('Hello from First Router root route');
});
firstRouter.get('/somepath/:param', (req: Request, res: Response) => {
res.send(
'Hello from First Router somePath route with param: ' + req.params.param,
);
});
export default firstRouter;

View File

@@ -0,0 +1,14 @@
import { Router, Request, Response } from 'express';
const secondRouter: Router = Router();
secondRouter.get('/', (req: Request, res: Response) => {
res.send('Hello from Second Router root route');
});
secondRouter.get('/somepath/:param', (req: Request, res: Response) => {
res.send(
'Hello from Second Router somePath route with param: ' + req.params.param,
);
});
export default secondRouter;

15
src/server.ts Normal file
View File

@@ -0,0 +1,15 @@
import express, { Express, Request, Response } from 'express';
import firstRouter from '@routers/firstRouter';
import secondRouter from '@routers/secondRouter';
const app: Express = express();
app.use(express.static('public'));
app.use('/firstroute', firstRouter);
app.use('/secondroute', secondRouter);
app.get('/', (req: Request, res: Response) => {
res.send('Application is running!');
});
export default app;

View File

@@ -0,0 +1,15 @@
import request from 'supertest';
import server from '../../src/server';
it('should respond to /firstroute', async () => {
const res = await request(server).get('/firstroute');
expect(res.statusCode).toEqual(200);
});
it('should respond to /firstroute/somepath/:para/', async () => {
const res = await request(server).get('/firstroute/somepath/myparam');
expect(res.statusCode).toEqual(200);
expect(res.text).toEqual(
'Hello from First Router somePath route with param: myparam',
);
});

View File

@@ -0,0 +1,15 @@
import request from 'supertest';
import server from '../../src/server';
it('should respond to /secondroute', async () => {
const res = await request(server).get('/secondroute');
expect(res.statusCode).toEqual(200);
});
it('should respond to /secondroute/somepath/:para/', async () => {
const res = await request(server).get('/secondroute/somepath/myparam');
expect(res.statusCode).toEqual(200);
expect(res.text).toEqual(
'Hello from Second Router somePath route with param: myparam',
);
});

10
tests/server.test.js Normal file
View File

@@ -0,0 +1,10 @@
import request from 'supertest'
import app from '../src/server'
describe('Express App', () => {
it('should respond with "Application is running!" at the root', async () => {
const res = await request(app).get('/')
expect(res.statusCode).toEqual(200)
expect(res.text).toBe('Application is running!')
})
})

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext", /* Specify what module code is generated. */
"moduleResolution": "Bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": "src", /* Specify the base directory to resolve non-relative module names. */
"paths": {
"@*": ["*"]
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
"strict": true, /* Enable all strict type-checking options. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

8
tsdown.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: 'src/index.ts',
format: ['esm'],
target: 'ESNext',
platform: 'node',
});