npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@acrontum/moxy

v3.2.1

Published

Simple, configurable part mock part proxy

Downloads

327

Readme

Moxy

Simple, configurable mock / proxy server with 0 dependencies.


Table of Contents

Quick Start

Installation

npm i -D @acrontum/moxy

Programatic

import { MoxyServer } from '@acrontum/moxy';

const moxy = new MoxyServer();

moxy.on('hello/world', {
  get: {
    status: 200,
    body: {
      message: 'Welcome!'
    }
  }
});

await moxy.listen(5000);

CLI

# load from local routes folder, and add single get handler for /hello/world
npx @acrontum/moxy --port 5000 --routes ./routes/ --on '{
  "path": "hello/world",
  "config": {
    "get": {
      "status": 418,
      "body": {
        "message": "I am a teapot"
      }
    }
  }
}'

Docker

docker run \
  --name moxy \
  --publish 5000:5000 \
  --volume $PWD/routes:/opt/routes \
   acrontum/moxy --port 5000 --routes /opt/routes

# OR acrontum/moxy --port 5000 --on '{ "path": "hello/world", "config": { "get": { "status":200, "body":{ "message":"Welcome!" } } } }'

Docker compose

version: "3.7"

services:
  moxy:
    image: acrontum/moxy
    container_name: moxy
    volumes:
      - ./routes:/opt/routes
    environment:
      - PORT=80
    ports:
      - 5000:80
    init: true
    command: --allow-http-config --routes /opt/routes

Examples (see the examples folder)

note that examples are in TypeScript for clarity, but moxy is a javascript library so you will need to transpile if you want to use TypeScript.

Simple server

import { MoxyServer } from '@acrontum/moxy';

const moxy = new MoxyServer();
await moxy.listen(5000);

moxy.on('/api/cats', {
  get: {
    body: [
      { name: 'alice', flavour: 'yellow' },
      { name: 'bob', flavour: 'black' },
      { name: 'cheshire', flavour: 'stripey' }
    ]
  }
});
// curl localhost:5000/api/cats
//   -> 200 [{"name":"alice","flavour":"yellow"},{"name":"bob","flavour":"black"},{"name":"cheshire","flavour":"stripey"}]

moxy.on('/api/cats/alice', {
  get: {
    body: { 
      name: 'alice', 
      flavour: 'yellow'
    }
  }
});
// curl localhost:5000/api/cats/alice
//   -> 200 {"name":"alice","flavour":"yellow"}

Common config options

import { MoxyServer } from '@acrontum/moxy';

// initialize with default settings (optional)
const moxy = new MoxyServer({
  logging: 'verbose',
  router: {
    allowHttpRouteConfig: false
  }
});

// start listening on port 5000 (can be done before or after route configuration)
await moxy.listen(5000);

// configure listener for a GET /some/path, responding with a 200 and text body "Hi!"
moxy.on('/some/path', {
  get: {
    status: 200,
    body: 'Hi!',
    headers: { 'Content-Type': 'text/plain' }
  }
});

// configure multiple routes (/auth/login, /auth/logout, and /auth/register) by prefix:
moxy.onAll('/auth/', {
  '/login/': {
    post: {
      status: 401,
      body: { message: 'Unauthorized' }
    }
  },
  '/logout/': {
    post: {
      status: 204
    }
  },
  '/register/': {
    post: {
      status: 201,
      body: { message: 'welcome' }
    }
  }
});

// handle all HTTP verbs with a request handler
moxy.on('/not/a/test', (req: MoxyRequest, res: MoxyResponse, variables: HandlerVariables) => {
  console.log('Hi world.');

  return res.sendJson({ pewPew: 'lazors' });
});

moxy.on('/still/not/a/test', {
  patch: (req: MoxyRequest, res: MoxyResponse, variables: HandlerVariables) => {
    console.log('Updating the world.');

    return res.sendJson({ pewPew: 'lazors' });
  }
});

// load from filesystem
await moxy.addRoutesFromFolder('/path/to/my/routes');

// using basic variable replacement, search in a static folder and return an image
moxy.on('/users/:userId/profile-picture', '/static/images/:userId.png');

// ... make requests against moxy

// stop the server. closeConnections: true will close any active connections immediately instead of waiting for them.
await moxy.close({ closeConnections: true });

Use it in tests

import { expect } from 'chai';
import { after, afterEach, before } from 'mocha';
import { default as supertest } from 'supertest';
import { MoxyServer } from '@acrontum/moxy';
import { MyApplication } from '../path/to/my/app/src';

describe('API auth', () => {
  const moxy: MoxyServer = new MoxyServer({ logging: 'error' });
  let request: supertest.SuperTest<supertest.Test>;

  before(async () => {
    moxy.on('/auth/login', {
      post: {
        status: 200,
        body: {
          username: 'bob'
        },
        headers: {
          'Set-Cookie': 'om-nom-nom;'
        }
      }
    });
    await moxy.listen(5001);

    MyApplication.configure({ authUrl: 'http://localhost:5001' });
    await MyApplication.start();

    request = supertest(MyApplication.expressApp);
  });

  after(async () => {
    await moxy.close({ closeConnections: true });
    await MyApplication.stop();
  });

  it('can authenticate users', async () => {
    await request.post('/v1/login').send({ username: 'bob', password: 'yes' })  
      .expect(({ status, body }) => {
        expect(status).equals(200);
        expect(body).deep.equals({ username: 'bob' });
      });

    // no more spyOn(http.send)!
    // test your full E2E http request flow
    moxy.once('/auth/login', {
      post: {
        status: 401,
        body: {
          message: 'Unknown user or invalid password'
        }
      }
    });

    await request.post('/v1/login').send({ username: 'bob', password: 'yes' })  
      .expect(({ status, body }) => {
        expect(status).equals(401);
        expect(body).equals('Failed to login');
      });
  });
});

Using variable replacement

import { MoxyServer, MoxyRequest, MoxyResponse, HandlerVariables } from '@acrontum/moxy';

const moxy = new MoxyServer();
await moxy.listen(5000);

moxy.on('/echo/:name', {
  get: {
    status: 200,
    body: {
      hello: ':name'
    },
  }
});
// curl localhost:5000/echo/bob
//   -> 200 {"hello":"bob"}
// curl localhost:5000/echo/bob/and/jane
//   -> 404

moxy.on('/echo-with-slash/(?<pathWithSlash>.+)', {
  get: {
    status: 200,
    body: {
      hello: ':pathWithSlash'
    },
  }
});
// curl localhost:5000/echo-with-slash/this/will/be/in/the/body
//   -> 200 {"hello":"this/will/be/in/the/body"}

moxy.on('/query\\?search=:querySearch', {
  get: {
    status: 200,
    body: {
      youSearchedFor: ':querySearch'
    },
  }
});

moxy.on('/query(.*)', {
  get: (req: MoxyRequest, res: MoxyResponse, vars: HandlerVariables) => {
    return res.sendJson({ status: 418, query: vars });
  }
});
// curl localhost:5000/query
//   -> {"status":418,"query":{}}

// curl 'localhost:5000/query?search=hello'
//   -> {"youSearchedFor":"hello"}

// curl 'localhost:5000/query?test=hello'
//   -> {"status":418,"query":{"test":"hello"}}

Configure a basic file server

import { MoxyServer, MoxyRequest, MoxyResponse, HandlerVariables } from '@acrontum/moxy';
import { createWriteStream, promises } from 'fs';
import { join, dirname, resolve } from 'path';

const moxy = new MoxyServer();

// simple file server (GET only, no nested paths)

// NOTE moxy will not accept paths outside of the target assets folder by default
// such as /home/file.png or ../../file.png. To disable this behaviour, use a
// request handler
moxy.on('/v1/assets/:filename', './assets/:filename');

// file server with upload
moxy.on('/v1/database/(?<filename>.+)', {
  get: './disk-db/:filename',
  put: async (req: MoxyRequest, res: MoxyResponse, vars: HandlerVariables) => {
    if (/\.\./.test(vars.filename)) {
      return res.sendJson({ status: 422, message: 'Invalid filename' });
    }

    const outfile = join('./disk-db', vars.filename);
    await promises.mkdir(resolve(dirname(outfile)), { recursive: true });

    const output = createWriteStream(outfile, 'utf-8');
    req.on('end', () => res.sendJson({ status: 201, message: 'ok' }));

    req.pipe(output);
  }
});

await moxy.listen(5000);

/*
mkdir assets/
echo 'hello world!' > assets/welcome.txt

curl localhost:5000/v1/assets/hello.html
#  -> 404

curl localhost:5000/v1/assets/welcome.txt
#  -> 200 hello world!

curl localhost:5000/v1/database/secrets/passwords.txt
#  -> 404

curl -XPUT localhost:5000/v1/database/secrets/passwords.txt -d 'admin=super-secret'
#  -> 201

curl localhost:5000/v1/database/secrets/passwords.txt
#  -> 200 admin=super-secret
*/

Use as a proxy

import { MoxyServer } from '@acrontum/moxy';

const moxy = new MoxyServer({ router: { allowHttpRouteConfig: true } });

// transparent proxy moxy -> google
moxy.on('/(?<path>.*)', {
  proxy: 'https://www.google.ca/:path'
});

await moxy.listen(5000);

/*

open http://localhost:5000/search?q=acrontum/moxy

since we allowed httpConfig, we can do this from the browser devtools:

fetch('http://localhost:5000/_moxy/routes', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    path: '/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png',
    config: {
      proxy: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Flag_of_Canada_%28Pantone%29.svg/255px-Flag_of_Canada_%28Pantone%29.svg.png'
    }
  })
});

and now when we refresh, the google logo is a Canada flag (much better, eh?)
*/

Or a 1-liner from the cli to spin up a quick proxy server

npx @acrontum/moxy --port 5000 --on '{ "path": "/(?<path>.*)", "config": { "proxy": "http://corporate.ca/:path" } }' --allow-http-config

Serve a local folder as a git repo for cloning

import { HandlerVariables, MoxyRequest, MoxyResponse, MoxyServer } from '@acrontum/moxy';
import { exec, spawn } from 'child_process';
import { existsSync } from 'fs';
import { rm } from 'fs/promises';
import { join } from 'path';
import { promisify } from 'util';

const moxy = new MoxyServer();

const run = promisify(exec);

const pack = (service: string) => {
  const name = `# service=${service}\n`;
  const len = (4 + name.length).toString(16);

  return `${Array(4 - len.length + 1).join('0')}${len}${name}0000`;
};

const getRepo = (repoName: string) => join('./repos', repoName.replace('.git', ''));

moxy.on('/projects/:repo/info/refs\\?service=:service', {
  get: async (req: MoxyRequest, res: MoxyResponse, vars: HandlerVariables): Promise<MoxyResponse> => {
    const repo = getRepo(vars.repo as string);
    const service = vars.service as string;

    if (!existsSync(repo)) {
      return res.sendJson({ status: 404, body: 'Not found', vars });
    }

    try {
      if (!existsSync(join(repo, '.git'))) {
        await run(`git init -q && git add -A && git commit -am init`, { cwd: repo });
      } else {
        await run(`git add -A && git commit -am init || echo 'up to date'`, { cwd: repo });
      }

      res.writeHead(200, {
        'Content-Type': `application/x-${service}-advertisement`,
        'Cache-Control': 'no-cache',
      });
      res.write(pack(service));

      const uploadPack = spawn(service, ['--stateless-rpc', '--advertise-refs', repo]);

      return uploadPack.stdout.pipe(res);
    } catch (e) {
      console.error(e);
    }

    return res.sendJson({ status: 404, body: 'Not found', vars });
  },
});

moxy.on('/projects/:repo/git-upload-pack', {
  post: async (req: MoxyRequest, res: MoxyResponse, vars: HandlerVariables): Promise<MoxyResponse> => {
    const repo = getRepo(vars.repo as string);

    if (!existsSync(repo)) {
      return res.sendJson({ status: 404, body: 'Not found', vars });
    }

    try {
      res.writeHead(200, {
        'Content-Type': 'application/x-git-upload-pack-response',
        'Cache-Control': 'no-cache',
      });

      const proc = spawn(`git-upload-pack`, ['--stateless-rpc', repo]);
      proc.stdout.on('end', () => rm(join(repo, '.git'), { recursive: true }).catch(console.error));

      req.pipe(proc.stdin);

      return proc.stdout.pipe(res);
    } catch (e) {
      console.error(e);
    }

    return res.sendJson({ status: 404, body: 'Not found', vars });
  },
});

await moxy.listen(5000);

/*
git clone http://localhost:5000/projects/server.git
git clone http://localhost:5000/projects/app
*/

More

The examples folder contains most of the examples above, as well as the example config which has common configuration options with comments:

import { HandlerVariables, MoxyRequest, MoxyResponse, Routes } from '@acrontum/moxy';

