CRXJS File Writer System: Architectural Design and Implementation

The CRXJS file writer system is a reactive, modular pipeline that bridges the Vite development server with Chrome extension file-system output using RxJS streams to serialize writes, manage dependencies, and ensure graceful shutdowns.

The CRXJS file writer system lives in @crxjs/vite-plugin and serves as the core orchestration layer between Vite's development server and the physical file manifest required by Chrome extensions. Located in packages/vite-plugin/src/node/fileWriter.ts, this architecture employs a reactive design pattern to handle dynamic module updates, virtual file resolution, and deterministic disk writes during development.

Core Architectural Components

The file writer is organized around three primary responsibilities: lifecycle management, centralized state tracking, and module registration.

Lifecycle Management with start() and close()

The writer exposes imperative start() and close() methods to control the reactive pipeline. According to the source code in fileWriter.ts, the start() function initializes the build process by pushing a start event to the serverEvent$ subject (lines 44-45). It constructs Rollup options from the existing Vite configuration, executes rollup(), writes the initial bundle, and awaits allFilesReady() before yielding control to the reactive stream (lines 63-69).

The close() method pushes a termination event through the observable chain, ensuring any in-flight writes abort cleanly using RxJS's takeUntil operator.

Central File Map State Management

All generated file metadata is stored in outputFiles, imported from fileWriter-filesMap.ts. This in-memory map serves as the single source of truth for the writer's state, tracking every OutputFile instance and enabling deterministic lookups during incremental updates.

Module Registration and Updates

The add() function retrieves existing entries from the central map or creates new metadata records. When processing virtual modules—identified by IDs prefixed with /@id/ or /__—the system forces a rewrite regardless of cache state to ensure dynamic content changes are captured (lines 98-110).

The update() method traverses script type permutations (iife and module), invalidates matching entries, and returns a list of updated files for hot-module replacement notifications (lines 24-39).

The Reactive Write Pipeline

The write() function constitutes the heart of the CRXJS file writer system, implementing a reactive orchestration pattern using RxJS streams defined in fileWriter-rxjs.ts. The pipeline waits for the start$ signal emitted by start(), then processes each file through prepFileData() to resolve { target, source, deps } objects.

Each dependency discovered during preparation flows through add() to ensure transitive modules are tracked. The actual disk operation uses fs-extra's outputFile method, serialized through mergeMap operators to prevent race conditions. The stream terminates upon receiving the close$ event, completing the observable chain (lines 49-78).

This architecture leverages RxJS operators including mergeMap for concurrency control, takeUntil for cancellation semantics, and concatWith for deterministic sequencing.

Utility Delegation and Modularity

File-name construction, prefix handling, and content formatting are abstracted into fileWriter-utilities.ts. This separation of concerns keeps the core orchestration logic in fileWriter.ts focused exclusively on stream management and state transitions, while helper utilities handle string manipulation and path resolution. Debug logging is handled consistently through helpers.ts.

Practical Implementation Examples

Starting the writer within a Vite plugin:

import { start, add, update, close } from '@crxjs/vite-plugin/node/fileWriter'

export default {
  name: 'crx:my-plugin',
  configureServer(server) {
    // Kick off the writer once the dev server is up
    start({ server })
  },
  // Example: expose a command to manually rebuild a script
  async handleHotUpdate({ file, server }) {
    if (file.endsWith('.ts')) {
      const updated = update(file)
      server.ws.send({ type: 'full-reload' })
      return updated
    }
  },
  // Graceful shutdown
  closeBundle() {
    close()
  },
}

Manually adding a new script:

import { add } from '@crxjs/vite-plugin/node/fileWriter'

const script = { id: '/src/content.ts', type: 'module' as const }
const outputFile = add(script)

// `outputFile.file` is a Promise that resolves when the file has been written
outputFile.file.then(({ start, close }) => {
  console.log(`Wrote ${script.id} in ${close - start} ms`)
})

Monitoring writer events:

import { fileWriterEvent$ } from '@crxjs/vite-plugin/node/fileWriter-rxjs'

fileWriterEvent$.subscribe(event => {
  if (event.type === 'build_start') console.log('CRX build started')
  if (event.type === 'build_end')   console.log('CRX build finished')
})

Summary

  • The CRXJS file writer system uses a reactive RxJS pipeline to synchronize Vite's development server with Chrome extension file output.
  • Lifecycle methods start() and close() control the observable stream, initiating builds and ensuring graceful termination.
  • A central file map (outputFiles) maintains metadata for every generated asset, serving as the single source of truth.
  • The write pipeline serializes disk operations using mergeMap and takeUntil operators to handle dependencies and cancellation safely.
  • Modular architecture delegates path resolution and formatting to fileWriter-utilities.ts, keeping core orchestration logic clean.

Frequently Asked Questions

How does the CRXJS file writer handle virtual modules during development?

The system identifies virtual modules by checking for ID prefixes /@id/ or /__ in the add() function. When detected, the writer forces a complete rewrite of the file regardless of caching state, ensuring that dynamically generated virtual content updates are always persisted to disk (lines 98-110 in fileWriter.ts).

What RxJS operators does the file writer use to manage concurrent writes?

The pipeline relies on mergeMap to serialize write operations, takeUntil to subscribe to the close$ signal for cancellation, and concatWith to sequence operations deterministically. These operators ensure that file writes occur in order and can be cleanly aborted when the development server shuts down.

Where is the file metadata stored in the CRXJS architecture?

All file metadata is stored in the outputFiles map, imported from fileWriter-filesMap.ts. This in-memory data structure tracks OutputFile instances for every generated asset and acts as the single source of truth for the writer's reactive state.

How do I programmatically trigger a rebuild of specific scripts?

Use the update() function exported from fileWriter.ts, passing the file path as an argument. The method walks through script type variations (iife and module), invalidates matching entries in the central map, and returns the list of affected files that require rewriting (lines 24-39).

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →