# What Role Does RxJS Play in the CRXJS File Writer System?

> Discover RxJS's crucial role in the CRXJS file writer system. See how RxJS provides the reactive event backbone for synchronizing Vite, Rollup, and Chrome extension runtime via observable streams.

- Repository: [crxjs/chrome-extension-tools](https://github.com/crxjs/chrome-extension-tools)
- Tags: internals
- Published: 2026-02-28

---

**RxJS provides the reactive event backbone that synchronizes Vite’s dev server, Rollup’s bundler, and Chrome extension runtime through observable streams of server lifecycle, build events, and file writes.**

The CRXJS file writer acts as a bridge between Vite’s development environment and the Chrome extension runtime in the `crxjs/chrome-extension-tools` repository. Because these systems communicate through asynchronous events—server start/close, build start/end, file generation, HMR updates, and error propagation—the project models each event as an **RxJS Observable** to maintain a declarative, decoupled architecture.

## Core RxJS Observables in the File Writer

The file writer system in [`packages/vite-plugin/src/node/fileWriter-rxjs.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/fileWriter-rxjs.ts) exposes several specialized streams that represent distinct lifecycle phases. Each stream uses `ReplaySubject(1)` to guarantee that late subscribers still receive the most recent event, which is essential when the dev server reconnects or when HMR logic runs after the initial build.

### Server Lifecycle Streams

The `serverEvent$` ReplaySubject emits `ServerEvent` objects whenever the Vite dev server starts or closes. From this core stream, the system derives filtered observables:

- **`start$`** – Emits only when the server transitions to the *start* state. The `fileWriter.write()` method waits on this stream before preparing file data, ensuring writes never occur before the server is ready.
- **`close$`** – Emits only when the server shuts down. Write operations use `takeUntil(close$)` to abort pending work immediately when the dev server closes.

These streams are defined in [`fileWriter-rxjs.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/fileWriter-rxjs.ts) at lines 38–48.

### Build Lifecycle Streams

Separate from server events, `fileWriterEvent$` tracks Rollup’s bundling phase through `FileWriterEvent` objects:

- **`buildStart$`** – Fires when Rollup begins writing the CRX base.
- **`buildEnd$`** – Fires when Rollup finishes the initial bundle.

These events are emitted by `fileWriter.start()` before and after the Rollup write process. The `buildEnd$` stream serves as the trigger for downstream file-watching logic.

### File Completion and Error Aggregation

The **`allFilesReady$`** observable combines `buildEnd$` with `outputFiles.change$` (a map tracking virtual file states). It emits an array of output file promises once every script and asset has been written to disk. Both the dev server and HMR plugin consume this stream to know precisely when the extension can safely reload.

For error handling, **`fileWriterError$`** derives from `allFilesReady$` and transforms any rejected write promise into a Vite-compatible `ErrorPayload`. The HMR plugin subscribes to this stream at line 76 in [`plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-hmr.ts) to forward build failures to the browser over WebSocket. Additionally, **`allFileWriterErrors`** provides a promise-based interface using `firstValueFrom(fileWriterError$)` that resolves to an array of collected errors when the server closes, allowing the build process to surface all failures at once.

## Why RxJS Instead of Callbacks?

The CRXJS architecture favors RxJS over traditional callbacks or EventEmitter for four specific technical advantages:

- **Decoupling**: The file writer does not maintain references to its consumers. Any subsystem—the HMR plugin, Vite dev-server integration, or test harnesses—can independently subscribe to the streams they require.
- **Composability**: Operators like `filter`, `mergeMap`, `retry`, `takeUntil`, and `concatWith` express complex pipelines declaratively. For example, the write logic can be expressed as "wait for server start, then write a file, then abort if the server closes" without nested conditionals.
- **State replay**: `ReplaySubject(1)` caches the latest event, solving race conditions where HMR handlers register after the initial build completes.
- **Centralized error handling**: By funneling all write failures through `fileWriterError$`, the system provides a single observable source of truth that can be logged, sent over WebSocket, or collected for test assertions.

## How the Streams Wire the System Together

The reactive architecture creates a deterministic event flow across the extension lifecycle:

1. **Server start**: The `crx:file-writer` plugin calls `start({ server })`, which emits `serverEvent$` with type `start`.
2. **Write preparation**: `fileWriter.write()` waits for `start$` (line 55 in [`fileWriter.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/fileWriter.ts)) before invoking `prepFileData()`.
3. **Build execution**: Rollup runs, then `fileWriter.start()` emits `fileWriterEvent$` leading to `buildEnd$`.
4. **Completion detection**: `buildEnd$` triggers `allFilesReady$`, which monitors `outputFiles.change$` and resolves once every write promise settles.
5. **Error propagation**: The HMR plugin subscribes to `fileWriterError$` (line 76 in [`plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-hmr.ts)) to immediately send any write failure to the browser client.
6. **Graceful shutdown**: When the dev server shuts down, `close()` emits `serverEvent$` leading to `close$`, causing `takeUntil(close$)` operators in pending `write()` calls to abort.

## Practical Code Examples

### Waiting for Server Start Before Writing

The `fileWriter.write()` method uses `firstValueFrom` with `start$` to pause execution until the Vite server is ready:

```typescript
import { firstValueFrom } from 'rxjs'
import { start$ } from './fileWriter-rxjs'
import { prepFileData } from './fileWriter'

// Inside fileWriter.write() (fileWriter.ts:49-57)
await firstValueFrom(
  start$.pipe(
    prepFileData(fileId),
    mergeMap(async ({ target, source, deps }) => {
      // Write the file to disk
      await fs.writeFile(target, source)
    })
  )
)

```

### Handling Write Errors in HMR

The HMR plugin forwards file-writer errors to the browser by subscribing to the error stream:

```typescript
import { fileWriterError$ } from './fileWriter-rxjs'

export const pluginHMR = () => {
  return {
    configureServer(server) {
      // Forward errors to the browser client (plugin-hmr.ts:75-78)
      fileWriterError$.subscribe(error => {
        server.ws.send(error)
      })
    }
  }
}

```

### Awaiting All File Writes

To block until every script and asset is written, consume the `allFilesReady` promise:

```typescript
import { allFilesReady } from './fileWriter'

// In an async Vite hook (fileWriter.ts:104-107)
await allFilesReady()  // Resolves after buildEnd$ and outputFiles.change$ settle

```

## Summary

- **RxJS models asynchronous coordination** between Vite, Rollup, and the Chrome extension runtime through specialized Observables defined in [`fileWriter-rxjs.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/fileWriter-rxjs.ts).
- **`serverEvent$` and `fileWriterEvent$`** track server and build lifecycles, with derived streams like `start$`, `close$`, `buildStart$`, and `buildEnd$` providing filtered views for specific phases.
- **`allFilesReady$`** combines build completion with file-watching to emit when every asset is written, while `fileWriterError$` centralizes failure handling for HMR and logging.
- **ReplaySubjects ensure late subscribers** receive current state, eliminating race conditions during dev server reconnects or HMR updates.
- **Composable operators** like `takeUntil(close$)` enable automatic cancellation of pending writes when the dev server shuts down.

## Frequently Asked Questions

### Why does CRXJS use RxJS instead of native EventEmitter?

RxJS provides **composable operators** and **ReplaySubject** semantics that EventEmitter lacks. The file writer needs to combine multiple event sources (server state, build output, file system changes) into derived streams like `allFilesReady$`, which is cumbersome with EventEmitter’s simple pub/sub model. Additionally, `ReplaySubject(1)` guarantees that subscribers attaching after a build completes still receive the latest state, preventing race conditions during HMR reconnects.

### What happens to pending writes when the Vite dev server closes?

Pending writes automatically abort through the **`takeUntil(close$)`** operator inside `fileWriter.write()`. When the server shuts down, `fileWriter.close()` emits a `close` event through `serverEvent$`, which flows to `close$`. Any active write pipelines listening to `start$` or `buildEnd$` will immediately terminate when `close$` emits, preventing writes to a defunct server context.

### How does `allFilesReady$` know when every file is written?

The stream combines **`buildEnd$`** (Rollup completion) with **`outputFiles.change$`** (a BehaviorSubject tracking the virtual file map). After `buildEnd$` fires, the operator waits for every entry in the `outputFiles` map to have a settled write promise. It emits an array of file results only once all promises resolve, ensuring the Chrome extension reloads only after every script and asset is physically on disk.

### Can I subscribe to `fileWriterError$` in my own Vite plugin?

Yes. Import `fileWriterError$` from [`fileWriter-rxjs.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/fileWriter-rxjs.ts) and subscribe to receive Vite-compatible `ErrorPayload` objects whenever any file write fails. This is useful for custom reporting or alternative HMR implementations. The stream is a singleton, so multiple subscribers will each receive every error without interfering with the core CRXJS HMR plugin’s error handling.