// The export name is unused, so it can be anything. Moxy will use the path to the file and the values of the Routes to
// configure the routing.
export const routeConfig: Routes = {
  // example using basic path params and replacements.
  '/:machineId/measurements/:measurementId': {
    get: {
      status: 200,
      // variables from the path can be injected into the response.
      // The simple version is ":variableName" in the path params and body. By
      // default, this will only match word boundaries (eg /:variable/).
      body: `<!DOCTYPE html><html>
        <head>
          <meta charset="utf-8">
          <title>:machineId/:measurementId</title>
        </head>
        <body>
          <h1>Machine: :machineId - measurement: :measurementId</h1>
        </body>
      </html>`,
      headers: {
        'Content-Type': 'text/html',
      },
    },
  },
  // The more complicated method is using regex capture groups. This allows for more control over how groups are
  // captured (eg for matching slashes in the path).
  '/static/(?<file>.*)': {
    // When the value for a method is a simple string, a file is assumed.
    get: '/public/:file',
  },
  // This is the short form of the above:
  '/assets/(?<file>.*)': '/www-data/:file',
  // With the above 2 configs, moxy will look in the `./public` folder for requests to `/static/path/to/file`, and the
  // `./www-data` folder for requests to `/assets/path/to/file`. These are relative to the process's current directory,
  // so if you ran from this folder it would look in `./images` and `./public` and try to return the file.
  'auth/login': {
    post: {
      status: 200,
      body: {
        active: true,
        user_id: 'user_id',
      },
    },
  },
  '/users/:username': {
    patch: {
      status: 200,
      body: {
        firstName: 'pat',
        username: ':username',
      },
    },
    get: {
      status: 200,
      body: {
        firstName: 'pat',
        username: ':username',
      },
    },
  },
  // example which proxies requests from moxy.test/proxied-server/<target> to google/<target>
  'proxied-server(?<path>.*)': {
    // here, everything after proxied-server is passed through to the proxy target
    proxy: 'https://www.google.com:path',
    // proxy options are the same as http request options, and are passed through.
    proxyOptions: {
      headers: {
        'x-auth-token': 'totally-real',
      },
    },
  },
  'manual-override': (request: MoxyRequest, response: MoxyResponse, variables: HandlerVariables) => {
    response.writeHead(418);
    response.end('I am a teapot');
  },
  'partly-manual-override/:userId': {
    get: {
      status: 418,
      body: 'I am a teapot',
    },
    post: (request: MoxyRequest, response: MoxyResponse, variables: HandlerVariables) => {
      response.writeHead(201);
      response.end(`Brew started for ${variables.userId}`);
    },
  },
  '/glacial/': {
    // slow all methods by 100ms
    delay: 100,
    get: {
      // slow only 1 method by 100ms
      delay: 100,
      status: 204,
    },
    delete: {
      status: 204,
    },
  },
  // note that by default, the path is converted to a regex, so special chars
  // should be escaped
  '/path/with/query\\?search=:theSearchThing': {
    get: {
      body: 'you searched for :theSearchThing',
      headers: { 'Content-Type': 'text/plain' },
    },
  },
  // passing exact: true will prevent the path from being converted to a regex.
  // NOTE: this will also disable simple or regex replacements. Parsed query params will still be returned in
  // HandlerVariables if you use a request handler (see below).
  '/exact/match/:notCaptured?queryMustHave': {
    exact: true,
    get: {
      status: 204,
    },
  },
  // if the handler would normally be a function and exact matching is desired,the 'all' method can be used to achieve
  // this.
  '/exact/match/handler?ignore=(.*)': {
    exact: true,
    all: (request: MoxyRequest, response: MoxyResponse, variables: HandlerVariables) => {
      return response.sendJson({ matchedExactly: true });
    },
  },
};

See API for full usage.

Configuration

Via HTTP requests

Assuming moxy is running at localhost:5000, and has HTTP config enabled:

# hit the moxy api
curl localhost:5000/_moxy/

# add a new handler that responds to GET /test/path with a 200 and text/plain "neat" response
curl localhost:5000/_moxy/routes \
  -H 'Content-Type: application/json' \
  -d '{
    "path": "/test/path",
    "config": {
      "get": {
        "status": 200,
        "body": "neat"
      }
    }
  }'

# will show the array of paths avaiable
curl localhost:5000/_moxy/routes

# will show paths and their handler object currently configured
curl localhost:5000/_moxy/router

# test our new route
curl localhost:5000/test/path

# remove our new route
curl -X DELETE localhost:5000/_moxy/routes/test/path

