Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Cloudhood is a cross-browser extension (Chrome & Firefox) for managing custom HTTP request headers. Users create profiles containing headers and URL filters, which are applied to web requests via the Declarative Net Request API.

## Commands

```bash
# Development
pnpm dev:chrome # Chrome dev with hot reload
pnpm dev:firefox # Firefox dev with file watch

# Build
pnpm build # Build both browsers
pnpm build:chromium # Chrome only
pnpm build:firefox # Firefox only

# Testing
pnpm test:unit # Unit tests (Vitest)
pnpm test:e2e # E2E tests interactive (Playwright)
pnpm test:e2e:ci # E2E tests in CI mode
pnpm test:e2e:screenshots # Visual regression tests
pnpm test:e2e:screenshots:docker # Visual regression in Docker (used in CI)

# Linting
pnpm lint # ESLint
pnpm lint:css # Stylelint
```

Run a single unit test file: `pnpm test:unit src/shared/utils/__tests__/formatHeaders.test.ts`

Run a single E2E test: `pnpm test:e2e tests/e2e/some-test.spec.ts`

## Architecture

### Feature-Sliced Design (FSD)

The codebase follows FSD with strict layer imports — each layer can only import from layers below it:

```
app → pages → widgets → features → entities → shared
```

### Path Aliases

TypeScript path aliases map to FSD layers: `#app`, `#pages/*`, `#widgets/*`, `#features/*`, `#entities/*`, `#shared/*`. Defined in `tsconfig.json`.

### State Management — Effector

All state is managed with **Effector** (stores, events, effects, `sample`). Each entity/feature has a `model/` directory containing Effector units. Data persists to `browser.storage` via effects.

### Key Domain Model

A **Profile** (`src/entities/request-profile/types.ts`) contains:
- `requestHeaders: RequestHeader[]` — name/value pairs applied to matching requests
- `urlFilters: UrlFilter[]` — URL patterns determining which requests get headers

Headers are applied via Declarative Net Request in `src/shared/utils/setBrowserHeaders.ts`.

### Browser Compatibility

- `webextension-polyfill` wraps Chrome/Firefox API differences
- `src/shared/utils/browserAPI.ts` provides further abstraction (action API v2/v3 fallback)
- Separate manifests: `manifest.chromium.json`, `manifest.firefox.json`
- Build output goes to `build/chrome/` and `build/firefox/`

### Build System

Vite with two build targets:
1. **Popup** — React SPA (entry: `src/index.tsx`)
2. **Background** — Service worker (entry: `src/background.ts`, config: `vite.background.config.ts`)

The `BROWSER` env var (`chrome` | `firefox`) controls which manifest and build output are used.

### UI Components

Uses `@snack-uikit/*` component library with `@emotion/styled` for CSS-in-JS styling.

## Code Style

- Prettier: 2-space indent, 120 print width, single quotes, trailing commas
- ESLint config from `@cloud-ru/eslint-config` with `eslint-plugin-effector`
- Commit messages validated by `@cloud-ru/ft-config-commit-message` (conventional commits)
- Pre-commit hooks via Husky + lint-staged

## Tech Stack

