Skip to content
Snippets Groups Projects
Commit 9171be33 authored by Louis's avatar Louis :fire:
Browse files

Initial commit

parents
No related branches found
No related tags found
No related merge requests found
Showing with 801 additions and 0 deletions
.idea/
.vscode/
node_modules/
.env
.circleci/
\ No newline at end of file
root = true
[*]
end_of_line = lf
insert_final_new_line = true
charset = utf-8
[*.{rs, js,json,mustache,hbs,handlebars}]
indent_style = tab
[*.{yml,yaml}]
indent_style = space
indent_size = 2
\ No newline at end of file
APP_NAME=
PORT=
\ No newline at end of file
module.exports = {
"env": {
"commonjs": true,
"es6": true,
"node": true
},
"extends": "@bark",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018
},
"rules": {
"indent": 1,
"no-return-await": 0, // Common koa middleware pattern
}
};
\ No newline at end of file
.idea/
node_modules
*.log
.env
*.iml
**/dist/
\ No newline at end of file
FROM mhart/alpine-node:12
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production --unsafe-perm
FROM mhart/alpine-node:slim-12
LABEL maintainer="Louis Capitanchik <contact@louiscap.co>"
LABEL description="A basic Node.js web app"
RUN apk add --no-cache bash
WORKDIR /app
COPY --from=0 /app/node_modules ./node_modules
COPY server.js .
COPY src ./src
# Add more COPY lines here as resources are added
ENV NODE_PATH=/app/src
ARG PORT=8000
ENV PORT ${PORT}
EXPOSE ${PORT}
CMD ["node", "server"]
\ No newline at end of file
README.md 0 → 100644
# node-koa-starter
A full-fat, go get 'em starter that I use to build enterprise web applications.
## Contents
* [Rationale](#rationale)
* [Included Batteries](#included-batteries)
* [Docker](#docker)
* [Code Conventions](#code-conventions)
* [Dependency Injection](#dependency-injection)
* [Service Facades](#service-facades)
* [Spaces vs Tabs](#spaces-vs-tabs)
## Rationale
I build a lot of large web applications; sometimes they're monoliths, sometimes they're
a series of microservices. This starter represents the collected wisdom that I've gained over
the years on how to structure a Node.js web app so that it's usable both on the micro and macro
scales without too much churn.
The included libraries have been chosen for their utility in any situation; there isn't any point
in including an email facade when not every project would need to send emails (microservices), or
including a JSON:API response transformer (not every project would even speak REST!).
Express is an outdated framework. It has a lot of good things going for it, and the community support
is astounding, but koa is a lot tighter and provides a nicer base on which to build bespoke functionality.
The one controversial point that I will concede is the usage of `NODE_PATH` to get absolute module imports.
This may not be to everyone's taste, but forms a core basis of some of the dependency injection techniques
used.
## Included Batteries
- `koa` - The HTTP request handling framework that forms the core of this starter. Modern, slim, BYOB attitude with enough community modules to cover the bases.
- `koa-static` - Not mounted be default, but included because many projects will need to serve static assets at some point
- `koa-router` - The defacto router for Koa, supports middleware, sub-routers and name based url-generation
- `koa-bodyparser` - Parses request bodies for forms and json uploads
- `koa-logger` - Basic request logging & timing
- `koa-cors` - Handle cors `OPTIONS` requests, very customisable
- `dotenv` - Environment file based configuration, feeds into the `env`/`config` multi-layered app configuration strategy
- `moment` - Almost all apps handle dates or times (even for logging purposes)
- `moment-range` - Configured by default to allow seamless date-range manipulation
- `fs-jetpack` - Easier file system handling. A global instance is added to the `bootstrap` to provide a default fs rooted to the app directory
- `uuid` - Standard for adding IDs to things
- `lodash` - Utilities for most apps
- `core/errors` - HTTP errors that, when not caught, will set the response status and contents appropriately
## Docker
The included `Dockerfile` works by default for this setup, but will need tweaking as new resources are added to the project.
It utilises multi-stage builds to build the app in an alpine container, and then copies the resources to a slim container
for a smaller output size. Additional `COPY` statements and `ENV` configs will need to be added as required.
## Code Conventions
Application entry points are located at the root level of the project. For example, `server.js` is a server entry point. When building
additional components that make use of a shared code base (e.g. a queue worker), they would be located at the root level as well
(e.g. a `worker.js` file).
All external configuration should be stored in a file located under `src/config`. These files should either be a JSON file or a JS
file that exports an object. Using the `config` helper from the `bootstrap` will access values from these files, utilising the node
module cache to prevent multiple potentially expensive lookups (e.g. syscalls to the environment). If the config object has a `driver`
key, the `config` helper will instead use the property with a name that matches the value of `driver` when retrieving values.
e.g.
```javascript
const { env } = require('bootstrap')
module.exports = {
driver: env('DATABASE_DRIVER', 'postgres'),
postgres: {
// ... postgres config ...
},
mysql: {
// ... mysql config ...
},
}
```
External services fall in to two categories; configured SDKs and service facades. Configured SDKs should export a configured version
of their parent-library, or an SDK factory, from `src/vendor/{library-name}` such that another module can `require('vendor/mylib')` to
use that library. An example of this would be using `pg`; `src/vendor/pg` would export a configured `pg` instance, typically using
`config` or `env` from the `bootstrap` to pull in the configuration.
Service facades are detailed more below, but essentially take the form of a configured mapping of a specific vendor service to a
generic service interface. For example, the `cache` service included with this starter has the `null` and `memory` implementations,
which are switched between by the `CACHE_DRIVER` environment variable. A `redis` implementation could be added by mapping an `ioredis`
configured SDK (detailed above) to a `redis` service that implements the `cache` facade.
Dependency injection is detailed more below, but functions by implementing the `ContextualModule` class and then adding that class
to the `ServiceProvider` module. Each `ContextualModule` declares it's exported name, and will be made available to any entity that
has access to the koa `ctx` object.
## Dependency Injection
- TBW
## Service Facades
- TBW
## Spaces Vs Tabs
The project is configured to expect tab-based indentation for the following reasons:
- Accessibility: I am dyslexic and I can configure the width of a tab to render as a comfortable amount of space to make understanding code easier
- Consistency: Every line has X number of tabs based on bracket nesting. There are no spaces for further alignment and the linting rules prevent mixed indentation so it's not possible to succumb to the temptation of vanity indentation
- Preference: I simply prefer tabs. There are arguments for either, and I don't care too deeply about it. Tabs give me the things I need, so I use them.
\ No newline at end of file
This diff is collapsed.
{
"name": "",
"version": "0.1.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "NODE_PATH=src jest",
"start": "NODE_PATH=src node server",
"watch": "NODE_PATH=src nodemon server",
"lint": "NODE_PATH=src eslint server.js src",
"lint:errors": "NODE_PATH=src eslint --quiet server.js src",
"lint:fix": "NODE_PATH=src eslint --fix server.js src"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^8.1.0",
"fs-jetpack": "^2.2.2",
"koa": "^2.8.1",
"koa-bodyparser": "^4.2.1",
"koa-cors": "0.0.16",
"koa-logger": "^3.2.1",
"koa-router": "^7.4.0",
"koa-static": "^5.0.0",
"lodash": "^4.17.15",
"moment": "^2.24.0",
"moment-range": "^4.0.2",
"scrypt-kdf": "^2.0.1",
"uuid": "^3.3.2"
},
"devDependencies": {
"@bark/eslint-config": "^0.1.2",
"eslint": "^6.2.0",
"jest": "^24.9.0",
"nodemon": "^1.19.1",
"supertest": "^4.0.2"
}
}
/* eslint-disable require-await */
const { config } = require('bootstrap')
async function main() {
const app = require('app')
const conf = config('app')
app.listen(conf.port)
console.log(`${ conf.name } listening on http://localhost:${ conf.port }`)
}
main()
.catch(e => {
console.error(e)
process.exit(e.exitCode || 1)
})
const Koa = require('koa')
const logger = require('koa-logger')
const body = require('koa-bodyparser')
const routers = require('http/routes')
const app = new Koa()
app.use(logger())
app.use(body())
Object.values(routers).forEach(router => {
app.use(router.routes())
app.use(router.allowedMethods())
})
// Add more conditional app instantiation here
module.exports = app
/** @module bootstrap */
require('dotenv').config()
const { get } = require('lodash')
const pathUtil = require('path')
const jetpack = require('fs-jetpack')
const moment = require('moment')
const momentRange = require('moment-range')
momentRange.extendMoment(moment)
exports.unset = Symbol('unset')
/**
* Pull a value from the environment, with a fallback if required. Specifying an error
* object as the fallback will throw the error if the given name is not present in
* the environment. This is useful for situations where the value _absolutely must_
* exist, where it is not possible to recover otherwise
*
* @param {string} name The name of the environment variable to search for. Typically a
* screaming snake case string such as `PORT` or `DATABASE_URL`
*
* @param {*} fallback The value to return if the given name is not found. When the
* fallback value is an error object, it will be thrown instead of being returned
*
* @returns {string|*} Either the environment variable specified by `name` (which will
* always be a string), or the fallback value
*/
exports.env = function getEnvVariable(name, fallback = null) {
if (name in process.env) {
return process.env[name]
}
if (fallback instanceof Error) {
throw fallback
}
if (typeof fallback === 'function') {
return fallback()
}
return fallback
}
/**
* Pull a driver-aware value from a path within a config file.
*
* "driver-aware" refers to multi-configuration files where there is a driver value present to
* specify which sub-configuration to use
*
* The path is a dot-separated object path, where the first part is the file to load from, and
* the remaining parts are the path either within the driver config or within the root if it is
* not a driver config
*
* Specifying just the name of the config file with no path will return either the entire config
* file, or the relevant driver config if a driver value is present
*
* As an example, take the following file as `config/logging`:
*
* ```js
* module.exports = {
* driver: 'loggly',
*
* loggly: {
* key: 'blah',
* url: 'foo',
* }
* }
* ```
*
* Calling `config('logging.key')` would return `'blah'`, while calling `config('logging')` would return the entire
* object keyed by 'loggly'
*
* @param {string} path The dot-separated path that specifies "{file name}.{path}.{inside}.{config}"
* @param {*} fallback The default value to use if the config value couldn't be found
* @returns {*} The value found at the specified path, or the fallback value.
*/
exports.config = function getConfigValue(path, fallback = null) {
const [file, ...innerPath] = path.split('.')
let conf = null
try {
conf = require(`./config/${ file }`)
} catch (e) {
console.error(e)
// require('services').log.error(e)
return fallback
}
if (conf.hasOwnProperty('driver')) {
conf = conf[conf.driver]
}
if (innerPath.length < 1) {
return conf
}
return get(conf, innerPath, fallback)
}
exports.route = function(type, name, params) {
const routers = require('http/routes')
if (!routers.hasOwnProperty(type)) {
return null
}
const router = routers[type]
return router.url(name, params)
}
exports.routees = {
api(name, params) { return exports.route('api', name, params) },
}
exports.fs = jetpack.cwd(pathUtil.join(__dirname, '..'))
const { env } = require('bootstrap')
module.exports = {
name: env('APP_NAME', 'Untitled 1'),
port: Number(env('PORT')) || 8000,
key: env('APP_KEY', () => require('core/utils/crypto').insecureHexString(32)),
dev: env('NODE_ENV', 'err') === 'development',
}
const { env } = require('bootstrap')
module.exports = {
driver: env('CACHE_DRIVER', 'memory'),
memory: {
prefix: 'appcache',
},
}
/** @module core/errors/HttpError */
const { config } = require('bootstrap')
const { insecureHexString } = require('core/utils/crypto')
/**
* @typedef {Object} HttpErrorPayload
* @static
*
* @property {string} [id] A value that identifies this specific error instance.
* defaults to a random 32 character string of hex digits
*
* @property {string} [status = "500"] The HTTP status that corresponds to this error
*
* @property {string} [code = "X-000"] An application-unique identifier code that corresponds
* to the error _type_ (not the error instance like `id`)
*
* @property {string} [title = "An Error Occurred"] A short title that can give a brief
* overview of what has gone wrong. Try to think of this value being shown in a list of other
* errors for a guide on how it should come across - will glancing at this title give the reader
* the information they need?
*
* @property {string} [description = "Something went wrong, but no reason was given"] A more
* in-depth message that can go into details (if possible) about what went wrong, any possible
* remediation steps and possibly useful links or information that could help resolve this
* error
*/
/**
* An error that can format itself as an error response payload
* @extends Error
* @see {@link https://nodejs.org/api/errors.html#errors_class_error}
*/
class HttpError extends Error {
/**
* Create a new HttpError
*
* @param {module:core/errors/HttpError.HttpErrorPayload} params The error descriptor; should typically
* contain at least a `description` property, but most uses will override more of the
* defaults
*/
constructor(params) {
const {
id = insecureHexString(32),
status = '500',
code = 'X-000',
title = 'An Error Occurred',
description = 'Something went wrong, but no reason was given',
} = params
super(title)
this.id = id
this.status = status
this.code = code
this.title = title
this.description = description
}
toJSON() {
return {
id: this.id,
status: this.status,
code: this.code,
title: this.title,
detail: config('app.dev') ?
`${ this.description }\n\n${ this.stack }` :
this.description,
}
}
toString() {
return JSON.stringify(this.toJSON(), null, 2)
}
}
module.exports = HttpError
const HttpError = require('./HttpError')
class NotFoundError extends HttpError {
constructor(instanceCode, modelName = 'The requested resource') {
super({
status: '404',
code: `H-404-${ instanceCode }`,
title: 'Not Found',
description: `${ modelName } could not be found`,
})
}
}
module.exports = NotFoundError
module.exports = class ContextualModule {
static withContext(ctx) { return new this(ctx) }
static withoutContext() { return new this({}) }
static getServiceName() { throw new Error(`getServiceName Not Implemented for ${ this.name }`) }
constructor(context = null) {
if (context == null) {
throw new TypeError('Can not create contextual module without context')
}
Object.defineProperty(this, 'ctx', {
value: context,
writable: false,
enumerable: false,
})
}
}
const services = [
require('domain/users/UserService'),
]
module.exports = class ServiceProvider {
static async attach(ctx, next) {
ctx.services = {}
services.forEach(service => {
const name = service.getServiceName()
if (ctx.services.hasOwnProperty(name)) {
console.warn(`Multiple services found for name: ${ name }. Using implementation provided by ${ service.name }`)
}
ctx.services[name] = service.withContext(ctx)
})
const serviceList = Object.values(ctx.services)
for (const serviceInstance of serviceList) {
if (typeof serviceInstance.init === 'function') {
await serviceInstance.init(ctx.services)
}
}
return next()
}
static /* async */ detached() {
const newContext = {}
return ServiceProvider.attach(newContext, () => newContext)
}
}
/** @module core/utils/crypto */
const crypto = require('crypto')
const scrypt = require('scrypt-kdf')
const { config } = require('bootstrap')
exports.secureBuffer = function generateSecureBuffer(bytes) {
return new Promise((resolve, reject) => {
crypto.randomBytes(bytes, (err, buffer) => {
if (err) {
reject(err)
} else {
resolve(buffer)
}
})
})
}
/**
* Generate a cryptographically secure hexadecimal string with the specified number of
* bytes. The generated value is secure enough to be used for short lived applications
* such as request nonces and reset tokens, but should not be used for higher security
* requirements such as passwords
*
* @param {number} bytes The length of the desired string in bytes. The string length
* will be double the number of bytes specified, as each byte will be mapped to two
* hexadecimal characters.
*
* For example, to get a nonce of length 32, you should request a hex string comprising
* 16 bytes; `crypto.secureHexString(16) // Promise -> output.length === 32`
*
* @returns {Promise<string>} A hexadecimal string with length `bytes * 2`. Created by
* converting a buffer of randomly generated bytes into a hex string
*/
exports.secureHexString = function generateSecureHexString(bytes) {
return exports.secureBuffer(bytes).then(b => b.toString('hex'))
}
const hex = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f']
/**
* Generate a hexadecimal string of the given length in a synchronous and insecure manner.
* The return value of this function should not be used in a secure context, but can be
* used to easily generate a unique identifier in scenarios where security is not important
* and synchronous execution is a requirement
*
* @param {number} length The number of characters to generate. Unlike
* the {@link module:core/utils/crypto.secureHexString|secureHexString} function, the length
* specified is the exact length of the output string.
*
* @returns {string} A string of hexadecimal characters. Not guaranteed to be secure or insecure
*/
exports.insecureHexString = function generateInsecureHexStringSync(length) {
const buffer = []
for (let i = 0; i < length; i += 1) {
const char = hex[Math.floor(Math.random() * hex.length)]
buffer.push(char)
}
return buffer.join('')
}
/**
* The "Friendly" alphabet contains no characters that could easily be confused with one another; for example, there is
* no capital or lowercase 'o', only 0. upper and lowercase i are also omitted so as not to be confused with a 1.
* The numbers are also positioned in the last 10 spaces so that any character selection can easily subset with just
* letters by reducing the index range by 10
*
* @type {string[]}
*/
const friendlyAlphabet = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
]
exports.friendlyRefString = function generateFriendlyRefString(length) {
const buffer = []
for (let i = 0; i < length; i += 1) {
if (i === 0) {
const value = Math.floor(Math.random() * (friendlyAlphabet.length - 10)) // Never start with a number
buffer.push(friendlyAlphabet[value])
} else {
const value = Math.floor(Math.random() * friendlyAlphabet.length)
buffer.push(friendlyAlphabet[value])
}
}
return buffer.join('')
}
function getScryptParams() {
return scrypt.pickParams(0.2)
}
exports.scryptParams = getScryptParams
/**
* Take a value and stringify it.
*
* This implementation will attempt to use `JSON.stringify` if
* the payload contains an implementation of `#toJSON`, otherwise
* the payload's `#toString` method will be called to stringify
* it. It is assumed that the string representation will be a
* valid UTF-8 string
*
* @param {*} payload The value to stringify. Can be anything that
* has a reasonable string representation (i.e. objects are OK,
* functions are not)
*
* @returns {string} A string that represents the payload in some
* manner. No guarantee is made about symmetric serialization, so
* you should not assume that you can reconstruct the payload from
* this return value.
*/
function stringifyPayload(payload) {
let source = null
if (payload.hasOwnProperty('toJSON')) {
source = JSON.stringify(source)
} else {
source = payload.toString()
}
return source
}
/**
* Create a cryptographically secure hash from the given payload. The
* payload will be converted to a string before being hashed, so a
* custom toString or toJSON implementation would be recommended
*
* Be aware that this function is currently backed by Scrypt, but could
* change in future as part of a breaking change, and any persisted
* hashes should be migrated at such time
*
* @param {*} payload The value to hash. Refer to
* {@link module:core/utils/crypto~stringifyPayload} for information
* regarding how it will be serialised
*
* @returns {Promise<string>} A promise that will resolve to the base64
* representation of the hashed payload value
*/
exports.hash = function hashPayload(payload) {
const source = stringifyPayload(payload)
const inputBuffer = Buffer.from(source, 'utf-8')
const params = getScryptParams()
return scrypt.kdf(inputBuffer, params)
.then(hash => hash.toString('base64'))
}
/**
* Verifies that a hash matches the given payload. if the payload
* is an object, it is recommended that the object's stringification
* method (see related hash method) returns values in a deterministic
* order, otherwise verification may fail for two matching objects.
*
* @param {string} hash A previously computed hash value, provided in
* the form of a base64 encoded string
*
* @param {*} payload The payload to check against. Refer to
* {@link module:core/utils/crypto~stringifyPayload} for information
* regarding how it will be serialised
*
* @returns {Promise<boolean>} Whether or not the payload matches the hash
*
* @see module:core/utils/crypto~stringifyPayload
*/
exports.verify = function verifyPayload(hash, payload) {
const source = stringifyPayload(payload)
const inputBuffer = Buffer.from(source, 'utf-8')
const hashBuffer = Buffer.from(hash, 'base64')
return scrypt.verify(hashBuffer, inputBuffer)
}
/**
* The value used to separate an encrypted payload from its initialisation
* vector. As encrypted values and IVs are serialised to hex strings,
* this padding will consist exclusively of URL-safe non-hex characters
*
* @type {string}
* @private
*/
const URL_SAFE_PADDING = '+!n+'
/**
* Takes a value and encrypts it using the apps's key. If the app
* key changes, any encrypted values will be rendered un-decryptable
* via this implementation
*
* No initialisation vector needs to be provided for the cipher, as
* this implementation will generate a nonce of the correct length
* and append it to the generated value, using the URL_SAFE_PADDING
* separator
*
* @param {*} payload The value to encrypt. Refer to
* {@link module:core/utils/crypto~stringifyPayload} for information
* regarding how it will be serialised
*
* @returns {Promise<string>} A promise that will resolve to the
* hex-encoded encrypted payload. May reject with an error if
* encryption failed
*/
exports.encrypt = function encryptWithAppKey(payload) {
return exports.encryptWith(config('app.key'), payload)
}
exports.encryptWith = async function encryptWithKey(key, payload) {
const nonce = await exports.secureBuffer(16)
const cipher = crypto.createCipheriv('aes-256-ctr', key, nonce)
const source = stringifyPayload(payload)
let buffer = cipher.update(source, 'utf8', 'hex')
buffer += cipher.final('hex')
buffer += URL_SAFE_PADDING
buffer += nonce.toString('hex')
return buffer
}
/**
* Takes a value encrypted by {@link module:core/utils/crypto.encrypt|encrypt}
* and attempts to decrypt it. If the app key has changed since the encrypted
* value was generated, decryption will fail.
*
* @param {string} encrypted A value generated by the encrypt function. It is
* assumed that the nonce is appended to the end of `encrypted` by the
* {@link module:core/utils/crypto~URL_SAFE_PADDING|URL_SAFE_PADDING} string. If this value has changed since the encrypted
* string was generated, or is not present, decryption will fail.
*
* @returns {string} The stringified version of the encrypted value, decrypted.
* if a value was provided to `encrypt` after being `JSON.stringify`'d, it is
* safe to run `JSON.parse` against the return value. For other cases, you
* should refer to the serialization guarantees of the object you encrypted
*
* @throws {Error} If the nonce is not provided, or the input was encrypted with
* a different {@link module:core/utils/crypto~URL_SAFE_PADDING|URL_SAFE_PADDING}
* value to the one currently in use
*
* @throws {Error} If the application key or encryption algorithm has changed
* since the input was encrypted
*/
exports.decrypt = function decryptWithAppKey(encrypted) {
return exports.decryptWith(config('app.key'), encrypted)
}
exports.decryptWith = function decryptWithKey(key, encrypted) {
const [payload, nonce] = encrypted.split(URL_SAFE_PADDING)
if (nonce == null) {
throw new Error('Invalid encrypted payload, missing nonce')
}
const nonceBuffer = Buffer.from(nonce, 'hex')
const decipher = crypto.createDecipheriv('aes-256-ctr', key, nonceBuffer)
let buffer = decipher.update(payload, 'hex', 'utf8')
buffer += decipher.final('utf8')
return buffer
}
const ContextualModule = require('core/injection/ContextualModule')
module.exports = class UserService extends ContextualModule {
static getServiceName() { return 'userService' }
findUser(id) {
const { cache } = require('services')
return cache.remember(`finduser:${ id }`, 180, () => ({
id,
email: 'example@example.com',
}))
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment