Skip to content Skip to navigation

Express.js

An example of how you can build a search API endpoint using Boox and Express.js:

Project setup

Create a new boox-express directory and run:

cd boox-express && npm init -y
BASH

Install Boox and other dependencies:

npm install -D boox express pako metaphone stemmer stopword
BASH

File structure

boox-express
├── public
│   ├── index.html
│   └── main.js
├── app.js
├── boox.config.js
└── pokemon-100r-trained.gz
TEXT

Did you know?

You can remove unnecessary files from the default Vite installation.

Code snippets

Kindly replicate the code snippets provided below:

/**
 * @typedef {import('./types').Pokemon} Pokemon
 */

import fs from 'fs'
import Boox from 'boox'
import express from 'express'
import { ungzip } from 'pako'
import defineBooxConfig from './boox.config.js'

const PORT = process.env.PORT || 3000

/** @type {Boox<Pokemon>} */
const boox = new Boox(defineBooxConfig())

const trainedData = fs.readFileSync('pokemon-100r-trained.gz')
/** @type {import('boox').State<Pokemon>} */
const state = JSON.parse(ungzip(trainedData, { to: 'string' }))

boox.currentState = state

const totalDocuments = Object.keys(boox.currentState.documents).length
const app = express()

// Serve static files
app.use(express.static('public'))

// Search endpoint
app.get('/search', async (req, res) => {
  const { q = '' } = req.query
  const results = await boox.search(q)

  const formattedResults = results.map(result => {
    const { image_url, name: image_alt, hp } = result.attributes
    const { text: name } = result.context('name')
    const { text: set_name } = result.context('set_name')
    const { text: caption } = result.context('caption', 80)

    return { image_url, image_alt, hp, name, set_name, caption }
  })

  res.json({
    totalDocuments,
    totalResults: results.length,
    results: formattedResults
  })
})

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`)
})
JS
import { metaphone } from 'metaphone'
import { stemmer } from 'stemmer'
import { removeStopwords } from 'stopword'

const wordRegexp = /\b\w+\b/g

/** @type {() => import('boox').BooxOptions<import('./types').Pokemon>} */
export default function defineBooxConfig() {
  return {
    features: ['name', 'set_name', 'caption'],
    attributes: ['image_url', 'hp'],
    modelOptions: {
      tokenizer(input) {
        const tokens = Array.from(input.match(wordRegexp) || [])
        return removeStopwords(tokens)
      },
      stemmer: stemmer,
      phonetic: metaphone
    }
  }
}
JS
/**
 * A Pokemon data object.
 */
export interface Pokemon {
  image_url: string
  image_alt: string
  hp: number
  name: string
  set_name: string
  caption: string
}

/**
 * The response data from a search query.
 */
export interface ResponseData {
  totalDocuments: number
  totalResults: number
  results: Pokemon[]
}
JS
<!doctype html>
<html lang="en" data-bs-theme="dark">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Express.js example - Boox</title>

    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
      crossorigin="anonymous"
    />
  </head>

  <body class="vstack min-vh-100 bg-body-secondary">
    <main
      class="mx-auto my-4 bg-body w-100 rounded-3 shadow"
      style="max-width: 30rem"
    >
      <form id="search-form" class="p-3" role="search">
        <div class="form-group">
          <input
            type="search"
            class="form-control form-control-lg"
            id="search-query"
            placeholder="Search (e.g. pikachu, water pwer)"
            aria-label="Search pokemons"
            autofocus
          />

          <div class="form-text">
            <strong>Note:</strong>
            the
            <code class="bg-body-secondary">pwer</code>
            demonstrates a typing error for
            <code class="bg-body-secondary">power</code>
            .
          </div>
        </div>
      </form>

      <p id="results-length" class="d-none px-3 text-muted"></p>

      <div
        id="results"
        class="list-group list-group-flush overflow-auto rounded-3 d-none"
      ></div>
    </main>

    <footer class="container mt-auto py-3">
      <p class="d-flex justify-content-center text-muted">
        Search by
        <a
          class="icon-link ms-2"
          href="https://github.com/bent10/boox"
          target="_blank"
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 180 180"
            height="28"
            aria-hidden="true"
          >
            <g fill="none" fill-rule="nonzero">
              <path
                fill="currentColor"
                d="M111.223 75.983 91.937 66.34a3.214 3.214 0 0 0-2.877 0l-19.286 9.643A3.214 3.214 0 0 0 68 78.859v22.5c0 1.219.688 2.333 1.778 2.877l19.285 9.643a3.214 3.214 0 0 0 2.874 0l19.286-9.643A3.214 3.214 0 0 0 113 101.36v-22.5a3.214 3.214 0 0 0-1.777-2.876ZM90.5 72.81l12.099 6.05L90.5 84.908l-12.099-6.05L90.5 72.81ZM74.429 84.06l12.857 6.429v15.313l-12.857-6.429V84.06Zm19.285 21.742V90.489l12.857-6.429v15.313l-12.857 6.429Z"
              ></path>
              <path
                fill="var(--bs-secondary-color)"
                d="M180 90c0-19.343-18-36.203-44.692-45.307C126.203 18 109.343 0 90 0 61.005 0 37.5 40.297 37.5 90c-.01 9.195.825 18.371 2.498 27.412C24.323 110.063 15 99.75 15 90a23.678 23.678 0 0 1 8.288-16.335A154.035 154.035 0 0 1 27 52.552C10.343 62.079 0 75.33 0 90c0 19.343 18 36.203 44.693 45.308C53.797 162 70.657 180 90 180c14.67 0 27.922-10.343 37.448-27a154.035 154.035 0 0 1-21.113 3.683A23.677 23.677 0 0 1 90 165c-9.75 0-20.063-9.322-27.413-24.998A149.798 149.798 0 0 0 90 142.5c49.702 0 90-23.505 90-52.5ZM90 15c9.75 0 20.063 9.323 27.412 24.998A149.798 149.798 0 0 0 90 37.5c-3.06 0-6.08.09-9.06.27a64.89 64.89 0 0 0-7.77 15.75A140.557 140.557 0 0 1 90 52.5a131.4 131.4 0 0 1 33.345 4.155A131.4 131.4 0 0 1 127.5 90c0 5.625-.339 11.245-1.013 16.83a64.89 64.89 0 0 0 15.75-7.77c.175-2.985.263-6.005.263-9.06.01-9.195-.825-18.371-2.498-27.413C155.678 69.938 165 80.25 165 90c0 17.737-30.795 37.5-75 37.5a131.4 131.4 0 0 1-33.345-4.155A131.4 131.4 0 0 1 52.5 90c0-44.205 19.763-75 37.5-75Z"
              ></path>
            </g>
          </svg>

          <span class="fw-bold text-body">Boox</span>
        </a>
      </p>
    </footer>

    <script type="module" src="main.js"></script>
  </body>
</html>
HTML
/**
 * @typedef {import('../types').Pokemon} Pokemon
 * @typedef {import('../types').ResponseData} ResponseData
 */

const searchQueryInput = document.querySelector('#search-query')
const resultsList = document.querySelector('#results')
const resultsLength = document.querySelector('#results-length')

/**
 * Performs a search with the current query and updates the UI.
 */
const performSearch = debounce(async () => {
  /** @type {string} */
  const query = searchQueryInput?.value || ''

  const response = await fetch(`/search?q=${query}`, { method: 'GET' })
  /** @type {ResponseData} */
  const responseData = await response.json()

  updateUI(responseData)
}, 300)

/**
 * Updates the UI with the given response data.
 *
 * @param {ResponseData} data
 */
function updateUI({ totalDocuments, totalResults, results }) {
  resultsList?.classList.toggle('d-none', !totalResults)

  if (resultsLength) {
    resultsLength.classList.toggle('d-none', !totalResults)
    resultsLength.textContent = totalResults
      ? `Found ${totalResults} of ${totalDocuments} pokemons`
      : ''
  }

  displayResults(results)
}

/**
 * Displays the search results in the results list.
 *
 * @param {Pokemon[]} results - The array of Pokemon objects.
 */
function displayResults(results) {
  if (!resultsList) return

  resultsList.innerHTML = ''

  results.forEach(result => {
    const { image_url, image_alt, hp, name, set_name, caption } = result

    const resultElement = document.createElement('a')
    resultElement.classList.add(
      'list-group-item',
      'list-group-item-action',
      'd-flex',
      'gap-3',
      'py-3'
    )
    resultElement.href = image_url
    resultElement.target = '_blank'
    resultElement.innerHTML = `<img loading='lazy' src="${image_url}" alt="${image_alt}" width="32" height="32" class="rounded-circle flex-shrink-0">
      <div class="d-flex gap-2 w-100 justify-content-between">
        <div>
          <h6 class="mb-1">${name} – ${set_name}</h6>
          <p class="mb-0 opacity-75">${caption}...</p>
        </div>
        <small class="opacity-50 text-nowrap">HP ${hp}</small>
      </div>`
    resultsList.appendChild(resultElement)
  })
}

/**
 * Debounces a function, ensuring it is not called more frequently than the specified delay.
 *
 * @param {Function} func - The function to debounce.
 * @param {number} delay - The delay in milliseconds.
 * @returns {Function} - The debounced function.
 */
function debounce(func, delay) {
  let timeoutId

  return function (...args) {
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    const context = this

    clearTimeout(timeoutId)

    timeoutId = setTimeout(() => {
      func.apply(context, args)
    }, delay)
  }
}

searchQueryInput?.addEventListener('input', performSearch)
JS

Running the app

Start the Express server:

node app.js
BASH

Open http://localhost:3000 in your browser to access the search interface.

Heads up

Remember to consult the documentation for Boox and other libraries for more advanced usage and configuration options.