- React 18, TypeScript (strict), Vite, Effector
- pnpm (>=10), Node.js (>=20)
- Playwright (E2E), Vitest (unit), jsdom
3 changes: 2 additions & 1 deletion manifest.chromium.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"permissions": [
"storage",
"declarativeNetRequest",
"declarativeNetRequestFeedback"
"declarativeNetRequestFeedback",
"cookies"
],
"background": {
"service_worker": "background.bundle.js"
Expand Down
10 changes: 2 additions & 8 deletions manifest.dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,8 @@
"48": "img/main-icon-48.jpg",
"128": "img/main-icon-128.jpg"
},
"host_permissions": [
"<all_urls>"
],
"permissions": [
"storage",
"declarativeNetRequest",
"declarativeNetRequestFeedback"
],
"host_permissions": ["<all_urls>"],
"permissions": ["storage", "declarativeNetRequest", "declarativeNetRequestFeedback", "cookies"],
"background": {
"service_worker": "background.bundle.js"
},
Expand Down
3 changes: 2 additions & 1 deletion manifest.firefox.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"storage",
"declarativeNetRequest",
"declarativeNetRequestFeedback",
"activeTab"
"activeTab",
"cookies"
],
"host_permissions": [
"<all_urls>"
Expand Down
18 changes: 11 additions & 7 deletions src/background.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import browser from 'webextension-polyfill';

import type { Profile, RequestHeader } from '#entities/request-profile/types';
import type { Profile, RequestCookie, RequestHeader, UrlFilter } from '#entities/request-profile/types';

import { BrowserStorageKey, ServiceWorkerEvent } from './shared/constants';
import { browserAction } from './shared/utils/browserAPI';
import { logger, LogLevel } from './shared/utils/logger';
import { setBrowserCookies } from './shared/utils/setBrowserCookies';
import { setBrowserHeaders } from './shared/utils/setBrowserHeaders';
import { setIconBadge } from './shared/utils/setIconBadge';
import { enableExtensionReload } from './utils/extension-reload';
Expand Down Expand Up @@ -47,8 +48,11 @@ logger.info('🔍 About to check storage contents...');
// Count active headers for the badge
const selectedProfile = profiles.find((p: Profile) => p.id === result[BrowserStorageKey.SelectedProfile]);
if (selectedProfile) {
activeHeadersCount = selectedProfile.requestHeaders?.filter((h: RequestHeader) => !h.disabled).length || 0;
logger.info(` - Active headers count: ${activeHeadersCount}`);
const activeHeaders = selectedProfile.requestHeaders?.filter((h: RequestHeader) => !h.disabled).length || 0;
const activeCookies = selectedProfile.requestCookies?.filter((c: RequestCookie) => !c.disabled).length || 0;
const activeUrlFilters = selectedProfile.urlFilters?.filter((f: UrlFilter) => !f.disabled).length || 0;
activeHeadersCount = activeHeaders + activeCookies + activeUrlFilters;
logger.info(` - Active rules count: ${activeHeadersCount}`);
}
}
} catch (error) {
Expand Down Expand Up @@ -89,7 +93,7 @@ async function notify(message: ServiceWorkerEvent) {
]);

