How To Make A Simple AdonisJS Package

Learn about AdonisJS' MRM Preset and how we can use it to easily make an AdonisJS package. In particular, we'll be building a Honeypot package that defines a provider, middleware, global component, and types.

Published
Jun 19, 22

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

Prerequisites

Before we begin, you’re going to want to have a Git Repository created and ready to go so that you can grab its URL for a few of the earlier steps.

What We’ll Be Building

In this lesson, we’ll be building a simple Honeypot package for AdonisJS. Honeypots are an easy way to prevent bots from submitting forms by injecting realistic fields into the form that only those bots can see. We’ll then have a middleware on our server that checks to see if those fields were filled out. If they were, we know a bot submitted the form and we can safely reject the submission.

You can find all the code for this package on GitHub. This package is also on NPM.

Getting Started

The first step here is to create a new folder on our system where we’ll be building the package. I’ll be naming mine adonisjs-honeypot. So, in your terminal let’s run the following to create the folder, then change into its directory.

mkdir adonisjs-honeypot
cd adonisjs-honeypot

Initialize NPM

Next, let’s go ahead and initialize NPM within our directory so that we can install the needed dependencies.

npm init

# ❯ package name: @adocasts.com/adonisjs-honeypot
# ❯ version: 1.0.0
# ❯ description:
# ❯ entry point: build/providers/HoneypotProvider.js
# ❯ test command:
# ❯ git repository: https://github.com/adocasts/adonisjs-honeypot.git
# ❯ keywords:
# ❯ author: adocasts.com,tomgobich
# ❯ license: MIT

Now that we have a package.json file in our repository to track our dependencies, we’re good to move onward.

MRM & The AdonisJS MRM Preset

If you’re not familiar with it, there’s a command-line tool called MRM which aids in keeping configuration files in sync. This is especially useful for open-source communities so that they can make as much of their community's code the same.

AdonisJS has its own MRM Preset, which we’ll be using to scaffold our package’s configuration. This preset provides a list of tasks we can execute, and it’ll alter our package’s configuration files to match that of the rest of the AdonisJS Core and community.

Installing & Configuring MRM

So, let’s go ahead and get both of these installed and set up within our package.

npm i -D mrm @adonisjs/mrm-preset

Once those are installed, go ahead and open your package within your text editor of choice. Let’s jump into the package.json file and add an additional script for MRM.

"scripts": {
  "mrm": "mrm --preset=@adonisjs/mrm-preset",
  "test": "echo \\"Error: no test specified\\" && exit 1"
}

With that added, we can now run any of the tasks defined within the AdonisJS MRM Preset.

Running MRM Tasks

Now that we have MRM and the AdonisJS MRM Preset setup within our project, let’s go ahead and make use of it by getting what we need for our package set up.

npm run mrm init 

# ❯ Enter git origin url: https://github.com/adocasts/adonisjs-honeypot.git
# ❯ Select the minimum node version your package will support: 16.13.1
# ❯ Is it a package written by the AdonisJS core team: No
# ❯ Automatically generate TOC for the README file: No
# ❯ Select project license: MIT
# ❯ Select CI services...:
# ❯ Select probot applications...:

Once you run through the questions, this command will go ahead and initialize the Git repository for you within your directory and set up your origin with Git as well.

Next, let’s go ahead and run a few more tasks.

npm run mrm gitignore # will generate a default .gitignore file
npm run mrm license # will generate our MIT license file
npm run mrm package # will add testing capabilities, scripts, and more

Defining AdonisJS Dependencies & Types

Before we start writing the code for our package, we’ll need to define what packages from AdonisJS our package will need. For our Honeypot package this’ll be core, view, and optionally session.

First let’s add them as Peer Dependencies within our package.json. We’ll use peerDependenciesMeta to specify session is optional.

"peerDependencies": {
  "@adonisjs/core": "^5.1.0",
  "@adonisjs/session": "^6.0.0",
  "@adonisjs/view": "^6.0.0"
},
"peerDependenciesMeta": {
  "@adonisjs/session": {
    "optional": true
  }
},

Next, let’s copy those peerDependencies over to our package devDependencies as well.

"devDependencies": {
  "@adonisjs/core": "^5.4.2",
  "@adonisjs/session": "^6.1.2",
  "@adonisjs/view": "^6.1.1",

  "...": "... other packages"
},

We’ll also want to add their types to our TypeScript config as well within our tsconfig.json.

