VOOZH about

URL: https://dev.to/congar97/mastering-javascript-event-systems-build-a-production-grade-file-watcher-from-scratch-4kl6

⇱ Mastering JavaScript Event Systems: Build a Production-Grade File Watcher from Scratch - DEV Community


What You'll Learn

  • An EventEmitter maintains a map of event names to arrays of listener functions
  • on(event, fn) registers a listener; off(event, fn) removes it by reference
  • emit(event, ...args) synchronously calls every registered listener in registration order
  • once(event, fn) wraps fn in a self-removing wrapper so it fires exactly one time

Why it matters: EventEmitter is the backbone of Node.js streams, Express middleware, browser DOM events, and React's synthetic event system. Building one from scratch makes every async pattern click — you'll understand why removeEventListener requires the same function reference, why event order matters, and how memory leaks from undisposed listeners happen.

Listener Registry Pattern

The core data structure is a Map from event name to an array of listener functions. This gives O(1) lookup per event name and preserves insertion order for emission. All four public methods — on, off, emit, once — operate on this single Map.

// Listener Registry Pattern
type Listener = (...args: unknown[]) => void

class EventEmitter {
 private listeners = new Map<string, Listener[]>() // event name → fns array

 on(event: string, fn: Listener): this {
 const existing = this.listeners.get(event) ?? []
 this.listeners.set(event, [...existing, fn]) // append, don't mutate
 return this // fluent API
 }

 off(event: string, fn: Listener): this {
 const existing = this.listeners.get(event) ?? []
 this.listeners.set(event, existing.filter(l => l !== fn)) // reference equality
 return this
 }

 emit(event: string, ...args: unknown[]): boolean {
 const fns = this.listeners.get(event)
 if (!fns || fns.length === 0) return false
 fns.forEach(fn => fn(...args)) // fires all listeners in order
 return true
 }
}

The once() Wrapper Pattern

once() must call the listener exactly once then remove itself. The trick is a wrapper function that (1) calls the original fn, (2) calls this.off() with itself as the reference. The wrapper must be stored so off() can find it by reference — which means you need to associate the original fn with its wrapper.

// The once() Wrapper Pattern
// Naive implementation — works but can't be removed early
once(event: string, fn: Listener): this {
 const wrapper: Listener = (...args) => {
 fn(...args)
 this.off(event, wrapper) // self-removing
 }
 return this.on(event, wrapper) // registers wrapper, not fn
}

// Problem: ee.off('data', originalFn) won't work because
// we registered `wrapper`, not `fn`.
// Fix: track the wrapper → original mapping
private wrappers = new Map<Listener, Listener>() // bridges originalFn → wrapper

once(event: string, fn: Listener): this {
 const wrapper: Listener = (...args) => {
 fn(...args) // call the real handler
 this.off(event, wrapper)
 this.wrappers.delete(fn) // avoid memory leak
 }
 this.wrappers.set(fn, wrapper)
 return this.on(event, wrapper)
}

off(event: string, fn: Listener): this {
 // Check if fn has an associated wrapper
 const toRemove = this.wrappers.get(fn) ?? fn // resolve to internal wrapper
 const existing = this.listeners.get(event) ?? []
 this.listeners.set(event, existing.filter(l => l !== toRemove))
 return this
}

👁 Interactive Visualization

The Project: mini-nodemon

A production-grade CLI file watcher that uses your EventEmitter as its event bus — detecting file system changes with glob pattern matching and automatically restarting a child process. The same architecture powering the real nodemon.