# add a "once" route handler
curl localhost:5000/_moxy/routes?once=true \
  -H 'Content-Type: application/json' \
  -d '{
    "path": "/pew/pew",
    "config": {
      "get": {
        "status": 200,
        "body": "neat"
      }
    }
  }'

# 200
curl localhost:5000/pew/pew

# 404
curl localhost:5000/pew/pew

Note that you will not be able to configure the response using a function via HTTP.

See API for full usage.

From files

Moxy can load routing configs from the filesystem, searching recursively for .js or .json files matching <anything>.routes.js(on). This allows you to organize routes into files, and put them in a routes folder (as shown in the example-routing folder).

await moxy.addRoutesFromFolder('/path/to/routes/folder');
# npx cli
npx @acrontum/moxy --routes /path/to/routes/folder

# docker run
docker run acrontum/moxy --routes /path/to/routes/folder
version: "3.7"

services:
  moxy:
    image: acrontum/moxy
    volumes:
      - ./routes:/opt/routes
    command: --routes /opt/routes

When loading routes from folders, the tree structure will determine the routing. For instance, if your folder structure looks like this:

public/
├── routes
│   ├── a
│   │   ├── a.routes.js
│   │   └── b
│   │       └── b.routes.js
│   └── c
│       └── c.routes.json
└── static
    ├── css
    ├── js
    └── index.html

And you loaded the public folder into moxy's router like so:

npx @acrontum/moxy --port 5000 --routes ./public

or

moxy.loadConfigFromFile('./public');

then your route config would look like:

{
  "/public/routes/a/": "...config from a.routes.js file",
  "/public/routes/a/b/": "...config from b.routes.js file",
  "/public/routes/c": "...config from c.routes.json file"
}

If you instead loaded the "a" folder (eg. --routes ./public/a/, it would look like:

{
  "/a/": "...config from a.routes.js file",
  "/a/b/": "...config from b.routes.js file",
}

API

See full API docs.

CLI options

# npx @acrontum/moxy --help
Start a mocking server

options:
-r, --routes FOLDER       Add routes from FOLDER. Can be called multiple times,
                          FOLDER can be multiple separated by comma (,).
-p, --port PORT           Run on port PORT. If none specified, will find an
                          avaiable port.
-o, --on CONFIG           Add json CONFIG to routes.
-q, --quiet               Decrease log verbosity.
-a, --allow-http-config   Allow routes config via HTTP methods. Default false.
-v, --version             Show build version and exit.
-h, --help                Show this menu.

HTTP API

Moxy exposes some HTTP routes for checking routing configurations. With allowHttpRouteConfig enabled it will also expose HTTP CRUD routes:

const server = new MoxyServer({ router: { allowHttpRouteConfig: true } });
npx @acrontum/moxy --allow-http-config

The HTTP API offers most of the functionality that programatic or file configs offer, although it is not possible to create and handler as a function at this time.

GET /_moxy:

Returns the list of api methods:

{
  "GET /router?once=false&serializeMethods=true": "show router",
  "GET /routes?once=false": "show router routes",
  // below are only available with '--allow-http-config'
  "POST /routes?once=false": "create route",
  "PUT /routes/:route": "create or update route",
  "PATCH /routes/:route": "update route",
  "DELETE /routes/:route": "delete route"
}

GET /_moxy/routes

Display a list of routes which moxy is listening to with default query params (if applicable).

Query params:

once=true: Show routes which will fire once then be removed (eg for testing).

GET /_moxy/router

Will return the current router config, including response handlers.

Query params:

once=true: Show routes which will fire once then be removed (eg for testing).

serializeMethods=false: Don't call .toString() on methods (removes some noise).

POST /_moxy/router

Will add a json payload to the router.

The payload should contain path and config, where path is the router path and config is RouteConfig:

{
  "path": "/some/path",
  "config": {
    "get": {
      "status": 200
    }
  }
}

Query params:

once=true: As soon as this route is hit, remove it.

The response will be a 201 containing the newly added route.

PATCH /_moxy/router/:route

Will update the route specified by :route (:route will match everything after /router/ including slashes).

Payload: RouteConfig.

The response will be a 200 containing the newly updated route.

PUT /_moxy/router/:route

Will replace the route specified by :route (:route will match everything after /router/ including slashes).

Payload: RouteConfig.

The response will be a 201, or 200 if a route was replaced.

DELETE /_moxy/router/:route

Will delete the route specified by :route (:route will match everything after /router/ including slashes).

The response will be a 200 containing { message: 'Ok' };