StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: internal_evt.js

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: internal_evt.js

Introduction

This installment examines src/shared/internal_evt.js, a seven-line implementation after its license and synchronization comment. A mirrored file exists at web/internal_evt.js.

The module is not an event bus and defines no event names. It exports two values used to classify event listeners:

  • INTERNAL_EVT, a token selected during preprocessing;
  • internalOpt, a frozen options object containing that token.

The actual registration and dispatch logic lives in web/event_utils.js. Reading the two sides together reveals the real mechanism: internal listeners authenticate their classification with a build-specific token, and EventBus delivers them before external listeners for the same event.

Module Overview

BlockLinesResponsibility
Synchronization comment16-18Require this file and web/internal_evt.js to remain aligned
INTERNAL_EVT20-23Select the injected build token or the source/testing fallback
internalOpt25Package the token as an immutable listener option
Exports27Make the token available to EventBus and the options object available to internal callers

The design separates roles cleanly: this module owns the capability value, internal components opt in with internalOpt, and EventBus performs the strict identity comparison that determines listener classification.

INTERNAL_EVT: Selecting the Token at Build Time

const INTERNAL_EVT =
  typeof PDFJSDev !== 'undefined' && !PDFJSDev.test('TESTING')
    ? PDFJSDev.eval('INTERNAL_EVT')
    : 'internalEvent';

There are two modes:

ModeSelected valueReason
Preprocessed, non-testing buildPDFJSDev.eval("INTERNAL_EVT")Inject the value supplied by the build definitions
Raw source or testing build"internalEvent"Provide a deterministic fallback without requiring production injection

The production value comes from the DEFINES object in gulpfile.mjs:

const DEFINES = Object.freeze({
  // Other build flags...
  INTERNAL_EVT: crypto.randomUUID(),
});

The Babel preprocessor evaluates PDFJSDev.eval against those definitions and replaces the expression with the resulting literal. Consequently, the shipped module does not call crypto.randomUUID() at runtime; the UUID is chosen when the build is produced and remains fixed within that build.

This token is not the name of an internal event. It is a capability-like value used in listener options. A caller cannot become internal merely by passing { internal: true }, because EventBus compares the supplied value with INTERNAL_EVT using strict equality.

The fixed testing fallback serves a different purpose. Source-level tests and unprocessed imports need both sides of the comparison to agree without depending on a production preprocessing pass. It should not be confused with the randomized value used for non-testing builds.

internalOpt: Freezing the Internal Registration Option

const internalOpt = Object.freeze({ internal: INTERNAL_EVT });

internalOpt turns the token into the exact option shape expected by EventBus.on. Freezing prevents one consumer from changing the shared object's internal property and silently affecting later registrations. The freeze is shallow, but the object has only one primitive-valued property, so that is sufficient here.

Internal callers can pass the object directly or combine it with other standard options. For example, the annotation editor UI manager registers several listeners with an abort signal:

const evtOpts = { signal, ...internalOpt };
eventBus.on('editingaction', this.onEditingAction.bind(this), evtOpts);
eventBus.on('pagechanging', this.onPageChanging.bind(this), evtOpts);
eventBus.on('scalechanging', this.onScaleChanging.bind(this), evtOpts);

The spread copies the token into a fresh options object while preserving signal. Other call sites add once in the same way. This keeps lifecycle and delivery-classification concerns composable instead of inventing a separate registration API for every combination.

EventBus.on() and dispatch(): Enforcing Internal-First Delivery

The token becomes meaningful when EventBus records a listener. In web/event_utils.js, registration stores a boolean derived from identity with the private module contract:

this.#listeners.getOrInsertComputed(eventName, makeSet).add({
  listener,
  internal: options?.internal === INTERNAL_EVT,
  once: options?.once === true,
  rmAbort,
});

Three cases therefore produce an external listener: no options, no internal property, or a value that does not equal the build token. Only the matching token sets the stored internal flag.

Dispatch then uses that flag to create two phases for the same event:

for (const { listener, internal, once } of new Set(eventListeners)) {
  if (once) {
    this.off(eventName, listener);
  }
  if (!internal) {
    (extListeners ??= []).push(listener);
    continue;
  }
  listener(data);
}
if (extListeners) {
  for (const listener of extListeners) {
    listener(data);
  }
}

Internal callbacks run during the first pass; external callbacks are collected and invoked afterward. The source comment states the reason directly: viewer components get time to handle the event and update their state before outside observers run.

This is listener classification and ordering, not two separate buses. External listeners still receive the event. The snapshot created with new Set(eventListeners) also keeps dispatch stable if listeners are added or removed during delivery, and once listeners are removed before invocation.

In the raw-source and GENERIC branches, the EventBus constructor additionally calls Object.seal(this). Its source comment explicitly connects this with preventing third-party method overrides from exposing INTERNAL_EVT. The seal, the build-specific random token, and the strict comparison all support the same internal/external contract.

The Twin File: Keeping Library and Viewer on One Contract

src/shared/internal_evt.js is imported by display-layer code such as src/display/editor/tools.js. Viewer modules and web/event_utils.js import web/internal_evt.js instead. The implementations are identical, and both files carry a comment instructing maintainers to keep them synchronized.

During a build, both sides resolve INTERNAL_EVT through the same build-definition entry, so their strict comparisons use the same UUID. This is why there is no runtime exchange of a secret between the display library and the viewer. The correspondence is established by preprocessing, while the duplicated fallback keeps direct source and testing usage consistent.

The duplication is therefore intentional, but it is not permission for the files to evolve independently. Changing one option shape or fallback without changing the other would break the agreement on which this classification depends.

Summary

The small module supports a precise event-routing mechanism rather than an event system of its own:

  1. Non-testing builds inject a random UUID through PDFJSDev.eval; raw source and testing use the fixed internalEvent fallback.
  2. INTERNAL_EVT is a comparison token, not an event name, and { internal: true } cannot substitute for it.
  3. internalOpt packages that token in a frozen, reusable options object that composes with signal and once.
  4. EventBus.on converts a successful identity check into a stored classification; dispatch invokes internal listeners before external ones.
  5. The shared and web copies must stay synchronized so display-layer registrations and viewer-side dispatch retain one contract.

The key insight is that build-time injection and event ordering are two halves of the same design. The token establishes which listeners are internal; the dispatcher uses that knowledge to update viewer state before exposing the event to external observers.

StreamPDF design principle: anything that can be handled ahead of time is absolutely not deferred to parse time; the server solves all performance problems once. The browser only fetches and renders the data it must.

Related articles