logger.info('📦 Storage data for reload:', result);
await setBrowserHeaders(result);
await Promise.all([setBrowserHeaders(result), setBrowserCookies(result)]);
}
return undefined;
}
Expand Down Expand Up @@ -128,7 +132,7 @@ browser.runtime.onStartup.addListener(async function () {
if (Object.keys(result).length) {
logger.info('🚀 Storage data found, setting browser headers on startup');
try {
await setBrowserHeaders(result);
await Promise.all([setBrowserHeaders(result), setBrowserCookies(result)]);
} catch (error) {
logger.error('Failed to set browser headers on startup:', error);
}
Expand Down Expand Up @@ -156,7 +160,7 @@ browser.storage.onChanged.addListener(async (changes, areaName) => {
]);
logger.debug('Storage changes data:', result);
try {
await setBrowserHeaders(result);
await Promise.all([setBrowserHeaders(result), setBrowserCookies(result)]);
} catch (error) {
logger.error('Failed to set browser headers on storage change:', error);
}
Expand Down Expand Up @@ -199,7 +203,7 @@ browser.runtime.onInstalled.addListener(async details => {
if (Object.keys(result).length) {
logger.info('🔧 Storage data found, initializing browser headers on install/update');
try {
await setBrowserHeaders(result);
await Promise.all([setBrowserHeaders(result), setBrowserCookies(result)]);
} catch (error) {
logger.error('Failed to set browser headers on install/update:', error);
}
Expand Down
2 changes: 1 addition & 1 deletion src/entities/profile-actions/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createEvent, createStore } from 'effector';

import { selectedRequestProfileIdChanged } from '#entities/request-profile/model/selected-request-profile';

export type ProfileActionsTab = 'headers' | 'url-filters';
export type ProfileActionsTab = 'headers' | 'cookies' | 'url-filters';

export const profileActionsTabChanged = createEvent<ProfileActionsTab>();

Expand Down
8 changes: 8 additions & 0 deletions src/entities/request-profile/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const DEFAULT_REQUEST_HEADERS: Profile[] = [
value: '',
},
],
requestCookies: [
{
id: generateId(),
disabled: false,
name: '',
value: '',
},
],
urlFilters: [
{
id: generateId(),
Expand Down
1 change: 1 addition & 0 deletions src/entities/request-profile/model/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './request-profiles';
export * from './selected-profile-url-filters';
export * from './selected-request-cookies';
export * from './selected-request-headers';
export * from './selected-request-profile';
1 change: 1 addition & 0 deletions src/entities/request-profile/model/request-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const profileAddedFx = attach({
{
id: addedHeaderId,
requestHeaders: [{ id: generateId(), name: '', value: '', disabled: false }],
requestCookies: [{ id: generateId(), name: '', value: '', disabled: false }],
urlFilters: [{ id: generateId(), value: '', disabled: false }],
},
],
Expand Down
19 changes: 19 additions & 0 deletions src/entities/request-profile/model/selected-request-cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { combine } from 'effector';

import { validateCookie } from '#shared/utils/cookies';

import { $requestProfiles } from './request-profiles';
import { $selectedRequestProfile } from './selected-request-profile';

export const $selectedProfileRequestCookies = combine(
$selectedRequestProfile,
$requestProfiles,
(selectedProfileId, profiles) => profiles.find(p => p.id === selectedProfileId)?.requestCookies ?? [],
{ skipVoid: false },
);

export const $selectedProfileActiveRequestCookiesCount = combine(
$selectedProfileRequestCookies,
cookies => cookies.filter(c => !c.disabled && validateCookie(c.name, c.value)).length,
{ skipVoid: false },
);
16 changes: 15 additions & 1 deletion src/entities/request-profile/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@ export type RequestHeader = {
value: string;
disabled: boolean;
};

export type RequestCookie = {
id: number;
name: string;
value: string;
disabled: boolean;
};

export type UrlFilter = {
id: number;
value: string;
disabled: boolean;
};

export type Profile = { id: string; name?: string; requestHeaders: RequestHeader[]; urlFilters: UrlFilter[] };
export type Profile = {
id: string;
name?: string;
requestHeaders: RequestHeader[];
requestCookies: RequestCookie[];
urlFilters: UrlFilter[];
};

export type RemoveHeaderPayload = {
headerId: number;
Expand Down
4 changes: 3 additions & 1 deletion src/features/export-profile/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ export const $profileExportString = combine(
profiles
.filter(({ id }) => selectedExportProfileIdList.includes(id))
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- if the model is extended, this may become an error
.map(({ id, requestHeaders, urlFilters, ...rest }) => ({
.map(({ id, requestHeaders, requestCookies, urlFilters, ...rest }) => ({
...rest,
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- if the model is extended, this may become an error
requestHeaders: requestHeaders.map(({ id, ...headerRest }) => headerRest),
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- if the model is extended, this may become an error
requestCookies: (requestCookies ?? []).map(({ id, ...cookieRest }) => cookieRest),
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- if the model is extended, this may become an error
urlFilters: urlFilters?.map(({ id, ...filterRest }) => filterRest) || [],
})) || [],
);
Expand Down
28 changes: 28 additions & 0 deletions src/features/selected-profile-request-cookies/add/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { attach, createEvent, sample } from 'effector';

import { $requestProfiles, $selectedRequestProfile, profileUpdated } from '#entities/request-profile/model';
import { RequestCookie } from '#entities/request-profile/types';
import { generateId } from '#shared/utils/generateId';

type SelectedProfileRequestCookiesAdded = Omit<RequestCookie, 'id'>[];

export const selectedProfileRequestCookiesAdded = createEvent<SelectedProfileRequestCookiesAdded>();

const selectedProfileRequestCookiesAddedFx = attach({
source: { profiles: $requestProfiles, selectedProfile: $selectedRequestProfile },
effect: ({ profiles, selectedProfile }, requestCookies: SelectedProfileRequestCookiesAdded) => {
const profile = profiles.find(p => p.id === selectedProfile);

if (!profile) {
throw new Error('Profile not found');
}

return {
...profile,
requestCookies: [...(profile.requestCookies ?? []), ...requestCookies.map(c => ({ ...c, id: generateId() }))],
};
},
});

sample({ clock: selectedProfileRequestCookiesAdded, target: selectedProfileRequestCookiesAddedFx });
sample({ clock: selectedProfileRequestCookiesAddedFx.doneData, target: profileUpdated });
25 changes: 25 additions & 0 deletions src/features/selected-profile-request-cookies/remove/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { attach, createEvent, sample } from 'effector';

import { $requestProfiles, $selectedRequestProfile, profileUpdated } from '#entities/request-profile/model';
import { RequestCookie } from '#entities/request-profile/types';

export const selectedProfileRequestCookiesRemoved = createEvent<RequestCookie['id'][]>();

const selectedProfileRequestCookiesRemovedFx = attach({
source: { profiles: $requestProfiles, selectedProfile: $selectedRequestProfile },
effect: ({ profiles, selectedProfile }, cookieIds: RequestCookie['id'][]) => {
const profile = profiles.find(p => p.id === selectedProfile);

if (!profile) {
throw new Error('Profile not found');
}

return {
...profile,
requestCookies: (profile.requestCookies ?? []).filter(c => !cookieIds.includes(c.id)),
};
},
});

sample({ clock: selectedProfileRequestCookiesRemoved, target: selectedProfileRequestCookiesRemovedFx });
sample({ clock: selectedProfileRequestCookiesRemovedFx.doneData, target: profileUpdated });
Loading
Loading