Here's what makes it interesting:

  • Glob pattern matching (src//.ts)*
  • Debounced restart with configurable delay
  • Colored console output with restart counter
  • .nodemonrc.json config file support

Here's how the project is laid out:

mini-nodemon/
 ├── event-emitter.ts
 ├── glob-matcher.ts
 ├── file-watcher.ts
 ├── debounce.ts
 ├── process-runner.ts
 ├── config.ts
 ├── index.ts
 ├── integration.test.ts
├── package.json
├── tsconfig.json

Building It Step by Step

Step 1: Build the EventEmitter core

Implement the full EventEmitter — the event bus the whole project uses. Map-based listener registry, on/off/emit/once, removeAllListeners, listenerCount. This is the engine everything hooks into.

Working in src/core/event-emitter.ts

Input event: string, fn: (...args: unknown[]) => void
Output this (for chaining); listener stored in Map<string, Listener[]>
// Use Map<string, Listener[]>. The once() wrapper needs a wrappers Map so off(originalFn) can find the internal wrapper to remove it.
type Listener = (...args: unknown[]) => void

export class EventEmitter {
 private listeners = new Map<string, Listener[]>()
 private wrappers = new Map<Listener, Listener>()

 on(event: string, fn: Listener): this {
 const existing = this.listeners.get(event) ?? []
 this.listeners.set(event, [...existing, fn])
 return this
 }

 off(event: string, fn: Listener): this {
 const target = this.wrappers.get(fn) ?? fn
 const existing = this.listeners.get(event)
 if (!existing) return this
 const updated = existing.filter(l => l !== target)
 if (updated.length === 0) this.listeners.delete(event)
 else this.listeners.set(event, updated)
 this.wrappers.delete(fn)
 return this
 }

 emit(event: string, ...args: unknown[]): boolean {
 const fns = this.listeners.get(event)
 if (!fns || fns.length === 0) return false
 fns.slice().forEach(fn => fn(...args))
 return true
 }

 once(event: string, fn: Listener): this {
 const wrapper: Listener = (...args) => {
 fn(...args)
 this.off(event, wrapper)
 this.wrappers.delete(fn)
 }
 this.wrappers.set(fn, wrapper)
 return this.on(event, wrapper)
 }

 removeAllListeners(event?: string): this {
 if (event !== undefined) this.listeners.delete(event)
 else { this.listeners.clear(); this.wrappers.clear() }
 return this
 }

 listenerCount(event: string): number {
 return this.listeners.get(event)?.length ?? 0
 }
}

The EventEmitter is the event bus for the whole project. FileWatcher extends it to emit 'change'/'add'/'remove', ProcessRunner subscribes to those events to trigger restarts. The wrappers Map solves the once() removability problem — off(originalFn) resolves to the internal wrapper automatically.

Watch out for:

Snapshot with fns.slice() before iterating — once() wrappers remove themselves during emit which mutates the array

The wrappers Map bridges off(originalFn) → finds internal wrapper → removes it correctly

removeAllListeners() must also clear the wrappers map to avoid memory leaks

Test it: const ee = new EventEmitter(); ee.on('change', p => console.log(p)); ee.emit('change', 'src/index.ts') — should log the path

Step 2: Build GlobMatcher and FileWatcher

GlobMatcher converts patterns like src/**/*.ts into regex. FileWatcher uses Node's fs.watch recursively + GlobMatcher to filter paths, then emits 'change', 'add', 'remove' events on the EventEmitter bus.

Working in src/watcher/file-watcher.ts

Input patterns: string[] (e.g. ['src/**/*.ts']), dir: string
Output EventEmitter emitting 'change' with file path when a matching file changes
// Use `fs.watch(dir, { recursive: true })` to watch subdirectories. Raw fs.watch fires 2-3 times per save — you'll debounce this in the next step. For now, emit immediately and filter by glob.
// src/watcher/glob-matcher.ts
export function globToRegex(pattern: string): RegExp {
 const escaped = pattern
 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
 .replace(/\*\*/g, '<<<DOUBLE>>>')
 .replace(/\*/g, '[^/]*')
 .replace(/<<<DOUBLE>>>/g, '.*')
 .replace(/\?/g, '[^/]')
 return new RegExp(`^${escaped}$`)
}

export class GlobMatcher {
 private patterns: RegExp[]
 constructor(globs: string[]) {
 this.patterns = globs.map(globToRegex)
 }
 matches(filePath: string): boolean {
 return this.patterns.some(re => re.test(filePath))
 }
}

// src/watcher/file-watcher.ts
import fs from 'fs'
import path from 'path'
import { EventEmitter } from '../core/event-emitter'
import { GlobMatcher } from './glob-matcher'

interface FileWatcherOptions {
 patterns: string[]
 dir: string
}

export class FileWatcher extends EventEmitter {
 private matcher: GlobMatcher
 private dir: string
 private watcher: fs.FSWatcher | null = null

 constructor({ patterns, dir }: FileWatcherOptions) {
 super()
 this.matcher = new GlobMatcher(patterns)
 this.dir = path.resolve(dir)
 }

 async start(): Promise<void> {
 this.watcher = fs.watch(this.dir, { recursive: true }, (eventType, filename) => {
 if (!filename) return
 const rel = filename.replace(/\\/g, '/')
 if (this.matcher.matches(rel)) {
 this.emit('change', path.join(this.dir, rel))
 }
 })
 }

 async stop(): Promise<void> {
 this.watcher?.close()
 this.watcher = null
 }
}

FileWatcher extends EventEmitter directly — it IS the event bus for file events. GlobMatcher converts glob syntax to regex: ** matches any path segment depth, * matches within a segment. The { recursive: true } option on fs.watch handles deep directory watching natively.

Watch out for:

fs.watch fires multiple events per single file save — the debouncer in step 3 will fix this

filename on Windows uses backslashes — normalize with replace(/\\/g, '/') before matching

recursive option is not available on all Linux systems — add a fallback note in README

Test it: const fw = new FileWatcher({ patterns: ['**/*.ts'], dir: './src' }); fw.on('change', console.log); fw.start() — edit a .ts file and see the log

Step 3: Build Debounce utility and ProcessRunner

debounce(fn, ms) delays execution — prevents restart storms when fs.watch fires multiple times per save. ProcessRunner spawns a child process, listens to FileWatcher 'change' events, kills and respawns on each change, logs restarts with color and a restart counter.

Working in src/runner/process-runner.ts

Input command: string (e.g. 'node dist/index.js'), watcher: FileWatcher
Output Child process spawned; on watcher 'change' event: old process killed, new process spawned, restart count logged
// Use `child_process.spawn` with `shell: true` to run arbitrary commands. Kill with `child.kill('SIGTERM')`. Track a restart counter and log with ANSI colors: `\x1b[32m` for green, `\x1b[0m` to reset.
// src/runner/debounce.ts
export function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number): T {
 let timer: ReturnType<typeof setTimeout> | null = null
 return ((...args: unknown[]) => {
 if (timer) clearTimeout(timer)
 timer = setTimeout(() => { fn(...args); timer = null }, ms)
 }) as T
}

