Skip to main content
HLS Downloader follows clean architecture principles with strict separation of concerns across multiple packages. This design ensures testability, maintainability, and clear boundaries between layers.

Architecture principles

Dependency rule

Dependencies point inward. Core has zero dependencies on UI or infrastructure.

Layer isolation

Each package has a single responsibility with well-defined interfaces.

Testability

Pure business logic in core enables isolated unit testing.

Type safety

TypeScript enforces contracts across package boundaries.

Package architecture

The monorepo consists of five packages organized by responsibility:

Core package

Location: src/core/
Purpose: Domain logic, state management, and business rules
src/core/
├── src/                    # TypeScript source files
│   ├── controllers/        # Redux epics for async workflows
│   │   └── *.epic.ts       # RxJS-based side effect handlers
│   ├── entities/           # Domain models and types
│   │   └── *.ts            # Interface definitions
│   ├── services/           # Service interfaces (no implementations)
│   │   └── *.service.ts    # Abstract contracts
│   ├── store/              # Redux state management
│   │   └── *.slice.ts      # Redux Toolkit slices
│   ├── use-cases/          # Business operations
│   │   └── *.use-case.ts   # Pure functions implementing features
│   └── utils/              # Helper functions
└── lib/                    # Generated JavaScript output (git-ignored)
  • Redux Toolkit: State management with minimal boilerplate
  • redux-observable: Side effect management using RxJS operators
  • RxJS: Reactive programming for async operations
  • TypeScript: Compile-time type checking
Use cases: Implement single business operations as pure functions. Example:
// src/core/src/use-cases/download-playlist.use-case.ts
export const downloadPlaylist = (playlist: Playlist, options: DownloadOptions) => {
  // Pure business logic
};
Epics: Orchestrate async workflows and coordinate use cases:
// src/core/src/controllers/download.epic.ts
export const downloadEpic: Epic = (action$, state$) =>
  action$.pipe(
    ofType(downloadActions.start),
    mergeMap(action => /* async operations */),
    map(result => downloadActions.complete(result))
  );
Service interfaces: Define contracts without implementation:
// src/core/src/services/storage.service.ts
export interface StorageService {
  save(data: Blob): Promise<void>;
  load(): Promise<Blob>;
}
Never implement services in the core package. Core defines interfaces; background provides implementations.

Background package

Location: src/background/
Purpose: Extension runtime, service implementations, and infrastructure
src/background/
├── src/
│   ├── listeners/          # Browser event handlers
│   │   └── *.listener.ts   # Tab, network, runtime listeners
│   ├── services/           # Service implementations
│   │   ├── IndexedDBFS.ts  # File system abstraction
│   │   ├── FetchLoader.ts  # Network request handler
│   │   └── M3u8Parser.ts   # HLS playlist parser
│   └── index.ts            # Entry point, store initialization
└── vite.config.ts          # Build configuration
  1. Store initialization: Creates Redux store with core reducers and epics
  2. Service wiring: Injects concrete implementations into core use cases
  3. Extension events: Listens to browser APIs (tabs, network, downloads)
  4. FFmpeg integration: Loads and manages ffmpeg.wasm for video merging
  5. Manifest compatibility: Adapts between MV2 background pages and MV3 service workers
{
  "@ffmpeg/ffmpeg": "^0.12.10",
  "@hls-downloader/core": "workspace:*",
  "webextension-polyfill": "0.10.0",
  "idb": "6.1.2",
  "m3u8-parser": "^6.2.0"
}
Background scripts should only coordinate use cases from core. Keep business logic in the core package.
Location: src/popup/
Purpose: React-based user interface
src/popup/
├── src/
│   ├── components/         # React components
│   │   ├── Sniffer/        # Playlist detection UI
│   │   ├── Downloader/     # Download configuration UI
│   │   └── Settings/       # User preferences
│   ├── modules/            # Feature modules
│   │   └── */              # Self-contained features
│   ├── App.tsx             # Root component
│   └── index.tsx           # Entry point
├── .storybook/             # Storybook configuration
└── vite.config.ts          # Build configuration
  • React Router: Multi-page navigation within the popup
  • React Redux: Connects to the store via webext-redux
  • Design system: Imports components from @hls-downloader/design-system
  • Storybook: Component development and documentation
  • Tailwind CSS: Utility-first styling
The popup connects to the background store using webext-redux:
// Popup uses a proxy store that communicates with background
import { Store } from 'webext-redux';

const store = new Store();
await store.ready();
Actions dispatched in the popup are forwarded to the background, which applies them to the main store.
Run pnpm storybook to develop components in isolation before integrating them into the extension.

Design system package

Location: src/design-system/
Purpose: Shared UI component library
src/design-system/
├── src/
│   ├── components/         # Reusable components
│   │   ├── ui/             # Atomic UI elements
│   │   │   ├── button.tsx
│   │   │   ├── select.tsx
│   │   │   ├── slider.tsx
│   │   │   └── tabs.tsx
│   │   └── */              # Composite components
│   ├── hooks/              # Custom React hooks
│   │   └── use-*.ts
│   └── lib/                # Utilities
│       └── utils.ts        # Class name helpers
└── vite.config.ts          # Build configuration
Built on top of:
  • Radix UI: Accessible, unstyled component primitives
  • Tailwind CSS: Utility-first styling framework
  • tailwindcss-animate: Animation utilities
  • class-variance-authority: Type-safe variant management
  • Lucide React: Icon library
Example component:
import { Button } from '@hls-downloader/design-system';

<Button variant="primary" size="lg">
  Download
</Button>
All popup components should use the design system to ensure:
  • Consistent visual language
  • Accessible components out of the box
  • Reduced duplication of styles
  • Easier maintenance and updates

Assets package

Location: src/assets/
Purpose: Static resources and manifests
src/assets/
└── assets/
    ├── manifest-mv2.json   # Manifest V2 for Firefox
    ├── manifest-mv3.json   # Manifest V3 for Chromium
    ├── icons/              # Extension icons
    │   ├── icon-16.png
    │   ├── icon-48.png
    │   └── icon-128.png
    └── ffmpeg/             # FFmpeg WebAssembly files
        ├── ffmpeg-core.js
        └── ffmpeg-core.wasm
MV2 (manifest-mv2.json):
  • Uses persistent background page
  • Required for Firefox (full support)
  • Compatible with legacy Chromium browsers
MV3 (manifest-mv3.json):
  • Uses service worker for background script
  • Uses offscreen document for FFmpeg
  • Required for modern Chrome, Edge, Brave, Arc

Data flow

The extension follows a unidirectional data flow pattern:
1

User interaction

User clicks a button in the popup UI
2

Action dispatch

React component dispatches a Redux action:
dispatch(downloadActions.start({ playlistId, options }));
3

Store forwarding

webext-redux forwards the action from popup to background
4

Epic processing

Redux-observable epic in core intercepts the action:
action$.pipe(
  ofType(downloadActions.start),
  mergeMap(action => /* async work */)
)
5

Use case execution

Epic calls use case from core with service implementations from background
6

State update

Epic dispatches success/failure actions that update the Redux store
7

UI re-render

React components subscribed to the store automatically re-render

Dependency graph

The dependency relationships between packages:
The core package has no dependencies on other workspace packages. This isolation enables pure unit testing.

Build order

Packages must build in dependency order:
1

Core

Compile TypeScript to lib/ directory
pnpm run build:core
2

Design system

Bundle components using Vite
pnpm run build:design-system
3

Background and Popup (parallel)

Build both packages simultaneously since they don’t depend on each other
run-p build:background build:popup
4

Assets

Copy static files to dist/
pnpm run copy-assets

Extension communication

The extension uses multiple communication patterns:
webext-redux keeps popup and background stores in sync:
  • Background hosts the main store
  • Popup has a proxy store
  • Actions from popup are forwarded to background
  • State changes broadcast to all connected popups
Direct communication via browser.runtime.sendMessage:
// From popup
await browser.runtime.sendMessage({ type: 'PING' });

// In background listener
browser.runtime.onMessage.addListener((msg) => {
  if (msg.type === 'PING') return Promise.resolve('PONG');
});
Persistent settings via browser.storage.local:
await browser.storage.local.set({ settings: userPreferences });
const { settings } = await browser.storage.local.get('settings');

Best practices

Do not edit src/core/lib directly. It’s generated from TypeScript sources in src/core/src.
When adding a feature:
  1. Create the use case in src/core/src/use-cases/
  2. Add an epic in src/core/src/controllers/ to orchestrate it
  3. Implement required services in src/background/src/services/
  4. Connect UI components in src/popup/src/components/
Keep styling consistent by using components from the design system. Only create new components when existing ones don’t fit your needs.

Next steps

Building

Learn how to build packages and create extension archives

Testing

Write and run tests for each package

Build docs developers (and LLMs) love