UNPKG

smoke

Version:

Simple yet powerful file-based mock server with recording abilities

219 lines (188 loc) 7.14 kB
import process from 'node:process'; import path from 'node:path'; import http from 'node:http'; import https from 'node:https'; import fs from 'node:fs'; import {fileURLToPath} from 'node:url'; import express from 'express'; import bodyParser from 'body-parser'; import multer from 'multer'; import proxy from 'express-http-proxy'; import corsMiddleWare from 'cors'; import {getMocks} from './mock.js'; import {respondMock} from './response.js'; import {record} from './recorder.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export async function createServer(options) { options = getOptions(options); const app = express() .disable('x-powered-by') .set('port', options.port) .set('host', options.host) .set('https', options.https) .use(bodyParser.urlencoded({extended: true})) .use(bodyParser.json({strict: false})) .use(multer().any()); let allowedOrigins = options.cors ? options.cors.split(',') : []; allowedOrigins = allowedOrigins.map((val) => val.trim()); if (allowedOrigins.length === 0 || allowedOrigins.includes('all')) { app.use(corsMiddleWare()); } else { app.use( corsMiddleWare({ origin(origin, callback) { /** * Allow requests with no 'origin' * i.e. mobile apps, curl etc. */ if (!origin) return callback(null, true); if (!allowedOrigins.includes(origin)) { const msg = `The CORS policy for this mock does not allow access from the specified origin: ${origin}. For details on how to whitelist requests from this origin, refer to --help.`; return callback(new Error(msg), false); } return callback(null, true); }, }), ); } const hooks = {before: [], after: []}; if (options.logs) { const morgan = (await import('morgan')).default; app.use(morgan('dev')); } if (options.hooks) { try { const hooksFile = path.isAbsolute(options.hooks) ? options.hooks : path.join(process.cwd(), options.hooks); let loadedHooks = (await import(hooksFile)) || {}; loadedHooks = loadedHooks.default ?? loadedHooks; hooks.before = Array.isArray(loadedHooks.before) ? loadedHooks.before : []; hooks.after = Array.isArray(loadedHooks.after) ? loadedHooks.after : []; } catch (error) { process.exitCode = -1; return console.error(`Cannot setup middleware hooks: ${error.message}`); } } return app.all(/(.*)/, hooks.before, asyncMiddleware(processRequest(options)), hooks.after, sendResponse); } export function startServer(app) { const port = app.get('port'); const host = app.get('host'); const useHttps = app.get('https'); let server; if (useHttps) { const key = fs.readFileSync(path.join(__dirname, '/../ssl/selfsigned.key')); const cert = fs.readFileSync(path.join(__dirname, '/../ssl/selfsigned.crt')); server = https.createServer({key, cert}, app); } else { server = http.createServer(app); } server.listen(port, host, () => { console.log(`Server started on: http${useHttps ? 's' : ''}://${host}:${port}`); }); } function getOptions(options) { options ||= {}; return { basePath: options.basePath || '', port: options.port || 3000, host: options.host || 'localhost', set: options.set || null, notFound: options.notFound || '404.*', ignore: options.ignore ? [options.ignore] : [], hooks: options.hooks || null, proxy: options.record || options.proxy || null, logs: options.logs || false, record: Boolean(options.record), collection: options.collection || null, depth: typeof options.depth === 'number' ? options.depth : 1, saveHeaders: options.saveHeaders || false, saveQueryParams: options.saveQueryParams || false, cors: options.cors || null, https: options.https || false, }; } function matchMock(mock, method, set, query) { return ( (!mock.methods || mock.methods.includes(method)) && (!mock.set || mock.set === set) && (!mock.params || Object.entries(mock.params).every(([k, v]) => query[k] === v)) ); } function processRequest(options) { return async (req, res, next) => { const {query, headers, body, files} = req; const reqPath = req.path.slice(1); const method = req.method.toLowerCase(); const data = {method, query, params: {}, headers, body, files}; const ignore = [...options.ignore, ...(options.hooks ? [options.hooks] : [])]; const mocks = await getMocks(options.basePath, [options.notFound, ...ignore]); const matches = mocks.reduce((allMatches, mock) => { const match = reqPath.match(mock.regexp); if (match) { const isJsMock = mock.ext === 'js' || mock.ext === 'cjs' || mock.ext === ''; const accept = req.accepts(mock.type); // For JS mocks and files without extension, skip accept header validation // For other mocks, validate accept header if ((isJsMock || accept) && matchMock(mock, method, options.set, query)) { const score = (mock.methods ? 1 : 0) + (mock.set ? 2 : 0) + (mock.params ? 4 : 0) + (mock.data === undefined ? 0.5 : 0) + (accept ? 0.1 : 0); allMatches.push({match, mock, score}); } } return allMatches; }, []); // Body-parser v2 returns undefined for empty bodies instead of an empty object if (data.body === undefined) { data.body = {}; } if (matches.length === 0) { if (options.proxy) { console.info(`No mock found for ${req.path}, proxying request to ${options.proxy}`); return proxy(options.proxy, { limit: '10mb', async userResDecorator(proxyRes, proxyResData, userReq) { if (options.record) { await record(userReq, proxyRes, proxyResData, options); } return proxyResData; }, })(req, res, next); } // Search for 404 mocks, matching accept header const notFoundMocks = await getMocks(options.basePath, ignore, [options.notFound]); const types = notFoundMocks.length > 0 ? notFoundMocks.map((mock) => mock.type) : null; const accept = types && req.accepts(types); const mock = accept && notFoundMocks.find((mock) => mock.ext === accept); if (mock) { await respondMock(res, mock, data, 404); } else { res.status(404).type('txt'); res.body = 'Not Found'; } } else { matches.sort((a, b) => b.score - a.score); const {match, mock} = matches[0]; // Fill in route params for (const [index, key] of mock.keys.entries()) { data.params[key.name] = match[index + 1]; } await respondMock(res, mock, data); } next(); }; } function sendResponse(_req, res) { if (res.body === null) { res.end(); } else { res.send(res.body); } } function asyncMiddleware(middleware) { // eslint-disable-next-line promise/prefer-await-to-then return (req, res, next) => Promise.resolve(middleware(req, res, next)).catch(next); }