# How Ego-Lite Implements the State Singleton Pattern in `src/state.ts`

> Discover how Ego-Lite implements the state singleton pattern in src/state.ts using a global object for mutable runtime info shared across modules via Node's caching.

- Repository: [CitroLabs/ego-lite](https://github.com/citrolabs/ego-lite)
- Tags: internals
- Published: 2026-08-01

---

**Ego-Lite uses a plain JavaScript object exported as `state` from [`src/state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/state.ts) to implement a global singleton that holds all mutable runtime information, shared across modules through Node's module caching system.**

The **state singleton pattern** in ego-lite provides a lightweight, testable way to manage browser session data and utility functions without class overhead or complex dependency injection. This pattern is central to how the ego-browser package coordinates navigation, waiting, and CDP (Chrome DevTools Protocol) communication.

## What the State Singleton Pattern Looks Like

The implementation is deliberately minimal. Rather than a class with private constructors or static instance getters, ego-lite uses a single exported constant object:

```typescript
// package/ego-browser/src/state.ts
export const state = {
  sessionId: "",
  sessionTargetId: "",
  defaultTimeout: 10_000,
  networkDomainEnabled: false,
  // ... additional properties
  
  now(): number { return Date.now(); },
  sleep(ms: number): Promise<void> { /* ... */ },
  writeFile(path: string, data: Buffer): Promise<void> { /* ... */ },
  isOverridden(): boolean { /* ... */ }
};

```

Node's module system guarantees that every `import { state } from "./state.js"` receives the same object reference. This fulfills the singleton contract without ceremony.

## Core Properties of the State Singleton

The `state` object holds mutable fields that evolve as the browser runtime executes:

| Property | Purpose | Typical Mutations |
|----------|---------|-----------------|
| `sessionId` | Active CDP session identifier | Set after `Target.attachToTarget` in [`src/driver/nav.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/nav.ts) |
| `sessionTargetId` | Target ID for the current page | Updated on navigation events |
| `defaultTimeout` | Milliseconds for wait operations | Modified via `setOverrides()` in tests |
| `networkDomainEnabled` | Whether Network domain events are subscribed | Toggled during page load monitoring |

These properties are accessed directly by helpers throughout the codebase. For example, [`src/driver/waits.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/waits.ts) reads `state.defaultTimeout` to determine how long to poll for conditions.

## Built-In Utility Methods

The singleton bundles commonly needed utilities:

- **`state.now()`** — Returns `Date.now()`, enabling time-aware operations without global references
- **`state.sleep(ms)`** — Returns a `Promise` that resolves after `ms` milliseconds, used for backoff and pacing
- **`state.writeFile(path, data)`** — File system helper for saving screenshots or downloads

These methods are attached to the `state` object itself, making them available wherever the singleton is imported without additional dependencies.

## Test-Friendly Override System

The `setOverrides()` function enables safe mutation in unit tests:

```typescript
// package/ego-browser/src/state.ts
export function setOverrides(overrides: Partial<typeof state>): () => void {
  const original = { ...state };
  Object.assign(state, overrides);
  return () => {
    Object.assign(state, original);
  };
}

```

This shallow-copy approach:
- Captures current values before modification
- Applies the provided overrides
- Returns a cleanup function that restores original state

**Example test usage:**

```typescript
import { state, setOverrides } from "../src/state.js";

test("waits respect custom timeout", async () => {
  const restore = setOverrides({ defaultTimeout: 500 });
  
  // Test behavior with 500ms timeout
  await expect(waitForSelector("#missing")).rejects.toThrow();
  
  restore(); // Original timeout restored
});

```

## Detecting Transport Overrides

The `state.isOverridden()` method provides runtime introspection:

```typescript
if (state.isOverridden()) {
  // CDP transport replaced — likely running under test mocks
  console.warn("Using mock CDP transport");
}

```

This check examines whether `state.send` (the CDP message dispatcher) or `state.cdpOverride` has been replaced, allowing conditional logic when the default transport is bypassed.

## How the Singleton Is Used Across the Codebase

### Navigation Module ([`src/driver/nav.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/nav.ts))