{
  "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig",
  "compilerOptions": {
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "types": [
      "@types/node",
      "@adonisjs/core",
      "@adonisjs/session",
      "@adonisjs/view"
    ]
  }
}

While we’re in here go ahead and add skipLibCheck, experimentalDecorators, and emitDecoratorMetadata as well. Skipping the lib check will prevent the core package from throwing errors when we try to build. Then, we’re essentially turning on decorators with the two decorator flags, which will allow us to utilize the @inject decorator.

Adding Our Provider

We’re now ready to start writing our package’s code! So, let’s get started by creating our provider file at /providers/HoneypotProvider.ts. Here we’re defining our package’s IoC Namespace as Adocasts/Honeypot, which’ll return our middleware; we’ll create that in a minute. We’ll also add our global Honeypot component to Edge.

import { ApplicationContract } from '@ioc:Adonis/Core/Application'

export default class HoneypotProvider {
  public static needsApplication = true

  constructor (protected app: ApplicationContract) {}

  public register () {
    // Register your own bindings
    this.app.container.singleton('Adocasts/Honeypot', () => {
      // import middleware
      // return middleware
    })
  }

  public async boot () {
    // IoC container is ready
    // register Honeypot component to Edge
  }
}

Creating Our Config

Before we begin writing our middleware, let’s go ahead and get our package’s configuration defined. Now, unlike everything else with our package, we’ll actually want our configuration file moved into the user’s project so they can make changes as needed.

We can easily do this by placing it inside a templates directory. This directory is used to copy files from our package into the user’s project. So, let’s create this folder along with a honeypot.txt file. Note the .txt there, we’ll need to define it as a txt file in our package. AdonisJS, when copying it to the user’s project will then convert it to TypeScript for us.

When writing your configuration file, remember to write it as though you’re in the user’s project and not within your package!

So, let’s go ahead and define this at /templates/honeypot.txt.

/**
 * Config source: <https://github.com/jagr-co/adonisjs-honeypot/blob/main/templates/honeypot.txt>
 *
 * Feel free to let us know via PR, if you find something broken in this config
 * file.
 */

import { HoneypotConfig } from '@ioc:Adocasts/Honeypot'

/*
|--------------------------------------------------------------------------
| Honeypot
|--------------------------------------------------------------------------
|
| Bots run through forms filling out each visible field. This honeypot
| traps those bots and prevents their request from being fulfilled.
| Bots tend to check for simple hidden fields like type=hidden, classes
| like "hidden", and the inline-style display: none. This plugin will
| instead use CSS clamping to hide the customizable list of fields you
| define below. Just be sure your fields below won't collide with any
| actual form fields your application will need.
*/
export const honeypotConfig: HoneypotConfig = {
  /*
  |--------------------------------------------------------------------------
  | Fields
  |--------------------------------------------------------------------------
  |
  | List of fields that will be added to your form when using the
  | honeypot component. The more realistic the field name, the more
  | likely it is to work.
  |
  */
  fields: ['ohbother', 'ohpiglet', 'ohpoo', 'firstName', 'lastName'],

  /*
  |--------------------------------------------------------------------------
  | Display flash message on failure
  |--------------------------------------------------------------------------
  |
  | When true, a flash message will be added to the session when the
  | honeypot fails due to the hidden fields being filled out. In order for
  | a flash message to be added, `flashMessage` and `flashKey` must have a
  | value.
  |
  */
  flashOnFailure: true,

  /*
  |--------------------------------------------------------------------------
  | Flash message displayed on failure
  |--------------------------------------------------------------------------
  */
  flashMessage: 'Our system flagged you as a bot',

  /*
  |--------------------------------------------------------------------------
  | Key used for flash message on failure
  |--------------------------------------------------------------------------
  */
  flashKey: 'error',

  /*
  |--------------------------------------------------------------------------
  | Redirect the user on failure
  |--------------------------------------------------------------------------
  |
  | When true, the user will be redirect to your defined redirectTo path.
  | When false, or when redirectTo is null or empty, an error will be thrown
  | which will prevent the bot form landing back at your form page and
  | resubmitting.
  |
  */
  redirectOnFailure: false,
  redirectTo: null
}

Creating Package Exceptions

Let’s also go ahead and create the two exceptions we’ll use within our middleware whenever issues are found. We’ll have one when the honeypot has been filled out and another for when the honeypot fields are missing.

First, let’s create a folder called src within our package root. Within here, let’s create another folder called Exceptions, where both our exceptions will reside.

