Connected EventEmitter Developer Guide

Build opt-in Go resource helpers that deliver events to JavaScript-created EventEmitter instances safely.

Sections

Terminology & Glossary
πŸ“– Documentation
Navigation
22 sectionsv0.1
πŸ“„ Connected EventEmitter Developer Guide β€” glaze help connected-eventemitters-developer-guide
connected-eventemitters-developer-guide

Connected EventEmitter Developer Guide

Build opt-in Go resource helpers that deliver events to JavaScript-created EventEmitter instances safely.

Tutorialgojajavascriptevent-emitterasyncfswatchwatermillgoja-repljsverbs-example

This guide explains how to build and use connected EventEmitter helpers in go-go-goja. A connected helper lets JavaScript create a normal EventEmitter, pass it to a Go-backed function, and receive events from a Go resource such as fsnotify or Watermill without violating goja's single-owner runtime rule.

Use this pattern when the JavaScript side should own listener registration, but the Go side owns a long-lived resource that produces events over time. Examples include filesystem watchers, message-bus subscriptions, process supervisors, or domain-specific notification streams.

Why connected emitters exist

Go resources often emit data from background goroutines. goja runtimes are not goroutine-safe: any code that touches goja.Runtime, goja.Value, a JavaScript function, or a JavaScript object must run on the runtime owner thread. Calling JavaScript directly from a filesystem watcher goroutine or message subscription goroutine creates data races and intermittent crashes.

Connected emitters solve that boundary by separating ownership:

  • JavaScript creates and owns the EventEmitter object.
  • Go adopts that emitter on the owner thread and stores a safe EmitterRef.
  • Background goroutines only call EmitterRef.Emit(...) or EmitterRef.EmitWithBuilder(...).
  • EmitterRef schedules listener delivery back onto the runtime owner.
  • The returned connection object closes the Go resource without removing JavaScript listeners.

The result feels natural in JavaScript while keeping Go concurrency explicit and reviewable.

JavaScript shape

A connected helper always starts with a JavaScript-created emitter:

const EventEmitter = require("events");

const watcher = new EventEmitter();
const conn = fswatch.watch("/tmp/project", watcher, {
  recursive: true,
  debounceMs: 100,
  include: ["**/*.js", "**/*.ts"],
  exclude: ["**/node_modules/**", "**/.git/**"]
});

watcher.on("event", (ev) => {
  console.log(ev.relativeName, ev.op, ev.debounced, ev.count);
});

watcher.on("error", (err) => {
  console.error(err.path, err.message);
});

conn.close();

The helper is intentionally not a default global in every runtime. Host applications install it explicitly because filesystem watching and message subscriptions are host-resource access.

Embedding runtime setup

Install the connected-emitter manager first, then install resource helpers. The order matters because helpers look up the manager during runtime initialization.

package myruntime

import (
    "context"
    "time"

    "github.com/go-go-golems/go-go-goja/engine"
    "github.com/go-go-golems/go-go-goja/pkg/jsevents"
)

func NewRuntime(ctx context.Context) (*engine.Runtime, error) {
    factory, err := engine.NewBuilder().
        Build().
        WithRuntimeInitializers(
            jsevents.Install(),
            jsevents.FSWatchHelper(jsevents.FSWatchOptions{
                Root:           "/tmp/my-app-sandbox",
                AllowRecursive: true,
                MaxDebounce:    time.Second,
            }),
        ).
        Build()
    if err != nil {
        return nil, err
    }
    return factory.NewRuntime(engine.WithStartupContext(ctx), engine.WithLifetimeContext(ctx))
}

jsevents.Install() does not create any emitters or start any background work. It only stores a per-runtime manager that helpers use later when JavaScript calls helper functions.

EventEmitter module contract

The events and node:events modules are data-only defaults. go-go-goja uses Node's node: prefix for Node-compatible or mostly-compatible built-ins such as node:events, node:path, node:crypto, and opt-in host modules such as node:fs and node:os. Custom helpers such as fswatch, watermill, time, and timer intentionally do not use a node: prefix.

The EventEmitter module is available in fresh runtimes and implements a Go-native subset of Node's EventEmitter:

const EventEmitter = require("events");
const emitter = new EventEmitter();