The navigation layer updates session identifiers after attaching to new targets:

```typescript
import { state } from "../state.js";

async function attachToTarget(targetId: string): Promise<void> {
  const { sessionId } = await send("Target.attachToTarget", { targetId });
  state.sessionId = sessionId;
  state.sessionTargetId = targetId;
}

```

### Waiting Module ([`src/driver/waits.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/waits.ts))

Wait helpers consume the singleton for timing and pacing:

```typescript
import { state } from "../state.js";

async function waitForCondition(
  predicate: () => boolean,
  timeoutMs = state.defaultTimeout
): Promise<void> {
  const deadline = state.now() + timeoutMs;
  while (!predicate()) {
    if (state.now() > deadline) throw new TimeoutError();
    await state.sleep(50);
  }
}

```

### Helpers Module ([`src/helpers.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/helpers.ts))

Higher-level conveniences expose state-derived values:

```typescript
import { state } from "./state.js";

export function agentWorkspace(): string {
  // Derives path from state configuration
  return state.workspacePath || defaultWorkspace();
}

```

## Why This Pattern Works for Ego-Lite

Several design decisions make this singleton appropriate:

- **Module caching ensures single instance** — No manual instance management required
- **Plain object avoids [class overhead](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/state.ts)** — Direct property access, no getter indirection
- **Explicit mutation sites** — State changes are grep-able and reviewable
- **`setOverrides()` enables testing** — No dependency injection boilerplate needed
- **TypeScript provides compile-time safety** — The `typeof state` type travels with the export

The trade-off is global mutable state, which the codebase mitigates through:
- Clear ownership (only specific driver modules write to each field)
- Override cleanup requirements in tests
- `isOverridden()` detection for unexpected modifications

## Summary

- **The state singleton in [`src/state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/state.ts) is a plain exported object** that Node's module system guarantees is shared across all importers
- **Mutable properties** like `sessionId`, `defaultTimeout`, and `networkDomainEnabled` track runtime browser state
- **Utility methods** (`now()`, `sleep()`, `writeFile()`) provide globally available helpers without extra imports
- **`setOverrides()` enables safe test mutation** with automatic cleanup of original values
- **`isOverridden()` detects mocked environments** where CDP transport has been replaced
- **Consumer modules** in [`src/driver/nav.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/nav.ts), [`src/driver/waits.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/waits.ts), and [`src/helpers.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/helpers.ts) read and write the singleton to coordinate behavior

## Frequently Asked Questions

### What makes ego-lite's state singleton different from a class-based singleton?

Ego-lite skips the class entirely. Instead of `StateManager.getInstance()` or private constructors, the pattern relies on **Node's module caching of `export const state`**. Every import receives the same object reference. This eliminates boilerplate while achieving identical singleton semantics. The source code in [`package/ego-browser/src/state.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/state.ts) implements this in roughly 50 lines versus a typical class singleton's ceremony.

### How does `setOverrides()` prevent test pollution?

The function performs a **shallow copy of current state** before applying overrides, then returns a restoration closure. When called, this closure reassigns all properties to their original values. This ensures that even if a test throws or exits early, paired `restore()` calls guarantee clean state for subsequent tests. The implementation uses standard `Object.assign()` rather than structured cloning, which matches ego-lite's preference for simple, inspectable code.

### Why attach utility methods like `sleep()` directly to the state object?

**Co-location of shared dependencies** reduces import complexity. Any module importing `state` gains immediate access to timing and I/O utilities without tracking separate `utils` or `helpers` imports. This proves especially useful in [`src/driver/waits.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/waits.ts), where `state.now()` and `state.sleep()` appear together in polling loops. The pattern also simplifies mocking—replacing `state.sleep` with a synchronous stub affects all consumers uniformly.

### Could this singleton pattern cause issues with parallel test execution?

Yes—**the global mutable nature requires coordination**. Ego-lite's test suite likely runs with `setOverrides()` or executes tests serially within a process. For true parallel execution, each worker would need isolated state, which would require architectural changes (e.g., passing context objects explicitly). The current design prioritizes simplicity for single-browser automation over concurrent safety.