Then, let’s create our HoneypotFailureException.ts file:

import { Exception } from '@poppinss/utils'

export class HoneypotFailureException extends Exception {
  public static invoke () {
    return new this('Honeypot Validation Failed', 403, 'E_HONEYPOT_FAILURE')
  }
}

We’ll import Exception from @poppinss/utils, which I believe comes with the AdonisJS Core. From there, define a static invoke method that’ll call and return our exception.

That takes care of our exception for when the honeypot fails, now let’s do the same for missing fields by creating our NoHoneypotFieldsFoundException.ts file:

import { Exception } from '@poppinss/utils'

export class NoHoneypotFieldsFoundException extends Exception {
  public static invoke () {
    return new this(
      'All honeypot fields were missing from your form. Did you forget to add the honeypot component?',
      403,
      'E_NO_HONEYPOT_FIELDS_FOUND'
    )
  }
}

Creating Our Middleware

Next, let’s go ahead and create the middleware we’ll export out of our package’s IoC Namespace. This middleware will be in charge of checking whether any honeypot fields have been filled in; meaning a bot submitted the form. We’ll also add another check to see if all honeypot fields are missing. If they are, we’ll log a warning to the dev that they may be missing the honeypot component within their form.

Initial Middleware File

import { ApplicationContract } from '@ioc:Adonis/Core/Application'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { inject } from '@adonisjs/core/build/standalone'

@inject(['Adonis/Core/Application'])
export class HoneypotMiddleware {
  constructor (private app: ApplicationContract) {}

  public async handle ({ request, response, session, logger }: HttpContextContract, next: () => Promise<void>) {
    await next()
  }
}

Here we have our initial middleware file. For the most part it’s just like any other middleware. However, we are injecting the Adonis/Core/Application namespace into our class. We’re then using that injection to provide the app to our class instance. This will allow us to grab our package’s config.

Importing Our Config

We haven’t created our config file yet, we’ll do that after we get our middleware setup, but let’s go ahead and get the import ready using our app.

import { ApplicationContract } from '@ioc:Adonis/Core/Application'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { inject } from '@adonisjs/core/build/standalone'

@inject(['Adonis/Core/Application'])
export class HoneypotMiddleware {
  // 👇
  private config = this.app.container.resolveBinding('Adonis/Core/Config').get('honeypot.honeypotConfig')

  constructor (private app: ApplicationContract) {}

  public async handle ({ request, response, session, logger }: HttpContextContract, next: () => Promise<void>) {
    await next()
  }
}

Here we’re resolving the namespace Adonis/Core/Config so that we can use the Config object to get our honeypot.honeypotConfig. The initial honeypot specifies the file to look up inside the config directory. Then, honeypotConfig specifies the exported name to grab.

Writing Our Middleware

Now that we have everything we need, let’s finish up our middleware by defining our checks. Remember, just like any middleware the handle method is the entry point here and in order to allow the code to continue onward, we’ll need to call next() once we’ve verified everything we need to within our middleware.

import { ApplicationContract } from '@ioc:Adonis/Core/Application'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { HoneypotFailureException } from '../Exceptions/HoneypotFailureException'
import { NoHoneypotFieldsFoundException } from '../Exceptions/NoHoneypotFieldsFoundException'
import { inject } from '@adonisjs/core/build/standalone'

@inject(['Adonis/Core/Application'])
export class HoneypotMiddleware {
  private config = this.app.container.resolveBinding('Adonis/Core/Config').get('honeypot.honeypotConfig')

  private states = {
    VALID: 1, // all good
    INVALID: 2, // honeypot field had a value
    MISSING: 3, // no honeypot fields found
  }

  private supportedMethods = ['POST', 'PUT', 'PATCH']

  constructor (private app: ApplicationContract) {}

  private validateFields (values: { [p: string]: any }) {
    let state = this.states.VALID

    if (Object.keys(values).length === 0) {
      return this.states.MISSING
    }

    for (let key in values) {
      if (values[key]) {
        state = this.states.INVALID
      }
    }

    return state
  }