// src/runner/process-runner.ts
import { spawn, ChildProcess } from 'child_process'
import { debounce } from './debounce'
import type { FileWatcher } from '../watcher/file-watcher'

interface ProcessRunnerOptions {
 command: string
 watcher: FileWatcher
 delay?: number
}

export class ProcessRunner {
 private child: ChildProcess | null = null
 private restartCount = 0
 private command: string
 private watcher: FileWatcher
 private delay: number

 constructor({ command, watcher, delay = 300 }: ProcessRunnerOptions) {
 this.command = command
 this.watcher = watcher
 this.delay = delay
 }

 start(): void {
 this.spawn()
 const debouncedRestart = debounce(() => this.restart(), this.delay)
 this.watcher.on('change', (filePath: unknown) => {
 console.log(`\x1b[33m[mini-nodemon]\x1b[0m Change detected: ${filePath}`)
 debouncedRestart()
 })
 }

 private spawn(): void {
 this.child = spawn(this.command, { shell: true, stdio: 'inherit' })
 this.child.on('exit', (code) => {
 if (code !== null && code !== 0) {
 console.log(`\x1b[31m[mini-nodemon]\x1b[0m Process exited with code ${code}`)
 }
 })
 }

 private restart(): void {
 this.restartCount++
 console.log(`\x1b[32m[mini-nodemon]\x1b[0m Restarting... (restart #${this.restartCount})`)
 this.child?.kill('SIGTERM')
 this.spawn()
 }