emitter.once("ready", (name) => console.log("first", name));
emitter.on("ready", (name) => console.log("always", name));

emitter.emit("ready", "goja");
emitter.emit("ready", "again");

Go helpers validate JavaScript-created emitters with events.FromValue(...) indirectly through Manager.AdoptEmitterOnOwner(...). Do not accept arbitrary JavaScript objects and try to call .emit yourself from Go; that bypasses the native emitter adoption and owner-thread scheduling model.

Writing a connected helper

A helper function should do only setup while it is executing on the owner thread. Long-running work belongs in a goroutine that communicates through EmitterRef.

The typical shape is:

func MyHelper(opts MyOptions) engine.RuntimeInitializer {
    return &myHelper{opts: opts}
}

func (h *myHelper) InitRuntime(ctx *engine.RuntimeContext) error {
    managerValue, ok := ctx.Value(jsevents.RuntimeValueKey)
    if !ok {
        return fmt.Errorf("my helper: manager is not installed; add jsevents.Install() first")
    }
    manager := managerValue.(*jsevents.Manager)

    obj := ctx.VM.NewObject()
    if err := obj.Set("connect", func(call goja.FunctionCall) goja.Value {
        resourceName := call.Argument(0).String()
        ref, err := manager.AdoptEmitterOnOwner(call.Argument(1))
        if err != nil {
            panic(ctx.VM.NewGoError(err))
        }

        resourceCtx, cancel := context.WithCancel(ctx.Context)
        ref.SetCancel(cancel)

        go runResource(resourceCtx, ref, resourceName)

        return connectionObject(ctx.VM, ref)
    }); err != nil {
        return err
    }

    return ctx.VM.Set("myResource", obj)
}

The resource goroutine never receives a *goja.Runtime, goja.Value, or JavaScript callback. It receives only plain Go data and an EmitterRef.

Building typed payloads

Use typed Go structs for JavaScript-facing payloads, then build lowerCamel JavaScript objects explicitly. Do not pass free-form map[string]any payloads for helper events; maps make contracts drift and hide field spelling changes from review.

type FileWatchEvent struct {
    Source       string
    WatchPath    string
    Name         string
    RelativeName string
    Op           string
    Create       bool
    Write        bool
    Debounced    bool
    Count        int
}

func (p FileWatchEvent) ToValue(vm *goja.Runtime) goja.Value {
    obj := vm.NewObject()
    _ = obj.Set("source", p.Source)
    _ = obj.Set("watchPath", p.WatchPath)
    _ = obj.Set("name", p.Name)
    _ = obj.Set("relativeName", p.RelativeName)
    _ = obj.Set("op", p.Op)
    _ = obj.Set("create", p.Create)
    _ = obj.Set("write", p.Write)
    _ = obj.Set("debounced", p.Debounced)
    _ = obj.Set("count", p.Count)
    return obj
}

Then emit through an owner-thread value builder:

payload := FileWatchEvent{Source: "fsnotify", Name: event.Name, Count: 1}
_ = ref.EmitWithBuilder(ctx, "event", func(vm *goja.Runtime) ([]goja.Value, error) {
    return []goja.Value{payload.ToValue(vm)}, nil
})

This pattern keeps the Go side typed while giving JavaScript idiomatic lowerCamel properties.

fswatch helper

FSWatchHelper installs a custom fswatch global. It is not a Node standard library. It is a go-go-goja host helper for connecting github.com/fsnotify/fsnotify to a JavaScript-created EventEmitter.

JavaScript API:

interface FSWatchOptionsJS {
  recursive?: boolean;
  debounceMs?: number;
  include?: string[];
  exclude?: string[];
}

interface FSWatchConnection {
  id: string;
  path: string;
  recursive: boolean;
  debounceMs: number;
  include: string[];
  exclude: string[];
  close(): boolean;
}

Event payload:

interface FileWatchEvent {
  source: "fsnotify";
  watchPath: string;
  name: string;
  relativeName: string;
  op: string;
  create: boolean;
  write: boolean;
  remove: boolean;
  rename: boolean;
  chmod: boolean;
  recursive: boolean;
  debounced: boolean;
  count: number;
}

Host options:

type FSWatchOptions struct {
    GlobalName     string
    Root           string
    AllowPath      func(path string) bool
    AllowRecursive bool
    MaxDebounce    time.Duration
    IgnorePath     func(path string) bool
}

Important behavior:

  • Root and AllowPath constrain host path access.
  • AllowRecursive defaults to false because recursive watching can allocate one OS watch per directory.
  • MaxDebounce caps script-provided debounceMs.
  • IgnorePath lets hosts skip paths before traversal or event delivery.
  • Recursive traversal skips symlink directories.
  • Include and exclude globs match slash-separated paths relative to the watch root.
  • ** as a full path segment matches zero or more path segments.

Watermill helper

WatermillHelper follows the same adoption pattern for message subscriptions. JavaScript passes an emitter into watermill.connect(topic, emitter), and Go emits message, error, and close events on that emitter.

A message event receives a typed JavaScript object with settlement methods:

orders.on("message", (msg) => {
  console.log(msg.uuid, msg.payload, msg.metadata.source);
  msg.ack();
});

Use the Watermill helper only when the embedding application has an explicit message.Subscriber and topic policy. Do not install default subscriptions at runtime startup.

jsverbs examples

The fixture directory contains examples for the EventEmitter and fswatch APIs:

examples/jsverbs/basic/events.js
examples/jsverbs/basic/fswatch.js

EventEmitter examples run through the default jsverbs-example runtime:

go run ./cmd/jsverbs-example --dir examples/jsverbs/basic events event-timeline evt --count 2
go run ./cmd/jsverbs-example --dir examples/jsverbs/basic events listener-summary demo
go run ./cmd/jsverbs-example --dir examples/jsverbs/basic events handled-error boom

The fswatch example demonstrates the JavaScript shape but requires an embedding runtime that installs jsevents.Install() and FSWatchHelper(...). The regression test TestFSWatchJsverbUsesInstalledHelper shows that embedding path and invokes the jsverb with recursive watching, debouncing, and glob filters.

go test ./pkg/jsverbs -run TestFSWatchJsverbUsesInstalledHelper -count=1

Review checklist

Use this checklist when reviewing a new connected helper:

  • The helper requires explicit runtime installation.
  • jsevents.Install() runs before the helper initializer.
  • JavaScript creates the EventEmitter and passes it into Go.
  • Go adopts the emitter on the owner thread.
  • Background goroutines do not touch goja runtime values directly.
  • Event, error, and connection payloads are typed Go structs with explicit JS builders.
  • The connection close() is idempotent and cancels Go resources.
  • Host access has policy hooks such as Root, AllowPath, AllowTopic, or equivalent.
  • Tests cover invalid emitters, denied resources, listener delivery, no-listener behavior where relevant, and close cleanup.

Troubleshooting

ProblemCauseSolution
fswatch is not definedThe runtime did not install FSWatchHelperAdd jsevents.Install() and jsevents.FSWatchHelper(...) to WithRuntimeInitializers(...).
manager is not installedHelper initializer ran before jsevents.Install()Put jsevents.Install() before helper initializers.
Recursive watch request failsHost did not set AllowRecursive: trueEnable AllowRecursive only for trusted/sandboxed roots.
Debounce request failsdebounceMs is negative, non-finite, or above MaxDebounceValidate script options or raise MaxDebounce intentionally.
No event for the first file in a newly-created directoryRecursive directory registration happens after fsnotify reports directory creationWait for directory registration, write again, or add a future directory-added/ready event if the script needs a guarantee.
Listener throws but the goroutine keeps runningAsync listener errors are reported through the manager error handlerInstall jsevents.WithErrorHandler(...) and decide whether the host should close the resource on listener errors.
Go code wants to pass map[string]any as event payloadThe helper contract becomes hard to review and documentDefine a struct and a ToValue(vm) method instead.

See Also

  • glaze help nodejs-primitives for built-in module availability and EventEmitter reference.
  • glaze help async-patterns for owner-thread scheduling and Promise/callback patterns.
  • glaze help creating-modules for native module adapter structure.
  • glaze help jsverbs-example-developer-guide for embedding and invoking JavaScript-backed Glazed commands.
  • glaze help jsverbs-example-reference for fixture and runner details.