  public async handle ({ request, response, session, logger }: HttpContextContract, next: () => Promise<void>) {
    if (!this.supportedMethods.includes(request.method())) {
      logger.warn(`[adonisjs-honeypot] Provided Http Method "${request.method()}" is not supported.`)
      return next()
    }

    const honeyValues = request.only(this.config.fields)
    const state = this.validateFields(honeyValues)

    // no honeypot fields found
    if (state === this.states.MISSING) {
      throw NoHoneypotFieldsFoundException.invoke()
    }

    // honeypot field contained value
    if (state === this.states.INVALID) {
      if (this.config.flashOnFailure && this.config.flashMessage && this.config.flashKey) {
        session.flash(this.config.flashKey, this.config.flashMessage)
      }

      if (!this.config.redirectOnFailure || !this.config.redirectTo) {
        throw HoneypotFailureException.invoke()
      }

      return response.redirect(this.config.redirectTo)
    }

    // all good, continue
    await next()
  }
}

So, let’s walk through our handle method here.

  1. First, we’re checking if the Http Method is supported. If not, we’ll log a warning for the developer and then return next() to skip the middleware check.

  2. Then, we’re grabbing just the honeypot fields out of the request body and validating those fields to see

    1. If we have fields passed through (missing)

    2. If any were filled out (invalid)

  3. Lastly, we’re checking the returned state from our validation check and handling any issues as defined by the developer within our config file.

Finishing Our Provider

Now that we have our middleware completed, we can go ahead and finish up our provider as well.

import { ApplicationContract } from '@ioc:Adonis/Core/Application'

export default class HoneypotProvider {
  public static needsApplication = true

  constructor (protected app: ApplicationContract) {}

  public register () {
    // Register your own bindings
    this.app.container.singleton('Adocasts/Honeypot', () => {
      // 👇 import our middleware and return it as the main value of our namespace
      const { HoneypotMiddleware } = require('../src/Middleware/HoneypotMiddleware')
      return HoneypotMiddleware
    })
  }

  public async boot () {
    // IoC container is ready
    // 👇 import view and our config & create the honeypot form fields
    const View = this.app.container.resolveBinding('Adonis/Core/View')
    const honeypotConfig = this.app.container.resolveBinding('Adonis/Core/Config').get('honeypot.honeypotConfig')
    const fieldTemplate = honeypotConfig.fields.map(f => `<input type="text" class="ohbother" name="${f}" />`).join('')

    // 👇 register our component to Edge
    View.registerTemplate('honeypot', {
      template: `
        <style>
          .ohbother {
            opacity: 0;
            position: absolute;
            top: 0;
            left: 0;
            height: 0;
            width: 0;
            z-index: -1;
          }
        </style>
        ${fieldTemplate}
      `,
    })
  }
}

First, we’re importing our middleware within our IoC Namespace singleton and returning it back out of it so that it’s the main value of our IoC Namespace.

Then, we’re using View to register a global component called honeypot so that it can be used anywhere with AdonisJS Edge. This is looping over all field definitions in the developer’s config and creating a text input with them.

Remember, we need these fields to be hidden from real users but visible to bots. Your first thought might be to use a type of hidden or a style tag on the field, but bots tend to watch out for those. So, we’ll add a style tag with a class to hide our fields using CSS as that’s more difficult for bots to detect.

Defining Our Package’s TypeScript Types

We’re almost done! Let’s get the TypeScript types defined for our package. For this, let’s create a folder called adonis-typings, and within it create a honeypot.ts file.

Within here we’ll want to declare a module on our namespace and define all the types for our namespace.

declare module '@ioc:Adocasts/Honeypot' {
  import { ApplicationContract } from '@ioc:Adonis/Core/Application'
  import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

  export type HoneypotConfig = {
    fields: string[]
    flashOnFailure: boolean
    flashMessage: string | null
    flashKey: string | null
    redirectOnFailure: boolean
    redirectTo: string | null
  }

  export interface HoneypotMiddlewareContract {
    new (application: ApplicationContract): {
      handle(ctx: HttpContextContract, next: () => Promise<void>): any
    }
  }

  const HoneypotMiddleware: HoneypotMiddlewareContract
  export default HoneypotMiddleware
}

First, we’ll define and export the type for our HoneypotConfig. This will allow us to import it within our config file to define the properties and types our config expects.

Then, we’ll export a contract interface for our middleware. Since we’re injecting our ApplicationContract, we’re specifying that for the constructor. Then, we’re defining the expectations for our handle method.

Lastly, we’ll define the property HoneypotMiddleware and set that as the default export. So, in total we’re describing the following potential imports for our namespace:

import HoneypotMiddleware, { HoneypotConfig } from '@ioc:Adocasts/Honeypot'

