Skip to main content

Command Palette

Search for a command to run...

Concurrently Run Hookdeck (Or any process) with Nuxt / Vite App

Updated
5 min read

Hookdeck is a convenient and easy-to-use webhook infrastructure that can also be easily used for local development as a replacement for ngrok. However, during local development, if you need to frequently receive hooks, you must start two Terminal sessions to run both the Nuxt/Vite App and Hookdeck separately.

In one terminal:

nuxt dev

Another terminal:


hookdeck listen 3000 {source}

The downside of this is that when port 3000 is occupied, Nuxt will fall back to 3001, 3002, and so on, using different ports. This can easily confuse engineers who need to frequently switch environments about which Hookdeck tunnel they are currently using.

Solutions 1: Use “concurrently”

NPM has a package called concurrently that allows us to run nuxt dev and hookdeck in parallel with a single command. When you stop the process, both threads will terminate at the same time, preventing confusion with multiple terminals.

Installation:

npm install concurrently

Change the dev command (if using Vite, use vite dev)

  "scripts": {
    ...
-    "dev": "nuxt dev",
+    "dev": "concurrently --names hook,nuxt 'hookdeck listen 3000 xxx' 'nuxt dev'",

Run:

npm run dev

This way, you can run two processes in parallel.

[hook] Dashboard
[hook] 👉 Inspect and replay events: https://dashboard.hookdeck.com?team_id=xxxxxxxxxxxx
[hook] 
[hook] Sources
[hook] 🔌 xxx URL: https://hkdk.events/xxxxxxxxxxxx
[hook] 
[hook] Connections
[hook] xxx -> xxx forwarding to /api/.../
[hook] 
[hook] Getting ready...
[nuxt] [nuxi] Nuxt 3.20.1 (with Nitro 2.12.9, Vite 7.2.2 and Vue 3.5.24)
[nuxt] 
[nuxt]   ➜ Local:    http://localhost:3000/
[nuxt]   ➜ Network:  use --host to expose
[nuxt] 
[nuxt]   ➜ DevTools: press Shift + Option + D in the browser (v3.1.0)

However, the downside of this approach is that we still can't make Hookdeck automatically detect the Nuxt dev server's port. We can only use port 3000 by default. A compromise is to specify the port each time we run it, so we tried modifying the command.

"dev": "concurrently --names hook,nuxt 'hookdeck listen ${PORT:-3000} rankwing' 'nuxt dev --port ${PORT:-3000}'"

Then you can customize the port when running.

# Use default 3000 port
npm run dev

# Use 3001 port
PORT=3001 npm run dev

Solutions 2: Custom Vite Plugin

The main drawback of the above solution is that it still can't automatically detect the Nuxt/Vite port. We tried using a Vite plugin to automatically use the current port when the Nuxt/Vite dev server starts.

What this plugin does is quite simple. It checks if there is a hookdeck.xxxx.pid stored in a certain tmp directory. If there is a pid, then no matter how many times the Nuxt dev server restarts, it won't repeatedly create a hookdeck process, preventing memory leaks or Zombie processes.

Note that this hookdeck.xxxx.pid must be stored in a tmp directory. If you are using a simple Vite project, please create a tmp directory to store it.

// plugins/hookdeck.ts
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { type Plugin } from 'vite';

export interface HookdeckOptions {
  hookdeckBinary?: string;
  source?: string;
  port?: number;
  pidFile?: string;
  urlMap?: Record<string, string>;
  enabled?: boolean;
}

export default function hookdeck(options: HookdeckOptions = {}): Plugin {
  return {
    name: 'hookdeck-concurrently',
    async configureServer(server) {
      const enabled = options.enabled ?? process.env.NUXT_HOOKDECK_ENABLED === '1';

      if (!enabled) {
        return;
      }

      const port = (server.httpServer?.address() as any)?.port ?? options.port ?? 3000;
      // NOTE: In vite, you must create a tmp folder and exclude it by .gitignore
      const pidFile = options.pidFile ?? `./.nuxt/hookdeck.${port}.pid`;
      const source = options.source || '';
      const hookdeckBinary = options.hookdeckBinary || 'hookdeck';

      if (!source) {
        console.warn('[vite hookdeck] No source provided, skipping hookdeck setup.');
        return;
      }

      if (fs.existsSync(pidFile)) {
        const oldPid = fs.readFileSync(pidFile, { encoding: 'utf-8' });

        try {
          process.kill(Number(oldPid), 0);
          console.log(`[vite hookdeck] Hookdeck is already running with PID: ${oldPid}`);
          return;
        } catch (e) {
          console.log(`[vite hookdeck] Old hookdeck process not found, starting a new one...`);
        }
      }

      console.log(`[vite hookdeck] Starting hookdeck on port ${port}`)

      const child = spawn(hookdeckBinary, ['listen', String(port), source], {
        shell: true, // Windows must add this
      });

      child.stdout.on('data', function (data) {
        console.log(data.toString().trim());
        const urlMap = options.urlMap;
        const matchProxy = data.toString().match(/URL: https:\/\/(hkdk\.events\/([a-zA-Z0-9]+))/);
        const matchDest = data.toString().match(/forwarding to ([a-zA-Z0-9_\-\/\:]+)/);

        if (urlMap && matchProxy && matchDest) {
          const url = matchProxy[1];
          const path = matchDest[1];
          const data: Record<string, string>[] = [];

          for (const [key, value] of Object.entries(urlMap)) {
            const endpoint = 'https://' + `${url}/${value}`.replace(/\/\//g, '/');
            const dest = 'http://' + `localhost:${port}${path}/${value}`.replace(/\/\//g, '/');

            // console.log(`  [${key}] ${endpoint} => ${dest}`);
            data.push({
              Name: key,
              Hook: endpoint,
              Dest: dest
            });
          }

          console.log('');
          console.log('HOOKDECK URL MAPPING:');
          console.table(data);
        }
      });

      fs.writeFileSync(pidFile, child.pid?.toString() || '', { encoding: 'utf-8' });

      child.on('error', (err) => {
        console.error('[vite hookdeck error]', err)
      })

      let exit = () => {
        console.log('[vite hookdeck] Shutting down hookdeck...')
        child?.kill('SIGTERM')
        fs.unlinkSync(pidFile);
      };

      server.httpServer?.on('close', exit);

      process.on('SIGINT', exit);
      process.on('SIGTERM', exit);
      process.on('exit', exit);
    }
  }
};

Register in nuxt.config.ts :

  vite: {
    plugins: [
      hookdeck({
        source: import.meta.env.NUXT_HOOKDECK_SOURCE || '{your hookdeck source}',
        urlMap: {
          Clerk: 'hook/clerk',
          Paddle: 'hook/paddle',
        },
        enabled: import.meta.env.NUXT_HOOKDECK_ENABLED === '1',
      }),
    ],

Register in vite.config.ts

    plugins: [
      hookdeck({
        source: import.meta.env.NUXT_HOOKDECK_SOURCE || '{your hookdeck source}',
        urlMap: {
          Clerk: 'hook/clerk',
          Paddle: 'hook/paddle',
        },
        enabled: import.meta.env.NUXT_HOOKDECK_ENABLED === '1',
      }),
    ],

When we start the dev server, we will see the following screen:

Connections
xxx -> xxx forwarding to /api/
                                                                                                                                                                                            4:07:08 PM
HOOKDECK URL MAPPING:                                                                                                                                                                       4:07:08 PM
┌─────────┬──────────┬──────────────────────────────────────────────────┬─────────────────────────────────────────────────┐                                                                 4:07:08 PM
│ (index) │ Name     │ Hook                                             │ Dest                                            │
├─────────┼──────────┼──────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
│ 0       │ 'Clerk''https://hkdk.events/xxxxxxxxxxxx/hook/clerk''http://localhost:3000/api/hook/clerk'  │
│ 1       │ 'Paddle''https://hkdk.events/xxxxxxxxxxxx/hook/paddle''http://localhost:3000/api/hook/paddle' │
└─────────┴──────────┴──────────────────────────────────────────────────┴─────────────────────────────────────────────────┘
✔ Nuxt Nitro server built in 614ms                                                                                                                                                   nitro 4:07:08 PM
ℹ Vite server warmed up in 1ms                                                                                                                                                             4:07:08 PM
ℹ Vite client warmed up in 1ms

Since Nuxt restarts the Vite server repeatedly, this hookdeck will not be created multiple times; it will only be created once during runtime. Additionally, when you terminate the process using Ctrl + C, it will also be closed, preventing any memory leaks.

I put the plugin to Gist: https://gist.github.com/asika32764/1dc7fa9acb4dac6e31770b3e393ca972

Maybe I’ll convert it to NPM package after more projects tests.