 stop(): void {
 this.child?.kill('SIGTERM')
 this.watcher.stop()
 }
}

debounce prevents restart storms: fs.watch fires 2-3 times per save, but the debounced restart only fires once after the last event within the delay window. SIGTERM is the graceful shutdown signal — the child process can clean up before exiting.

Watch out for:

stdio: 'inherit' passes child process output directly to parent terminal — without this you won't see the command output

Kill SIGTERM before spawning — if you spawn first, you'll have two processes running simultaneously

The debounce delay (300ms default) should be configurable — different projects need different tolerances

Test it: const runner = new ProcessRunner({ command: 'echo hello', watcher }); runner.start() — should print 'hello', then reprint on each file change

Step 4: Build the CLI entry point and config loader

config.ts reads .nodemonrc.json (watch patterns, delay, ignore list). index.ts parses process.argv, merges with config file, wires FileWatcher + ProcessRunner together. Adds a bin field to package.json so users can run mini-nodemon globally.

Working in src/cli/index.ts

Input process.argv: ['node', 'mini-nodemon', './src', 'node dist/index.js', '--delay=500']
Output FileWatcher started on ./src, ProcessRunner running 'node dist/index.js', watching for changes
// Parse argv: `node mini-nodemon [watchDir] [command] [--delay=500]`. Read .nodemonrc.json with fs.existsSync + JSON.parse. The bin field in package.json: `{ "bin": { "mini-nodemon": "./dist/cli/index.js" } }`.
// src/cli/config.ts
import fs from 'fs'
import path from 'path'

interface NodaemonConfig {
 watch?: string[]
 patterns?: string[]
 delay?: number
 ignore?: string[]
}

export function loadConfig(cwd: string): NodaemonConfig {
 const configPath = path.join(cwd, '.nodemonrc.json')
 if (!fs.existsSync(configPath)) return {}
 try {
 return JSON.parse(fs.readFileSync(configPath, 'utf8')) as NodaemonConfig
 } catch {
 return {}
 }
}

// src/cli/index.ts
import { FileWatcher } from '../watcher/file-watcher'
import { ProcessRunner } from '../runner/process-runner'
import { loadConfig } from './config'

const args = process.argv.slice(2)
const watchDir = args[0] ?? './'
const command = args[1] ?? 'node index.js'
const delayArg = args.find(a => a.startsWith('--delay='))
const delay = delayArg ? parseInt(delayArg.split('=')[1]) : undefined

const config = loadConfig(process.cwd())

const patterns = config.patterns ?? ['**/*.ts', '**/*.js', '**/*.json']
const finalDelay = delay ?? config.delay ?? 300

const watcher = new FileWatcher({ patterns, dir: watchDir })
const runner = new ProcessRunner({ command, watcher, delay: finalDelay })

console.log(`\x1b[36m[mini-nodemon]\x1b[0m Watching ${watchDir} for changes...`)
console.log(`\x1b[36m[mini-nodemon]\x1b[0m Running: ${command}`)

watcher.start().then(() => runner.start())

process.on('SIGINT', () => {
 console.log('\n\x1b[33m[mini-nodemon]\x1b[0m Shutting down...')
 runner.stop()
 process.exit(0)
})

The CLI composes FileWatcher and ProcessRunner together. Config is loaded from .nodemonrc.json in the cwd, with CLI args taking precedence. SIGINT handler (Ctrl+C) gracefully shuts down both the watcher and runner process.

