Skip to content

Commit

Permalink
Merge pull request #17 from RobertoMachorro/express5-upgrade
Browse files Browse the repository at this point in the history
Express5 Upgrade
  • Loading branch information
RobertoMachorro authored Sep 13, 2024
2 parents 17c9e74 + d565fbb commit 6605e26
Show file tree
Hide file tree
Showing 6 changed files with 1,369 additions and 754 deletions.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2020 Roberto Machorro
Copyright (c) 2020-2024 Roberto Machorro

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
86 changes: 69 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ A simple framework for MVC web applications and RESTful APIs.
## Features

* Docker container ready
* Express based HTTP handling and routes
* Express 5 based HTTP handling and routes
* Familiar MVC folder structure and URL paths (controller per file, public folder for static content, etc)
* Optional shared session management using Redis (falls-back to memorystore)
* Optional shared session management using Redis
* CORS support (HTTP OPTIONS)
* Flexible logging formatting using Morgan
* Flexible logging formatting using Morgan (defaults to Apache style)
* Out of the box support for EJS templates in Views, and partials
* Use any Node based data access module for storage
* Custom error handling
Expand All @@ -27,6 +27,7 @@ A simple framework for MVC web applications and RESTful APIs.
mkdir test-app
cd test-app
npm init
npm install express@5 --save
npm install mvc-webapp --save
mkdir -p application/models
mkdir -p application/controllers
Expand All @@ -48,40 +49,91 @@ webapp.run({
applicationRoot: process.env.PWD,
listenPort: process.env.PORT || '3000',
sessionRedisUrl: process.env.REDISCLOUD_URL || undefined,
sessionSecret: process.env.SESSION_SECRET || 'NOT_SO_SECRET',
sessionSecret: process.env.SESSION_SECRET || undefined,
redirectSecure: true,
allowCORS: false,
viewEngine: 'ejs', // Optional: Pug, Handlebars, EJS, etc
loggerFormat: 'common', // Morgan formats
trustProxy: true,
notfoundMiddleware: (request, response, next) => {
response.status(404).json({
code: 404,
message: 'Sorry! File Not Found'
})
},
errorMiddleware: (error, request, response, _) => {
response.json({
status: error.status,
message: error.message,
stack: request.app.get('env') === 'development' ? error.stack : '',
response.status(500).json({
code: 500,
message: error
})
},
})
```

This is the minimal amount of options you can give, sensible and secure default values are given for everything else:

```javascript
#!/usr/bin/env node

const webapp = require('mvc-webapp')

webapp.run({
// Mandatory
applicationRoot: process.env.PWD,
listenPort: process.env.PORT || '3000',

// Optional Redis Session Management
// sessionRedisUrl: undefined,
// sessionSecret: undefined,

// Optional Security Related
// redirectSecure: false,
// allowCORS: false,
// trustProxy: false,

// Optional Framework
// viewEngine: undefined, // Pug, Handlebars, EJS, etc
// loggerFormat: 'common', // Morgan formats

// Optional Error Handling
// notfoundMiddleware: undefined,
// errorMiddleware: undefined,
})
```

The error handling can be customized to return plain JSON, HTTP codes or an EJS rendered page, your choice.

3. Add an initial controller, this will be automatically mapped to a path (login.js becomes /login/<method>/<params>):

```javascript
const express = require('express')
exports.actions = controller => {
controller.get('/', (request, response, _) => {
response.json({
status: 'Sample status...',
data: null,
})
})

controller.get('/async', async (request, response) => {
const hi = await Promise.resolve('Hi!')
response.send(hi)
})

const router = new express.Router()
controller.get('/fail', async (request, response) => {
await Promise.reject('REJECTED!')
})

router.get('/', (request, response, _) => {
response.json({
status: 'OK',
data: null,
controller.get('/denied', async (request, response) => {
response.status(403).send('Not here')
})
})

module.exports = router
return controller
}
```

This should be familiar to any Express user. A special exception is made for the index.js controller file, this is mapped to the root / folder. Additionally, any routes inside that controller, get appended as a method.

In order to render the EJS view, invoke the view (file)name in the res.render call:
In order to render a view, invoke the view (file)name in the res.render call:

```javascript
response.render('index', {
Expand Down
105 changes: 55 additions & 50 deletions core.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,71 @@ const process = require('process')
const debug = require('debug')('mvc-webapp:core')
const express = require('express')
const session = require('express-session')
const redis = require('redis')
const Redis = require('redis')
const logger = require('morgan')
const createError = require('http-errors')

exports.create = function (options) {
debug('application root', options.applicationRoot)
const RedisStore = require('connect-redis').default

const createApp = function (options) {
const app = express()
app.set('port', options.listenPort)

// View engine setup
app.set('views', path.join(options.applicationRoot, 'application/views'))
app.set('view engine', 'ejs')
if (options.viewEngine) {
const viewsPath = path.join(options.applicationRoot, 'application/views')
app.set('views', viewsPath)
app.set('view engine', options.viewEngine)
}

return app
}

exports.create = async function (options) {
debug('Application Root:', options.applicationRoot)

const app = createApp(options)
app.set('port', options.listenPort)

// Engine options
app.use(logger('dev'))
app.use(logger(options.loggerFormat || 'common'))
app.use(express.json())
app.use(express.urlencoded({extended: false}))
app.use(express.static(path.join(options.applicationRoot, 'application/public')))

// Trust Proxy
if (options.trustProxy) {
app.enable('trust proxy')
debug('Trusting Proxy.')
}

// Session Storage
if (options.sessionRedisUrl) {
const RedisStore = require('connect-redis')(session)
const client = redis.createClient({
url: options.sessionRedisUrl,
const redisClient = await Redis.createClient({
url: process.env.REDIS_URL
})
.on('error', error => debug('Redis Fail', error))
.connect()
const redisStore = new RedisStore({
client: redisClient,
prefix: 'session:'
})
app.enable('trust proxy')
debug('Setting up for Redis session management.')
app.use(session({
secret: options.sessionSecret,
resave: false,
saveUninitialized: false,
store: new RedisStore({client}),
}))
} else {
const MemoryStore = require('memorystore')(session)
debug('Setting up for Memory session management.')
app.use(session({
secret: options.sessionSecret,
resave: false,
saveUninitialized: false,
store: new MemoryStore({
ttl: 600_000, // TTL with 10m
checkPeriod: 3_600_000, // Prune expired entries every 1h
}),
store: redisStore,
}))
}

// Check for Session Storage
app.use((request, response, next) => {
if (!request.session) {
return next(createError(500, 'No session handler found'))
}

next()
})

// Ensure secure connection in production
app.use((request, response, next) => {
if (options.redirectSecure && !request.secure && request.get('x-forwarded-proto') !== 'https' && process.env.NODE_ENV === 'production') {
return response.redirect('https://' + request.get('host') + request.url)
}
if (options.redirectSecure) {
app.use((request, response, next) => {
if (options.redirectSecure && !request.secure && request.get('x-forwarded-proto') !== 'https' && process.env.NODE_ENV === 'production') {
return response.redirect('https://' + request.get('host') + request.url)
}

next()
})
next()
})
}

// Cross Origin Resource Sharing
if (options.allowCORS) {
Expand All @@ -87,13 +87,21 @@ exports.create = function (options) {
const filepath = path.parse(file)
const controller = require(path.join(controllersPath, filepath.name))
const sitepath = '/' + ((filepath.name === 'index') ? '' : filepath.name)
const subapp = createApp(options)
debug('Loading controller on path:', sitepath)
app.use(sitepath, controller)
app.use(sitepath, controller.actions(subapp))
}

// Catch 404 and forward to error handler
// File Not Found
app.use((request, response, next) => {
next(createError(404, 'Not Found'))
if (options.notfoundMiddleware) {
options.notfoundMiddleware(request, response, next)
} else {
response.status(404).json({
code: 404,
message: 'File Not Found'
})
}
})

// Error handler
Expand All @@ -102,15 +110,12 @@ exports.create = function (options) {
return next(error)
}

response.status(error.status || 500)
if (options.errorMiddleware) {
options.errorMiddleware(error, request, response, next)
} else {
response.json({
title: 'Default Error Handler',
status: error.status,
message: error.message,
stack: request.app.get('env') === 'development' ? error.stack : '',
response.status(500).json({
code: 500,
message: error
})
}
})
Expand Down
42 changes: 7 additions & 35 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
const http = require('http')
const debug = require('debug')('mvc-webapp:server')

const core = require('./core.js')

exports.run = function (options) {
exports.run = async function (options) {
validateOptions(options)

const app = core.create(options)
const server = http.createServer(app)

server.on('error', onError)
server.on('listening', onListening)
const app = await core.create(options)

debug('port', options.listenPort)
return server.listen(options.listenPort)
app.listen(options.listenPort)

return app
}

exports.test = function (options) {
Expand All @@ -34,32 +30,8 @@ function validateOptions(options) {
throw new TypeError('Listening port must be defined.')
}

if (typeof options.sessionSecret === 'undefined') {
if (typeof options.sessionSecret === 'undefined'
&& typeof options.sessionRedisUrl !== 'undefined') {
throw new TypeError('A session secret salt must be defined.')
}

if (typeof options.redirectSecure === 'undefined') {
throw new TypeError('Redirect to secure protocol must be defined.')
}
}

function onError(error) {
if (error.syscall !== 'listen') {
throw error
}

const bind = error.address + ':' + error.port

switch (error.code) {
case 'EACCES':
throw new Error(bind + ' requires elevated privileges')
case 'EADDRINUSE':
throw new Error(bind + ' is already in use')
default:
throw error
}
}

function onListening() {
debug('is', this.listening ? 'online' : 'offline')
}
Loading

0 comments on commit 6605e26

Please sign in to comment.