Where HoneypotMiddleware is the actual middleware and HoneypotConfig is our type definition for our config.

Lastly, let’s create and index.ts file within our adonis-typings folder that’ll reference our honeypot.ts typings.

/// <reference path="./honeypot.ts" />

And yes, the /// is intentionally included. You can think of this line as “importing” our honeypot.ts typings.

Defining Our Package’s Instructions

We have the option to display instructions to the developer after they configure our package within their project. These are typically to give the developer a heads up on what to do next. In our case, we’ll want to inform them to register the named middleware for use on their routes.

So, in the root of our project, let’s add an instructions.md file. You can write any valid Markdown in here.

The Adocasts package `@adocasts/adonisjs-honeypot` has been successfully configured. Before you begin, please register the below named middleware inside your `start/kernel.ts` file.

```ts
Server.middleware.registerNamed({
  honeypot: () => import('@ioc:Adocasts/Honeypot') // 👈
})

CopyFiles, Moving Our Instructions & Templates to Build

Second to last step, if we were to build our project now, the instructions.md file and templates directory would be omitted. We’ll want to install a package called copyfiles and add a command to our package.json build command to move these files over.

So, first, let’s install the copyfiles package.

npm i copyfiles

Then, add in the additional script commands.

"scripts": {
  "...": "... omitting other scripts not changed",
  "compile": "npm run lint && npm run clean && tsc && npm run copy:files",
  "copy:files": "copyfiles \\"templates/**/*.txt\\" build && npm run copy:instructions_md",
  "copy:instructions_md": "copyfiles \\"instructions.md\\" build"
},
  • compile
    We’re adding to the end && npm run copy:files to execute the copy:files command post-build.

  • copy:files
    This will move over our templates directory and it’s files to the build directory, then call copy:instructions_md to move that over as well.

  • copy:instructions_md
    This will move our instructions.md file over to the root of the build directory

Making Our Package Configurable

The last thing to do is to make our package configurable, making it easier to install! This is where we can automatically register our providers and types, move our config into the developer’s project, and display our instructions to the developer.

To do this, we can add an “adonisjs” property to our package.json.

"adonisjs": {
  "instructionsMd": "./build/instructions.md",
  "types": "@adocasts.com/adonisjs-honeypot",
  "providers": [
    "@adocasts.com/adonisjs-honeypot"
  ],
  "templates": {
    "config": [
      "honeypot.txt"
    ]
  }
},
  • instructionsMd
    For this, we’ll want to point to the build path for our instructions file. Providing this will inform AdonisJS that we have instructions to display after our package is configured.

  • types
    Here we’ll want to provide the value that should be added to the tsconfig.json types array. Which, in this case, is our package’s name.

  • providers
    The same thing as types here, we want to provide what should be added to our .adonisrc.json providers array. Again, this is our package’s name.

  • templates
    For this, we’ll want to provide an object. The key of the object is the folder within the developer’s project the template should be added to. The value of the key should be an array of the file from our project that should be moved there. So, we’ll be moving honeypot.txt to our config file. Adonis will then map that config holds .ts files and change our honeypot.txt to honeypot.ts.

Wrapping Up

With that, our package should be ready for usage! We’ll be able to submit it to the NPM registry, and install it using @adocasts.com/adonisjs-honeypot, and configure it within our project using node ace configure @adocasts.com/adonisjs-honeypot.

Bloopers

A written blooper? Yep! So, you might be wondering why I’m using @adocasts.com/package-name instead of just @adocasts/package-name. Well… I’d love to, but I did an oopsie. I previously had an NPM user called adocasts. Well, I thought “shoot, this should probably be an organization”. So I deleted the user and went to go create the organization. The user didn’t have any packages tied to it so I didn’t second guess anything. Well turns out NPM locks usernames once any account, even those that have been deleted, has claimed it. So, since I deleted the adocasts account, I can no longer reclaim it. Hense the @adocasts.com username.

Also, in case you’re wondering, yes there is an option to turn a user account into an organization. I failed to see that until after I had deleted my adocasts account.

Life goes on and lessons are learned.

Join The Discussion! (2 Comments)

Please sign in or sign up for free to join in on the dicussion.

  1. Anonymous (SpiderFelicia85)
    Commented 1 year ago

    Nice job fellows

    1

    Please sign in or sign up for free to reply

    1. Commented 1 year ago

      Thank you!! 😊

      0

      Please sign in or sign up for free to reply

Playing Next Lesson In
seconds