Watch out for:

args[1] might be undefined if user only passes watchDir — always provide a default command

The watcher must start before the runner to avoid missing events during startup

SIGINT must call runner.stop() not just process.exit() — otherwise the child process becomes orphaned

Test it: npx ts-node src/cli/index.ts ./src 'echo restarted' -- edit any file in ./src and see 'restarted' print

Step 5: Write the integration test

Test the full cycle: create a temp directory, start FileWatcher with a glob pattern, write a file to the temp dir, verify the 'change' event fires with the correct path. Test debounce fires once on rapid changes. Test ProcessRunner restart count increments.

Working in tests/integration.test.ts

Input Temp dir with a .ts file written to it
Output FileWatcher emits 'change' event with the correct file path within 500ms
// Use `fs.mkdtempSync(path.join(os.tmpdir(), 'nodemon-test-'))` for an isolated temp dir. Write a file with fs.writeFileSync and await the event with a Promise + setTimeout timeout.
import { FileWatcher } from '../src/watcher/file-watcher'
import { debounce } from '../src/runner/debounce'
import fs from 'fs'
import os from 'os'
import path from 'path'

async function waitForEvent(emitter: FileWatcher, event: string, timeoutMs = 1000): Promise<unknown> {
 return new Promise((resolve, reject) => {
 const timer = setTimeout(() => reject(new Error(`Timeout waiting for '${event}'`)), timeoutMs)
 emitter.once(event, (data: unknown) => { clearTimeout(timer); resolve(data) })
 })
}

async function runTests() {
 // Test 1: FileWatcher emits 'change' when a matching file is written
 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodemon-test-'))
 const watcher = new FileWatcher({ patterns: ['**/*.ts'], dir: tmpDir })
 await watcher.start()
 await new Promise(r => setTimeout(r, 50)) // let watcher initialize

 const changePromise = waitForEvent(watcher, 'change')
 fs.writeFileSync(path.join(tmpDir, 'test.ts'), 'const x = 1')
 const changedPath = await changePromise as string

 console.assert(changedPath.endsWith('test.ts'), `Expected path ending in test.ts, got: ${changedPath}`)
 console.log('✓ FileWatcher emits change for matching files')

 // Test 2: .js files are ignored when pattern is **/*.ts
 let jsChangeFired = false
 watcher.on('change', (p: unknown) => { if (String(p).endsWith('.js')) jsChangeFired = true })
 fs.writeFileSync(path.join(tmpDir, 'test.js'), 'const y = 2')
 await new Promise(r => setTimeout(r, 200))
 console.assert(!jsChangeFired, 'Should not emit change for .js files')
 console.log('✓ GlobMatcher filters out non-matching files')

 // Test 3: debounce fires once on rapid calls
 let callCount = 0
 const debounced = debounce(() => callCount++, 50)
 debounced(); debounced(); debounced()
 await new Promise(r => setTimeout(r, 200))
 console.assert(callCount === 1, `Expected 1 call, got ${callCount}`)
 console.log('✓ debounce fires once on rapid calls')

 await watcher.stop()
 fs.rmSync(tmpDir, { recursive: true })
 console.log('\n✅ All tests passed!')
}

runTests().catch(err => { console.error(err); process.exit(1) })

The integration test uses a real temp directory and actual fs.watch to verify the full file-watching pipeline works end-to-end. waitForEvent converts EventEmitter.once into a Promise with a timeout, making async assertions clean. Tests clean up after themselves with fs.rmSync.

Watch out for:

fs.watch needs a small delay after start() before it begins detecting changes — add a 50ms pause

On macOS, fs.watch may fire the filename as just the basename (not the full path) — resolve it with path.join(dir, filename)

Always clean up temp directories in tests — use try/finally or add cleanup to process.on('exit')

Test it: npx ts-node tests/integration.test.ts — should print 'All tests passed!'

The Integration Test

End-to-end integration: watch a temp directory, write a .ts file, verify the 'change' event fires with the correct path, verify debounce prevents multiple rapid restarts

import { EventEmitter } from './src/core/event-emitter'
import { FileWatcher } from './src/watcher/file-watcher'
import { debounce } from './src/runner/debounce'
import fs from 'fs'
import os from 'os'
import path from 'path'

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodemon-e2e-'))
const watcher = new FileWatcher({ patterns: ['**/*.ts'], dir: tmpDir })
const events: string[] = []

watcher.on('change', (p: unknown) => events.push(String(p)))
await watcher.start()
await new Promise(r => setTimeout(r, 50))

fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export const x = 1')
await new Promise(r => setTimeout(r, 300))

console.assert(events.length >= 1, 'change event fired')
console.assert(events[0].endsWith('index.ts'), 'correct path emitted')

await watcher.stop()
fs.rmSync(tmpDir, { recursive: true })
console.log('mini-nodemon e2e test passed!')

Run it with: npx ts-node tests/integration.test.ts

Skills: TypeScript · Node.js fs.watch · EventEmitter · Child Processes · CLI Tools

Test Your Knowledge

Q1: Why does off(event, fn) require the exact function reference?

  • [ ] JavaScript compares functions by their string source code
  • [ ] Two function objects are only equal if they share the same memory reference
  • [ ] TypeScript enforces reference equality for listener removal
  • [ ] The Map uses structural comparison for function keys

Reveal answer

Two function objects are only equal if they share the same memory reference — JavaScript's === for objects (including functions) is reference equality. Two arrow functions with identical bodies are different objects at different memory addresses. This is why addEventListener + removeEventListener with an anonymous function never removes the listener.

Q2: What is the purpose of snapshotting the listener array before iterating in emit()?

  • [ ] To improve performance by avoiding repeated map lookups
  • [ ] To allow listeners to safely call off() on themselves during emission without causing array mutation bugs
  • [ ] To create a deep copy of function references
  • [ ] To enforce FIFO ordering of listeners

Reveal answer

To allow listeners to safely call off() on themselves during emission without causing array mutation bugs — If a listener calls this.off(event, fn) during emission (as once() wrappers do), it mutates the array being iterated. Snapshotting with slice() before the loop prevents skipping or double-calling other listeners.

Q3: A once() listener and a regular on() listener for the same event will fire in emission order — the listener registered first fires first.

Reveal answer

true — Both on() and once() push to the same listener array. Emission iterates the array in insertion order. The type of registration (on vs once) does not affect ordering.

Q4: Which data structure is most appropriate for the listener registry in a high-performance EventEmitter?

  • [ ] Plain object {} — fastest property access
  • [ ] Map — O(1) lookup, handles any string key safely
  • [ ] Set<[string, Listener]> — deduplicates listeners automatically
  • [ ] Array of {event, fn} tuples — simplest implementation

Reveal answer

Map — O(1) lookup, handles any string key safely — Map provides O(1) amortized get/set/delete, handles any string key without prototype collision risk (unlike plain objects where 'constructor' or 'proto' could be event names), and has a clean API for checking existence.


Key Takeaways

  • An EventEmitter is a Map with four methods: on, off, emit, once
  • off() uses strict reference equality — anonymous functions cannot be removed
  • once() requires a wrapper function and a reverse Map to allow early removal via off(originalFn)
  • Snapshotting the listener array before iterating in emit() prevents mutation bugs from self-removing listeners
  • Deleting empty event keys on off() prevents memory leaks in long-lived emitters

Mental model: An EventEmitter is a telephone switchboard. on() plugs in a phone. off() unplugs it. emit() calls every phone on the line simultaneously. once() is a phone that self-destructs after one call. The switchboard (Map) knows which phones are connected to which lines — but you must keep the exact same phone reference to unplug it.