From ffdcc82f3cf0e204a216cd2dfd75720bf4fd80c9 Mon Sep 17 00:00:00 2001 From: Arthur Buldauskas Date: Wed, 8 Nov 2023 12:39:37 -0500 Subject: [PATCH] Version `0.1.1` (#41) - Add custom responses for cache misses while network access is disabled - Add custom responses for unmocked requests while network access is disabled - Add `--reset`/`-r` reset flag to restart jambox --------- Co-authored-by: Arthur Buldauskas --- CHANGELOG.md | 6 ++ jam.mjs | 8 +- recipes/nextjs-graphql-example/package.json | 2 +- src/Config.mjs | 6 +- src/Jambox.mjs | 54 ++++++++----- .../__snapshots__/parse-args.test.mjs.md | 43 ++++++++++ .../__snapshots__/parse-args.test.mjs.snap | Bin 0 -> 392 bytes src/__tests__/parse-args.test.mjs | 37 +++++++++ src/__tests__/server.test.mjs | 4 +- src/{record.mjs => entrypoint.mjs} | 12 +-- src/handlers/CacheHandler.mjs | 34 ++++++-- src/matchers/CacheMatcher.mjs | 25 ++++-- src/parse-args.mjs | 44 ++++++++++ src/server-launcher.mjs | 75 ++++++++++++------ src/server/index.mjs | 2 +- 15 files changed, 287 insertions(+), 65 deletions(-) create mode 100644 src/__tests__/__snapshots__/parse-args.test.mjs.md create mode 100644 src/__tests__/__snapshots__/parse-args.test.mjs.snap create mode 100644 src/__tests__/parse-args.test.mjs rename src/{record.mjs => entrypoint.mjs} (85%) create mode 100644 src/parse-args.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 282d2b9..882562d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.1.1 - `--reset` cli arg, better messages + +- Add custom responses for cache misses while network access is disabled +- Add custom responses for unmocked requests while network access is disabled +- Add `--reset`/`-r` reset flag to restart jambox + ## 0.1.0 - Refactor - Rework internal logic, bump to `0.1.0` diff --git a/jam.mjs b/jam.mjs index 3dc6d2b..8c354bf 100755 --- a/jam.mjs +++ b/jam.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import record from './src/record.mjs'; +import entrypoint from './src/entrypoint.mjs'; import * as constants from './src/constants.mjs'; import { enable as enableDiagnostics } from './src/diagnostics.js'; @@ -14,7 +14,7 @@ const run = async (argv, cwd) => { process.exit(0); } - record({ + entrypoint({ script, cwd, log: console.log, @@ -24,6 +24,10 @@ const run = async (argv, cwd) => { .then((result) => { if (result.browser) { console.log('Browser launched'); + // TODO: Figure out why spawning the browser process leaves + // our original node process running. + // Maybe we should always exit even for process launches + process.exit(0); } if (result.process) { diff --git a/recipes/nextjs-graphql-example/package.json b/recipes/nextjs-graphql-example/package.json index f859a5e..0ca8f69 100644 --- a/recipes/nextjs-graphql-example/package.json +++ b/recipes/nextjs-graphql-example/package.json @@ -6,7 +6,7 @@ "react-dom": "^18.2.0" }, "scripts": { - "dev": "../../jam.mjs next", + "dev": "../../jam.mjs --reset next", "build": "next build", "start": "next start", "visit": "../../jam.mjs http://jambox-demo-graphql.vercel.app", diff --git a/src/Config.mjs b/src/Config.mjs index dd2e66f..3e62dbd 100644 --- a/src/Config.mjs +++ b/src/Config.mjs @@ -36,7 +36,10 @@ export default class Config extends Emitter { noProxy = ['<-loopback->']; trust = new Set(); forward = null; - cache = null; + /** + * @member {object|null} + */ + cache; stub = null; blockNetworkRequests = false; paused = false; @@ -70,6 +73,7 @@ export default class Config extends Emitter { this.serverURL = new URL('http://localhost'); this.serverURL.port = port || '9000'; this.proxy = proxy; + this.cache = null; this.update(rest); } diff --git a/src/Jambox.mjs b/src/Jambox.mjs index 0a98709..e207716 100644 --- a/src/Jambox.mjs +++ b/src/Jambox.mjs @@ -45,7 +45,12 @@ export default class Jambox extends Emitter { this.cache = new Cache(); this.proxy = proxy; - this.config.subscribe(this.reset.bind(this)); + this.onAbort = this.onAbort.bind(this); + this.onRequest = this.onRequest.bind(this); + this.onResponse = this.onResponse.bind(this); + this.reset = this.reset.bind(this); + + this.config.subscribe(this.reset); } async reset() { @@ -53,9 +58,9 @@ export default class Jambox extends Emitter { await this.proxy.reset(); await this.cache.reset({ ...this.config.cache }); - this.proxy.on('abort', this.#onAbort.bind(this)); - this.proxy.on('request', this.#onRequest.bind(this)); - this.proxy.on('response', this.#onResponse.bind(this)); + this.proxy.on('abort', this.onAbort); + this.proxy.on('request', this.onRequest); + this.proxy.on('response', this.onResponse); if (!this.config.blockNetworkRequests) { await this.proxy @@ -63,8 +68,13 @@ export default class Jambox extends Emitter { .asPriority(98) .thenPassThrough({ // Trust any hosts specified. - ignoreHostHttpsErrors: [...this.config.trust], + ignoreHostHttpsErrors: Array.from(this.config.trust), }); + } else { + await this.proxy + .forAnyRequest() + .asPriority(98) + .thenReply(418, 'Network access disabled', ''); } if (this.config.paused) { @@ -72,19 +82,19 @@ export default class Jambox extends Emitter { } if (this.config.cache) { - await this.#record(this.config.cache); + await this.record(this.config.cache); } if (this.config.forward) { - await this.#forward(this.config.forward); + await this.forward(this.config.forward); } if (this.config.stub) { - await this.#stub(this.config.stub); + await this.stub(this.config.stub); } } - #record(setting) { + record(setting) { return this.proxy.addRequestRule({ priority: 100, matchers: [new CacheMatcher(this, setting)], @@ -92,7 +102,7 @@ export default class Jambox extends Emitter { }); } - #forward(setting) { + forward(setting) { return Promise.all( Object.entries(setting).map(async ([original, ...rest]) => { const options = @@ -150,7 +160,7 @@ export default class Jambox extends Emitter { ); } - #stub(setting) { + stub(setting) { return Promise.all( Object.entries(setting).map(([path, value]) => { const options = typeof value === 'object' ? value : { status: value }; @@ -158,7 +168,7 @@ export default class Jambox extends Emitter { return; } - let response = null; + let response = Buffer.from(''); if (options.file) { response = fs.readFileSync(options.file); } else if (options.body && typeof options.body === 'object') { @@ -178,7 +188,7 @@ export default class Jambox extends Emitter { ); } - #shouldStage(url) { + shouldStage(url) { if (this.cache.bypass() || this.config.blockNetworkRequests) { return false; } @@ -187,19 +197,25 @@ export default class Jambox extends Emitter { const stageList = this.config.cache?.stage || []; const matchValue = url.hostname + url.pathname; - if (ignoreList.some((glob) => minimatch(matchValue, glob))) { + if ( + ignoreList.some((/** @type {string} */ glob) => + minimatch(matchValue, glob) + ) + ) { return false; } - return stageList.some((glob) => minimatch(matchValue, glob)); + return stageList.some((/** @type {string} */ glob) => + minimatch(matchValue, glob) + ); } - async #onRequest(request) { + async onRequest(request) { try { const url = new URL(request.url); const hash = await Cache.hash(request); const cached = this.cache.has(hash); - const staged = cached ? false : this.#shouldStage(url); + const staged = cached ? false : this.shouldStage(url); if (staged) { this.cache.add(request); @@ -218,7 +234,7 @@ export default class Jambox extends Emitter { } } - async #onResponse(response) { + async onResponse(response) { try { if (!this.cache.bypass() && this.cache.hasStaged(response)) { await this.cache.commit(response); @@ -231,7 +247,7 @@ export default class Jambox extends Emitter { } } - async #onAbort(abortedRequest) { + async onAbort(abortedRequest) { if (this.cache.hasStaged(abortedRequest)) { this.cache.abort(abortedRequest); } diff --git a/src/__tests__/__snapshots__/parse-args.test.mjs.md b/src/__tests__/__snapshots__/parse-args.test.mjs.md new file mode 100644 index 0000000..6773753 --- /dev/null +++ b/src/__tests__/__snapshots__/parse-args.test.mjs.md @@ -0,0 +1,43 @@ +# Snapshot report for `src/__tests__/parse-args.test.mjs` + +The actual snapshot is saved in `parse-args.test.mjs.snap`. + +Generated by [AVA](https://avajs.dev). + +## arg parsing, jambox flags: no + +> Snapshot 1 + + { + target: [ + 'yarn', + 'dev', + ], + } + +## arg parsing, jambox flags: yes + +> Snapshot 1 + + { + reset: true, + target: [ + 'yarn', + 'dev', + ], + } + +## arg parsing, custom flags + +> Snapshot 1 + + { + port: 99, + reset: true, + target: [ + 'yarn', + 'dev', + '--port', + '9000', + ], + } diff --git a/src/__tests__/__snapshots__/parse-args.test.mjs.snap b/src/__tests__/__snapshots__/parse-args.test.mjs.snap new file mode 100644 index 0000000000000000000000000000000000000000..e9b4943f89b5da27ffed58654b13356ad659814b GIT binary patch literal 392 zcmV;30eAjERzVY00000000BEkg-a{KoEv^vPl$C6VXOQ$pd&2L`22LUa+w^%T2NubLNt; zyC-*8iLHf=mDLIMzJQhZ4!(r%;N;E?r>Dlog`e5|8D{@)2CX2?!!vnx&sCyQ-dklN z_6jCslEv#@n{|S`=e1H6%RMj46Pd9>p5{tk4x)%D)_kC3ij)VY!PvmMg<1rA0qmoo z%|iuHU0v5DFh>9g%mW-^;}TE;7B(pSmaB3I?V^8yy3vEI10h%+41IGPhrkR0gTO3y z7}XTB&qVfN0%(^;{9Mzm{X*C0auV-l43BPN-9a7KBVsnEjG;qVf=jN3FT)w)r$6A| zqJM{a{0IEyPxxV1s=PD8pCCVhl`(X48|yx*iBk3W^b5XO+e@%J)!@M mdPTjVET~zL`S%rRvx;Rm8f9qh(`v-7@B8m<=*%KF0{{Tt)w { + const flags = parseArgs(['yarn', 'dev'], JAMBOX_FLAGS); + t.snapshot(flags); +}); + +test('arg parsing, jambox flags: yes', (t) => { + const flags = parseArgs(['-r', 'yarn', 'dev'], JAMBOX_FLAGS); + t.snapshot(flags); +}); + +test('arg parsing, custom flags', (t) => { + const flags = parseArgs( + [ + '--port', + '0', + '-p', + '99', + '-r', + '--reset', + 'yarn', + 'dev', + '--port', + '9000', + ], + { + ...JAMBOX_FLAGS, + '--port': ['port', 1, String], + // shouldn't do it this way but it's allowed to change the types here + '-p': ['port', 1, Number], + } + ); + + t.snapshot(flags); +}); diff --git a/src/__tests__/server.test.mjs b/src/__tests__/server.test.mjs index 20eb835..858f577 100644 --- a/src/__tests__/server.test.mjs +++ b/src/__tests__/server.test.mjs @@ -15,7 +15,7 @@ test.before(async (t) => { try { t.context.server = await server({ port: SERVER_PORT, - nodeProcess: { on() {}, exit() {} }, + nodeProcess: { on() {}, exit() {}, pid: '0' }, }); // Setup a tiny server @@ -199,7 +199,7 @@ test.serial('pause', async (t) => { }); // NOTE: This does work but needs a better cache mock -test('server - reset', async (t) => { +test.serial('server - reset', async (t) => { t.assert(t.context.server, `Server init error: ${t.context.error?.stack}`); const cacheDir = path.join(PROJECT_ROOT, 'src', '__mocks__', 'cache-dir'); diff --git a/src/record.mjs b/src/entrypoint.mjs similarity index 85% rename from src/record.mjs rename to src/entrypoint.mjs index 919e1ca..de70340 100644 --- a/src/record.mjs +++ b/src/entrypoint.mjs @@ -1,19 +1,21 @@ // @ts-check +import * as path from 'node:path'; +import { spawn } from 'node:child_process'; import fetch from 'node-fetch'; -import path from 'path'; -import { spawn } from 'child_process'; import persistRuntimeConfig from './persist-runtime-config.mjs'; import launchProxiedChrome from './browser.mjs'; import isURI from './is-uri.mjs'; import launchServer from './server-launcher.mjs'; import Config from './Config.mjs'; +import { parseArgs, JAMBOX_FLAGS } from './parse-args.mjs'; import { createDebug } from './diagnostics.js'; const debug = createDebug(); -export default async function record(options) { +export default async function cli(options) { const { script, cwd = process.cwd(), log, env, constants } = options; - const [entrypoint, ...args] = script; + const flags = parseArgs(script, JAMBOX_FLAGS); + const [entrypoint, ...args] = flags.target; debug('Checking if a server instance is running.'); @@ -21,7 +23,7 @@ export default async function record(options) { config.load(cwd); try { - await launchServer({ log, constants, config }); + await launchServer({ log, constants, config, flags }); } catch (error) { log(`Failed to launch a server, terminating. ${error}`); throw error; diff --git a/src/handlers/CacheHandler.mjs b/src/handlers/CacheHandler.mjs index bb51f29..8e46270 100644 --- a/src/handlers/CacheHandler.mjs +++ b/src/handlers/CacheHandler.mjs @@ -5,14 +5,29 @@ import Cache from '../Cache.mjs'; export default class CacheHandler extends mockttp.requestHandlers .CallbackHandler { /** - * @param svc {object} - * @param svc.cache {Cache} + * Empty cache response */ - constructor(svc) { + static NO_CACHE_RESULT = { + statusCode: 404, + statusMessage: 'No cached result found', + json: { + errors: [ + 'This request was matched, but no previous response found in cache.', + ], + }, + }; + + /** + * @param {import('../Jambox.mjs').default} jambox + */ + constructor(jambox) { const callback = async (completedRequest) => { try { const hash = await Cache.hash(completedRequest); - const { response } = svc.cache.get(hash); + if (!jambox.cache.has(hash)) { + return CacheHandler.NO_CACHE_RESULT; + } + const { response } = jambox.cache.get(hash); return { headers: { ...response.headers, @@ -25,7 +40,16 @@ export default class CacheHandler extends mockttp.requestHandlers statusMessage: response.statusMessage, }; } catch (e) { - throw new Error('Error'); + return { + statusCode: 500, + statusMessage: 'Internal jambox error', + json: { + errors: [ + 'CacheHandler encountered an error', + `${e.message} ${e.stack}`, + ], + }, + }; } }; diff --git a/src/matchers/CacheMatcher.mjs b/src/matchers/CacheMatcher.mjs index 77f6176..cb1a8b4 100644 --- a/src/matchers/CacheMatcher.mjs +++ b/src/matchers/CacheMatcher.mjs @@ -7,24 +7,37 @@ export default class CacheMatcher extends mockttp.matchers.CallbackMatcher { #options; /** - * @param svc {object} - * @param svc.cache {Cache} + * @param {import('../Jambox.mjs').default} jambox */ - constructor(svc, options = {}) { + constructor(jambox, options = {}) { super(async (request) => { - if (svc.cache.bypass()) { + if (jambox.cache.bypass()) { return false; } const { ignore = [], stage = [] } = options; const url = new URL(request.url); - const testGlob = (glob) => minimatch(url.hostname + url.pathname, glob); + const testGlob = (/** @type {string} */ glob) => + minimatch(url.hostname + url.pathname, glob); const ignored = ignore.some(testGlob); + + if (ignored) { + return false; + } + const matched = stage.some(testGlob); + if (!matched) { + return; + } + + if (jambox.config.blockNetworkRequests) { + return true; + } + const hash = await Cache.hash(request); - return !ignored && matched && svc.cache.has(hash); + return jambox.cache.has(hash); }); this.#options = options; } diff --git a/src/parse-args.mjs b/src/parse-args.mjs new file mode 100644 index 0000000..2d531f8 --- /dev/null +++ b/src/parse-args.mjs @@ -0,0 +1,44 @@ +export const JAMBOX_FLAGS = { + '-r': ['reset', 0, Boolean], + '--reset': ['reset', 0, Boolean], +}; + +/** + * Parse out supported flags, everything else becomes the target script + * + * args = ['yarn', 'dev'] + * args = ['yarn', '--yarn-flag', '--yarn-flag2', 'dev'] + * args = ['yarn', 'dev', '--dev-flag'] + * args = ['-A', '', '-B', '', '-JamboxFlag', '', 'yarn', 'dev'] + * + * result: + * // flags object + * { + * A: [""], + * B: [""], + * JamboxFlag: [""], + * target: ["yarn dev"] // etc + * } + * + * @param {Array} args + * @param {{[string]: [string, number]}} supported + * + * @return {Object} + */ +export const parseArgs = (args, supported) => { + const flags = {}; + let i = 0; + while (i < args.length) { + const flag = args[i]; + if (flag in supported) { + const [name, length, type] = supported[flag]; + flags[name] = args.slice(i + 1, i + 1 + length); + flags[name] = type(flags[name]); + i += 1 + length; + } else { + flags.target = args.slice(i); + break; + } + } + return flags; +}; diff --git a/src/server-launcher.mjs b/src/server-launcher.mjs index f0778b3..40fbc33 100644 --- a/src/server-launcher.mjs +++ b/src/server-launcher.mjs @@ -21,7 +21,16 @@ const ping = async (href) => { } }; -const spawnServerProcess = async ({ config, constants }) => { +/** + * Launches the jambox server in a new process + * + * @param {object} options + * @param {(msg: string) => void} options.log + * @param {import('./Config.mjs').default} options.config + * @param {object} options.constants + */ +const spawnServerProcess = async ({ log, config, constants }) => { + log('Launching a new Jambox instance.'); const out = fs.openSync(config.logLocation, 'a'); const child = spawn( 'node', @@ -52,34 +61,54 @@ const spawnServerProcess = async ({ config, constants }) => { }); }; -// Returns when a server instance is available -const launcher = async ({ log, constants, config }) => { +/** + * Kill a running server + * + * @param {import('./Config.mjs').default} config + * @param {(msg: string) => void} log + */ +const killOldServer = (config, log) => { + log(`Sending a shutdown signal to running Jambox server.`); + return fetch(new URL('shutdown', config.serverURL)); +}; + +/** + * Returns when a server instance is available + * + * @param {object} options + * @param {object} options.constants + * @param {(msg: string) => void} options.log + * @param {import('./Config.mjs').default} options.config + * @param {object} options.flags + */ +const launcher = async ({ log, constants, config, flags }) => { debug('Checking if a server instance is running.'); const isServerAvailable = await ping(config.serverURL.href); + if (!isServerAvailable) { + return spawnServerProcess({ log, config, constants }); + } - if (isServerAvailable) { - // Would be slightly nicer if waitOn could return the values from the pinged endpoints... - const apiVersion = await fetch(config.serverURL.href).then((res) => - res.text() - ); - const currentVersion = getVersion(); - if (currentVersion !== null && apiVersion !== currentVersion) { - log( - `Installed version (${getVersion()}) is different from running version (${ - apiVersion || '??' - }).` - ); - log(`Killing old version.`); - await fetch(`${config.serverURL.origin}/shutdown`); - log(`Starting a new Jambox process.`); - await spawnServerProcess({ config, constants }); - } - return; + if (flags.reset) { + log('Resetting.'); + await killOldServer(config, log); + return spawnServerProcess({ log, config, constants }); } - log('Jambox server not currently running. Launching an instance'); + // Would be slightly nicer if waitOn could return the values from the pinged endpoints... + const apiVersion = await fetch(config.serverURL.href).then((res) => + res.text() + ); + const currentVersion = getVersion(); - await spawnServerProcess({ config, constants }); + if (currentVersion !== null && apiVersion !== currentVersion) { + log( + `Installed version (${getVersion()}) is different from running version (${ + apiVersion || '??' + }).` + ); + await killOldServer(config, log); + return spawnServerProcess({ log, config, constants }); + } }; export default launcher; diff --git a/src/server/index.mjs b/src/server/index.mjs index 3999114..f34bdc8 100644 --- a/src/server/index.mjs +++ b/src/server/index.mjs @@ -52,7 +52,7 @@ async function start({ port, nodeProcess = process, filesystem = fs }) { app.get('/shutdown', async (_req, res) => { await proxy.stop(); - res.send('Shutting down.'); + res.send(nodeProcess.pid.toString()); nodeProcess.exit(0); });