Refactor VPN proxy components and update related behavior
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 01:27:46 +03:00
parent c89e56942a
commit aa9c959368
58 changed files with 4234 additions and 2755 deletions
+10 -10
View File
@@ -1,6 +1,6 @@
# Harbor
Harbor помогает пользоваться одной VPN-подпиской дома и на Mac без ручной настройки `sing-box`.
Harbor помогает пользоваться несколькими VPN-подписками дома и на Mac без ручной настройки `sing-box`.
Проект работает в двух режимах:
@@ -9,7 +9,7 @@ Harbor помогает пользоваться одной VPN-подписко
| **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси |
| **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный HTTP/SOCKS5-прокси |
В Harbor Connect подписка и выбор сервера остаются на основном экране. Harbor Gateway открывается как панель маршрутизации даже без подписки: Home, «Устройства» и «Диагностика» доступны сразу, а VPN-подписка настраивается отдельной верхней кнопкой в правой панели.
Основной экран Connect и Gateway всегда показывает фактически применённые подписку и сервер. Управление подписками открывается отдельной верхней кнопкой в правой панели; Home, «Устройства» и «Диагностика» Gateway доступны и без подписки.
## Что понадобится
@@ -62,10 +62,10 @@ http://АДРЕС-GATEWAY:3456
### 4. При необходимости добавьте подписку
1. Нажмите «Подписка» — верхнюю кнопку в правой панели Gateway.
2. Вставьте ссылку VPN-подписки.
3. Нажмите «Сохранить подписку».
4. Выберите сервер.
1. Нажмите «Подписки» — верхнюю кнопку в правой панели Gateway.
2. Нажмите «Добавить подписку», задайте понятное имя и вставьте ссылку VPN-провайдера.
3. Выберите сервер внутри добавленной группы.
4. При нескольких группах выберите нужную действием «Сделать активной».
5. Включите VPN.
После подключения Harbor покажет два варианта использования:
@@ -114,7 +114,7 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
### 3. Добавьте подписку
Откройте `http://127.0.0.1:3456`, вставьте ссылку подписки, выберите сервер и включите VPN.
Откройте `http://127.0.0.1:3456`, добавьте подписку с понятным именем, выберите сервер внутри её группы и включите VPN. Остальные подписки можно добавить через правую панель «Подписки»; у каждой сохраняются собственные серверы, лимит и выбор.
Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS.
@@ -167,15 +167,15 @@ networksetup -setsocksfirewallproxystate Wi-Fi off
## Автоматическое использование домашнего Gateway
Harbor Connect раз в пять секунд узнаёт у macOS адрес текущего основного шлюза. Если по этому адресу работает Harbor Gateway с той же VPN-подпиской, Connect оставляет локальный прокси доступным для приложений, но не создаёт второй VPN-маршрут: трафик уже обрабатывает Gateway.
Harbor Connect раз в пять секунд узнаёт у macOS адрес текущего основного шлюза. Если по этому адресу работает Harbor Gateway с той же выбранной VPN-подпиской, Connect оставляет локальный прокси доступным для приложений, но не создаёт второй VPN-маршрут: трафик уже обрабатывает Gateway.
Для этого:
1. добавьте одну и ту же ссылку подписки в Gateway и Connect;
1. добавьте одну и ту же ссылку подписки в Gateway и Connect и выберите соответствующий профиль на обоих устройствах;
2. убедитесь, что Mac может открыть интерфейс Gateway на порту `3456`;
3. оставьте автоматический режим включённым в Harbor Connect.
Ссылка должна содержать персональный секрет или token длиной не менее 16 символов — обычные ссылки подписок уже соответствуют этому условию. Ссылка между устройствами не передаётся: она используется локально для проверки, что Connect нашёл именно ваш Gateway. При смене сети или после трёх неудачных проверок Connect возвращается к локальному VPN.
Ссылка должна содержать персональный секрет или token длиной не менее 16 символов — обычные ссылки подписок уже соответствуют этому условию. Ссылка между устройствами не передаётся: она используется локально для проверки, что Connect нашёл именно ваш Gateway. До отдельного pairing-flow Connect не получает от Gateway имя фактически применённых подписки и сервера, поэтому в режиме Gateway честно показывает `Gateway · сервер не определён`. При смене сети или после трёх неудачных проверок Connect возвращается к локальному VPN.
## Повседневные команды
+57 -44
View File
@@ -1,44 +1,30 @@
# Harbor application state v1
`GET /api/state` is the canonical Harbor domain snapshot. Successful POST and DELETE endpoints return the same snapshot as `state` while retaining their v0 response fields for compatibility.
`GET /api/state` is the canonical Harbor domain snapshot. Successful mutations return the same snapshot as `state`. The persisted owner is `state.json` schema v5; React keeps only drafts, disclosure, focus, animation and transport freshness.
An abbreviated snapshot:
```json
{
"apiVersion": 1,
"revision": 42,
"generatedAt": "2026-07-11T15:00:00.000Z",
"mode": "client",
"profiles": [
{
"id": "profile_primary",
"label": "Личный",
"subscription": {
"status": "ready",
"host": "provider.example/…",
"fetchedAt": "2026-07-11T14:58:00.000Z",
"userInfo": {}
},
"selection": {
"desiredServerId": "srv_4d7c5d1bcd60d665",
"appliedServerId": "srv_4d7c5d1bcd60d665"
},
"connection": {
"desired": "running",
"process": "running",
"startedAt": "2026-07-11T14:59:10.000Z",
"lastError": null
},
"route": {
"mode": "local-vpn",
"gatewayAddress": null,
"lastVerifiedAt": null,
"reason": "auto"
},
"operation": {
"kind": null,
"status": "idle",
"startedAt": null,
"error": null
"fetchedAt": "2026-08-11T12:00:00.000Z",
"userInfo": {},
"lastRefreshAttemptAt": null,
"errorCode": null
},
"desiredServerId": "srv_amsterdam",
"servers": [
{
"id": "srv_4d7c5d1bcd60d665",
"id": "srv_amsterdam",
"label": "Amsterdam",
"host": "nl.example.net",
"port": 443,
@@ -46,40 +32,67 @@
}
]
}
],
"selection": {
"desiredProfileId": "profile_primary",
"desiredServerId": "srv_amsterdam",
"appliedProfileId": "profile_primary",
"appliedServerId": "srv_amsterdam",
"appliedServerSnapshot": {
"id": "srv_amsterdam",
"label": "Amsterdam",
"host": "nl.example.net",
"port": 443,
"protocol": "vless"
}
},
"operation": {
"kind": null,
"status": "idle",
"startedAt": null,
"error": null,
"profileId": null,
"serverId": null
}
}
```
The backend owns subscription metadata, servers, desired/applied selection, desired/process connection state, route and current operation. React may keep only unsaved form values, pending selection and visual state. Browser transport freshness is not part of this contract.
Every profile owns one private provider URL/config, public metadata, server list and desired server. The URL/config never enters the public snapshot. `subscription` and top-level `servers` remain a one-release projection of the desired profile for older clients; they are not a second owner.
## Revision rules
`revision` is persisted in the existing `state.json` and increases on externally visible transitions, including operation start/completion/failure, import, refresh, forget, apply, start, stop and Gateway Auto changes. `generatedAt` is response metadata and does not change revision by itself.
`revision` increases for every visible domain transition, including operation start, completion and failure. Commands carry `expectedRevision`; stale commands fail with `STATE_CONFLICT`. Duplicate profile labels are rejected by preflight without a provider request or revision change.
A consumer must eventually apply only snapshots whose revision is at least its current revision. The frontend comparison and stale/offline transport envelope are intentionally handled by TASK-002 and TASK-003.
The frontend accepts only newer snapshots. Equal revisions preserve object identity, and older polling responses cannot overwrite mutation results. After a failed mutation the browser immediately synchronizes the authoritative snapshot before allowing another command or retry.
The frontend keeps the accepted snapshot in one reducer and replaces it only when `incoming.revision` is greater. Equal revisions preserve object identity so background polling does not replay decorative transitions. Mutation responses are applied directly; polling requests started before a mutation are logically invalidated and cannot overwrite its result. A locally pending server choice remains local until a newer snapshot acknowledges it or removes that server.
Browser boot/offline/stale state remains a transport envelope beside the domain snapshot. A transport failure retains the last accepted domain state.
Browser transport state lives beside, not inside, the domain snapshot. It records boot status, last successful sync time and consecutive failures. Three failed polls mark the retained snapshot stale; the next successful GET or mutation clears that marker. An initial failure shows `control-unreachable`, `incompatible-api` or `fatal` without inventing domain state.
## Desired and applied identity
Gateway discovery follows the same retain-and-mark-stale rule. Once a concrete default Gateway has been verified, transient presence failures or a briefly stale macOS route snapshot keep `gateway-direct` active and report `route.reason = gateway-stale`; they do not restart sing-box into `local-vpn`. Local routing resumes only after the user disables Gateway mode or macOS reports a different default Gateway identity.
`desiredProfileId` and each profile's `desiredServerId` record the next local choice. `appliedProfileId`, `appliedServerId` and `appliedServerSnapshot` describe the runtime that actually owns traffic. There is no third `activeProfileId`.
## Desired and applied state
While stopped, selecting or activating a profile only updates desired state. While running, changing the applied profile/server builds a candidate config, starts it, then publishes desired and applied identity in one final state commit. Until that commit the old applied pair remains authoritative. A failure restores the previous config, runtime and state.
`selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded.
If refresh removes the applied server, the running process is not silently switched. The provider list and desired selection are cleared as needed, while `appliedServerSnapshot` retains the last applied label until explicit stop or a successful switch. Stop clears applied identity and keeps the desired pair.
Server IDs are deterministic from normalized protocol, host and port, while provider order and the human-readable `label` are separate. Duplicate labels remain separate servers; reorder and cosmetic rename keep the same ID. Ping results, React keys, persisted selection and apply commands use the ID. If the selected endpoint disappears, Harbor stops the active process, clears selection and requires an explicit new choice instead of silently switching traffic.
## Profile operations
## Subscription import and refresh
The canonical API is scoped by profile:
The browser validates only the shape and `http`/`https` protocol of a subscription URL. The provider is contacted once, after explicit submit. The backend fetches and parses the complete response before entering the serialized commit.
- `POST /api/profiles` adds a profile after one explicit provider fetch;
- `PATCH /api/profiles/:id` renames it locally;
- `PUT /api/profiles/:id/server` selects one of its servers;
- `POST /api/profiles/:id/activate` activates/switches it;
- `POST /api/profiles/:id/refresh` refreshes only that provider;
- `DELETE /api/profiles/:id` deletes it, with explicit `stop-and-delete` for a running applied profile;
- `POST /api/profiles/:id/servers/ping` performs bounded transient health checks.
Import and refresh share one commit path. It prepares the candidate server list and sing-box config first, then updates cache, config, runtime and canonical state. If provider fetch, parsing, config validation or runtime apply fails, the previous subscription cache, selected server, config and running process remain active. Refreshes for the saved URL share one in-flight Promise; a refresh that finishes after another import is rejected with `STATE_CONFLICT` instead of overwriting the newer subscription.
The existing background refresh remains every 15 minutes. Provider requests time out after 15 seconds by default (`SUBSCRIPTION_TIMEOUT_MS` may override it). A failed background refresh logs a redacted warning and keeps the last successful subscription snapshot.
Provider failure retains the last successful list and metadata, marks only the target profile stale and records the last successful timestamp. Refreshing, pinging or deleting an inactive profile does not mutate the applied config/runtime. Background refresh iterates profiles independently every 15 minutes.
## Compatibility and migration
No path, volume or file is renamed. A legacy `state.json` without stable IDs is migrated to schema v4. A unique `selectedTag` is matched to its normalized endpoint and stored as `selectedServerId`/`appliedServerId`; an ambiguous or missing tag explicitly clears selection. The raw provider config remains unchanged in subscription cache and is normalized only in memory, so an older Harbor build can still use its original tags after rollback. Existing unknown fields remain untouched.
Schema v5 migrates the legacy singleton and `subscription-cache.json` into one profile named `Основной`. Stable endpoint identity preserves unambiguous desired/applied selection, including transport variants whose normalized IDs differ from old labels. An explicitly stopped legacy state does not resurrect an old applied target.
During the v0 compatibility window, the snapshot also exposes `selectedTag`, `singboxRunning`, `servers[].tag`, `gatewayAuto` and the other previous GET fields. Mutation responses retain their previous result fields and add `state`. The canonical `subscription` object never contains the full subscription URL.
Migration atomically backs up the previous `state.json`. After the embedded profile is committed, Harbor also backs up and removes the legacy subscription cache so there is one persisted owner. Invalid legacy cache/config returns to a truthful stopped first-run state instead of starting stale generated config.
Rollback is code-only: deploy the previous build. The v4 state keeps `selectedTag`, `appliedTag` and server aliases for older builds, while subscription cache keeps raw provider tags. The added ID fields are ignored by the previous implementation.
The old HTTP projection remains bounded for one release, but schema v5 persistence is not downgrade-compatible. To roll back to a pre-v5 build, stop Harbor and restore both pre-migration backups rather than deploying old code over v5 data.
+4 -4
View File
@@ -3,14 +3,14 @@
Harbor tracks active browser mutations by operation key instead of one global `busy` flag:
- `connection`: start, stop and restart;
- `serverApply`: apply the selected server;
- `subscriptionImport`, `subscriptionRefresh`, `subscriptionDelete`;
- `serverApply`: apply a `(profileId, serverId)` pair;
- `profileAdd`, `profileRename`, `profileSelect`, `profileActivate`, `profileRefresh`, `profileDelete`;
- `gatewayAuto`: change the active route preference.
Each entry is `{ status: "running", startedAt }`. A repeated operation key receives the same in-flight Promise, so a double click sends one request. A conflicting key resolves to `false` without starting its action. The symmetric conflict matrix lives in `src/web/state/operations.ts`.
The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure.
Subscription URL validation is local and accepts only well-formed `http` and `https` URLs. It does not contact the provider; the explicit import operation performs the single provider request and reports provider failures through the structured subscription error.
Subscription URL validation is local and accepts only well-formed `http` and `https` URLs. It does not contact the provider; explicit profile add performs the single provider request and reports provider failures at that profile.
The registry is local transport/UI state. It does not replace backend `snapshot.operation`, change revisions or persist data. Rollback is frontend-only. A `diagnostics` key is intentionally deferred until TASK-016 adds a diagnostics operation to run.
The registry is local transport/UI state for immediate feedback. It does not replace backend `snapshot.operation`, which preserves the target across polling, reloads and other windows. A `diagnostics` key is intentionally deferred until diagnostics become a conflicting mutation.
+25 -13
View File
@@ -1,36 +1,48 @@
# Harbor state recovery
Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 4`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 introduced locally managed domain routing rules. Schema v3 added rule `enabled` state. Schema v4 adds stable server IDs and migrates an unambiguous legacy `selectedTag` to `selectedServerId`.
Harbor keeps the existing data directory and `state.json` path. The current persisted format is `schemaVersion: 5`: schema v2 introduced local route rules, v3 added rule enabled state, v4 added stable server IDs, and v5 embeds the canonical `profiles[]` collection with desired/applied profile identity.
## Atomic writes
Persistent files are written to a unique temporary file in the same directory, flushed with `fsync`, closed and atomically renamed over the target. A failure before rename leaves the previous target untouched and removes the temporary file.
## Migration
Profile/server switching prepares candidate config and runtime before the final state publication. If any later step fails, Harbor restores the previous config, runtime and canonical state.
On startup, a legacy `state.json` without `schemaVersion`, or any v1-v3 state, is normalized and migrated to the current schema. Existing custom rules are preserved. Server identity is derived from protocol, host and port; a unique legacy tag keeps selection, while duplicate or missing matches require a new explicit choice. Before replacement Harbor saves the original beside it:
## Migration to profiles
On startup, a legacy state is normalized before the process starts. Harbor creates one profile named `Основной`, moves the provider URL/config and metadata into it, and preserves unambiguous desired/applied server identity. A legacy state explicitly marked stopped clears stale applied residue.
Before replacing state Harbor saves the original beside it:
```text
state.json.backup-v0-2026-07-11T12-00-00-000Z
state.json.backup-v4-2026-08-11T12-00-00-000Z
```
The migration preserves compatibility aliases, adds normalized revision, selection and server fields, and does not rename the volume. Subscription cache keeps the raw provider config so older builds can still use its original outbound tags. The backup remains the safest manual recovery source.
After a valid profile has been committed, the raw legacy cache is saved and removed as a second owner:
```text
subscription-cache.json.backup-v1-2026-08-11T12-00-00-000Z
```
An invalid legacy provider config is backed up but not started. Harbor removes stale generated config and returns to a stopped first-run state.
## Corrupt JSON
If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to:
```text
state.json.corrupt-2026-07-11T12-00-00-000Z
state.json.corrupt-2026-08-11T12-00-00-000Z
```
It then creates a valid empty current-schema state and reports `storage-recovery` through `snapshot.operation`. A corrupt subscription cache is preserved with the same suffix and reported in control logs.
It then creates a valid empty current-schema state and reports storage recovery. A corrupt legacy subscription cache is preserved with the same suffix and is never used to start stale config.
Recovery should be performed while Harbor is stopped:
## Manual recovery and downgrade
1. Copy the whole data directory before changing anything.
2. Inspect a backup with `jq . <backup-file>`.
3. Restore only a valid JSON backup to the original filename.
4. Start Harbor and verify `GET /api/state` before applying or importing anything.
Perform recovery while Harbor is stopped:
Generated config rollback also uses the atomic writer. No automatic recovery tries to guess missing subscription credentials or repair semantically invalid sing-box configuration.
1. Copy the whole data directory.
2. Inspect the intended backup with `jq . <backup-file>`.
3. Restore only matching state/cache backups to their original filenames.
4. Start Harbor and verify `GET /api/state` before applying a profile.
A pre-v5 binary cannot interpret canonical profiles. Downgrade therefore requires both pre-migration backups; deploying old code alone is not a safe rollback.
@@ -1,4 +1,8 @@
import type { StoredState } from '../../../shared/contracts/state.js';
import {
profileById,
type StoredProfile,
type StoredState,
} from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import { finishRollback } from '../../services/rollback.js';
@@ -7,11 +11,7 @@ interface ConnectionServiceDependencies {
read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
subscription: {
readConfig(): unknown | null;
};
config: {
exists(): boolean;
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
read(): string | null;
write(value: unknown): void;
@@ -25,6 +25,7 @@ interface ConnectionServiceDependencies {
stopCommand(): Promise<RuntimeCommandResult>;
restartCommand(): Promise<RuntimeCommandResult>;
};
route?: { isGatewayDirect(): boolean };
serialize<T>(operation: () => Promise<T>): Promise<T>;
now(): Date;
}
@@ -46,65 +47,125 @@ export async function captureRuntimeCommand(
}
}
function requireExpectedRevision(state: StoredState, expectedRevision: unknown) {
if (expectedRevision === undefined) return;
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) {
throw new HarborError('STATE_CONFLICT');
}
}
function resolveProfile(state: StoredState, profileId: unknown): StoredProfile {
const requested = String(profileId || '').trim();
const profile = profileById(state, requested)
|| (!requested && state.profiles.length === 1 ? state.profiles[0] : null);
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
return profile;
}
function withDesiredServer(state: StoredState, profile: StoredProfile, serverId: string) {
const nextProfile = { ...profile, desiredServerId: serverId };
return {
...state,
profiles: state.profiles.map((candidate) => candidate.id === profile.id ? nextProfile : candidate),
desiredProfileId: profile.id,
};
}
export function createConnectionService(dependencies: ConnectionServiceDependencies) {
const apply = (serverId: unknown, selectedTag: unknown) => dependencies.serialize(async () => {
const previousState = dependencies.state.read();
const requestedId = String(serverId).trim();
const requestedTag = String(selectedTag).trim();
const applyWithinQueue = async (
previousState: StoredState,
profile: StoredProfile,
serverIdValue: unknown,
selectedTagValue: unknown,
) => {
const requestedId = String(serverIdValue || '').trim();
const requestedTag = String(selectedTagValue || '').trim();
const resolvedId = requestedId || (() => {
const matches = previousState.servers.filter((server) => server.label === requestedTag);
const matches = profile.servers.filter((server) => server.label === requestedTag);
return matches.length === 1 ? matches[0].id : '';
})();
const selectedServer = previousState.servers.find((server) => server.id === resolvedId);
const selectedServer = profile.servers.find((server) => server.id === resolvedId);
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
if (!profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID');
if (dependencies.route?.isGatewayDirect()) {
dependencies.state.update((state) => withDesiredServer(state, profile, selectedServer.id));
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
}
const subscriptionConfig = dependencies.subscription.readConfig();
if (!subscriptionConfig) throw new HarborError('CONFIG_INVALID');
const nextConfig = dependencies.config.build(
subscriptionConfig,
profile.subscriptionConfig,
selectedServer.id,
previousState.routeRules,
);
const previousConfig = dependencies.config.read();
const wasRunning = await dependencies.runtime.isRunning();
let desiredCommitStarted = false;
let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
try {
desiredCommitStarted = true;
dependencies.state.update((state) => ({
...state,
selectedServerId: selectedServer.id,
connectionDesired: 'running',
}));
configMutationStarted = true;
dependencies.config.write(nextConfig);
runtimeMutationStarted = true;
await dependencies.runtime.start();
stateCommitStarted = true;
dependencies.state.update((state) => ({
...state,
...withDesiredServer(state, profile, selectedServer.id),
connectionDesired: 'running',
appliedProfileId: profile.id,
appliedServerId: selectedServer.id,
appliedServerSnapshot: selectedServer,
appliedAt: dependencies.now().toISOString(),
appliedRouteRules: state.routeRules,
}));
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
...(configMutationStarted ? [{
run: () => previousConfig === null
? dependencies.config.remove()
: dependencies.config.restore(previousConfig),
}] : []),
...(configMutationStarted ? [{
...(runtimeMutationStarted ? [{
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
runtime: true,
}] : []),
...(desiredCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
], 'Connection rollback failed');
}
return { serverId: selectedServer.id, selectedTag: selectedServer.label };
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
};
const apply = (
profileId: unknown,
serverId: unknown,
selectedTag: unknown = '',
expectedRevision?: unknown,
) => dependencies.serialize(async () => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
return applyWithinQueue(state, resolveProfile(state, profileId), serverId, selectedTag);
});
const activate = (profileId: unknown, expectedRevision?: unknown) => (
dependencies.serialize(async () => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
const profile = resolveProfile(state, profileId);
const selectedServer = profile.servers.find((server) => server.id === profile.desiredServerId);
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
const running = await dependencies.runtime.isRunning();
if (!running || dependencies.route?.isGatewayDirect()) {
if (state.desiredProfileId !== profile.id) {
dependencies.state.update((current) => ({ ...current, desiredProfileId: profile.id }));
}
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
}
return applyWithinQueue(state, profile, selectedServer.id, '');
})
);
const stop = () => dependencies.serialize(async () => {
const previousState = dependencies.state.read();
let wasRunning: boolean | null = null;
@@ -119,7 +180,13 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error;
stateCommitStarted = true;
dependencies.state.update((state) => ({ ...state, connectionDesired: 'stopped' }));
dependencies.state.update((state) => ({
...state,
connectionDesired: 'stopped',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
}));
} catch (error) {
await finishRollback(error, [
...(runtimeMutationStarted && wasRunning !== null ? [{
@@ -133,28 +200,53 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
const restart = () => dependencies.serialize(async () => {
const previousState = dependencies.state.read();
if (!dependencies.config.exists()) throw new HarborError('CONFIG_INVALID');
let wasRunning: boolean | null = null;
try {
wasRunning = await dependencies.runtime.isRunning();
} catch {}
const wasRunning = await dependencies.runtime.isRunning();
const targetProfileId = wasRunning
? previousState.appliedProfileId
: previousState.desiredProfileId;
const targetServerId = wasRunning
? previousState.appliedServerId
: resolveProfile(previousState, targetProfileId).desiredServerId;
const profile = resolveProfile(previousState, targetProfileId);
const server = profile.servers.find((candidate) => candidate.id === targetServerId)
|| (previousState.appliedServerSnapshot?.id === targetServerId
? previousState.appliedServerSnapshot
: null);
if (!server || !profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID');
const candidateConfig = dependencies.config.build(
profile.subscriptionConfig,
server.id,
previousState.routeRules,
);
const previousConfig = dependencies.config.read();
let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
try {
configMutationStarted = true;
dependencies.config.write(candidateConfig);
const command = await dependencies.runtime.restartCommand();
runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error;
stateCommitStarted = true;
dependencies.state.update((state) => ({
...state,
appliedServerId: state.selectedServerId,
desiredProfileId: wasRunning ? state.desiredProfileId : profile.id,
appliedProfileId: profile.id,
appliedServerId: server.id,
appliedServerSnapshot: server,
connectionDesired: 'running',
appliedRouteRules: state.routeRules,
}));
} catch (error) {
await finishRollback(error, [
...(runtimeMutationStarted && wasRunning !== null ? [{
...(configMutationStarted ? [{
run: () => previousConfig === null
? dependencies.config.remove()
: dependencies.config.restore(previousConfig),
}] : []),
...(runtimeMutationStarted ? [{
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
runtime: true,
}] : []),
@@ -163,7 +255,7 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
}
});
return { apply, stop, restart };
return { apply, activate, stop, restart };
}
export type ConnectionService = ReturnType<typeof createConnectionService>;
@@ -1,12 +1,11 @@
interface DiagnosticServer {
id: unknown;
label: unknown;
}
import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js';
interface DiagnosticState {
desiredProfileId?: unknown;
appliedProfileId?: unknown;
appliedServerId?: unknown;
selectedServerId?: unknown;
servers?: DiagnosticServer[];
appliedServerSnapshot?: HarborServer | null;
profiles?: StoredProfile[];
}
interface DiagnosticsResult extends Record<string, unknown> {
@@ -25,15 +24,24 @@ function diagnosticsResult(value: unknown): DiagnosticsResult {
return value as DiagnosticsResult;
}
function selectedServer(state: DiagnosticState) {
const profiles = Array.isArray(state.profiles) ? state.profiles : [];
const appliedProfile = profiles.find((profile) => profile.id === state.appliedProfileId);
const applied = appliedProfile?.servers.find((server) => server.id === state.appliedServerId)
|| (state.appliedServerSnapshot?.id === state.appliedServerId
? state.appliedServerSnapshot
: null);
if (state.appliedServerId) return applied;
const desiredProfile = profiles.find((profile) => profile.id === state.desiredProfileId);
return desiredProfile?.servers.find((server) => server.id === desiredProfile.desiredServerId) || null;
}
export function createConnectivityDiagnosticsUseCase(
dependencies: ConnectivityDiagnosticsDependencies,
) {
return {
async run(services: unknown, target: unknown) {
const state = dependencies.readState();
const appliedServerId = state.appliedServerId || state.selectedServerId;
const selected = (Array.isArray(state.servers) ? state.servers : [])
.find((server) => server.id === appliedServerId);
const selected = selectedServer(dependencies.readState());
const server = selected ? { id: selected.id, label: selected.label } : null;
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
return {
@@ -1,6 +1,12 @@
import { isDeepStrictEqual } from 'node:util';
import type { GatewayAutoState, StoredState } from '../../../shared/contracts/state.js';
import {
appliedProfile,
desiredProfile,
type GatewayAutoState,
type StoredState,
} from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import type { RuntimeCommandResult } from '../connection/index.js';
import { finishRollback } from '../../services/rollback.js';
@@ -25,7 +31,7 @@ interface GatewayAutoServiceDependencies {
read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
subscription: { readConfig(): unknown | null };
subscription: { readConfig(profileId: string): unknown | null };
config: {
build(
subscriptionConfig: unknown,
@@ -42,6 +48,7 @@ interface GatewayAutoServiceDependencies {
isRunning(): boolean;
applyCommand(): Promise<RuntimeCommandResult>;
restoreRunning(): Promise<unknown>;
stopCommand(): Promise<RuntimeCommandResult>;
};
discovery: {
readHostNetwork(): HostNetworkState | null;
@@ -108,22 +115,76 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
const previousGatewayAuto = current;
const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate);
const modeChanged = previousGatewayAuto.mode !== candidate.mode;
const leavesGatewayDirect = previousGatewayAuto.mode === 'gateway-direct'
&& candidate.mode !== 'gateway-direct';
if (!stateChanged && persistEnabled === undefined) return current;
const previousState = dependencies.state.read();
const subscriptionConfig = modeChanged
? dependencies.subscription.readConfig()
const wasRunning = modeChanged ? dependencies.runtime.isRunning() : false;
const targetProfile = wasRunning
? appliedProfile(previousState)
: desiredProfile(previousState);
const targetServerId = wasRunning
? previousState.appliedServerId
: targetProfile?.desiredServerId || '';
const subscriptionConfig = modeChanged && targetProfile
? dependencies.subscription.readConfig(targetProfile.id)
: null;
const candidateConfig = modeChanged && previousState.selectedServerId && subscriptionConfig
? dependencies.config.build(
const stopUnavailableTarget = async (cause: unknown) => {
let runtimeMutationStarted = false;
let gatewayAutoPublished = false;
let stateCommitStarted = false;
try {
const command = await dependencies.runtime.stopCommand();
runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error;
current = candidate;
gatewayAutoPublished = true;
stateCommitStarted = true;
dependencies.state.update((state) => ({
...state,
connectionDesired: 'stopped',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
...(persistEnabled === undefined ? {} : { gatewayAutoEnabled: persistEnabled }),
}));
} catch (error) {
await finishRollback(error, [
...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []),
...(runtimeMutationStarted ? [{
run: () => dependencies.runtime.restoreRunning(),
runtime: true,
}] : []),
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
], 'Gateway auto safe-stop rollback failed');
}
dependencies.onDiscoveryWarning(errorMessage(cause));
if (modeChanged) dependencies.onRouteChange(candidate);
throw cause;
};
if (modeChanged && wasRunning && (!targetProfile || !targetServerId || !subscriptionConfig)) {
if (leavesGatewayDirect) return stopUnavailableTarget(new HarborError('CONFIG_INVALID'));
throw new HarborError('CONFIG_INVALID');
}
let candidateConfig: unknown | null = null;
if (modeChanged && targetServerId && subscriptionConfig) {
try {
candidateConfig = dependencies.config.build(
subscriptionConfig,
previousState.selectedServerId,
targetServerId,
previousState.routeRules,
candidate,
)
: null;
);
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
if (wasRunning && leavesGatewayDirect && ['CONFIG_INVALID', 'SERVER_NOT_FOUND'].includes(code)) {
return stopUnavailableTarget(error);
}
throw error;
}
}
const previousConfig = candidateConfig === null ? null : dependencies.config.read();
const wasRunning = candidateConfig === null ? false : dependencies.runtime.isRunning();
let configMutationStarted = false;
let runtimeMutationStarted = false;
let gatewayAutoPublished = false;
@@ -171,7 +232,9 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
const runRefresh = async ({ reconfigure = true }: RefreshOptions) => {
const state = dependencies.state.read();
const network = state.subscriptionUrl
const profile = desiredProfile(state);
const subscriptionUrl = profile?.subscriptionUrl || '';
const network = subscriptionUrl
? dependencies.discovery.readHostNetwork()
: null;
@@ -182,7 +245,7 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
error: discoveryError,
});
const candidate = dependencies.transition.applyPreference(
state.subscriptionUrl
subscriptionUrl
? { ...discoveredState, lastError: discoveryError }
: discoveredState,
state.gatewayAutoEnabled !== false,
@@ -204,16 +267,17 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
try {
verifiedGateway = await dependencies.discovery.probeGateway({
gateway: network.gateway,
subscriptionUrl: String(state.subscriptionUrl),
subscriptionUrl,
});
} catch (error) {
const reason = errorMessage(error);
const latestState = dependencies.state.read();
const latestNetwork = latestState.subscriptionUrl
const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
const latestNetwork = latestSubscriptionUrl
? dependencies.discovery.readHostNetwork()
: null;
if (
latestState.subscriptionUrl !== state.subscriptionUrl ||
latestSubscriptionUrl !== subscriptionUrl ||
!dependencies.transition.sameRoute(network, latestNetwork)
) {
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
@@ -232,11 +296,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
}
const latestState = dependencies.state.read();
const latestNetwork = latestState.subscriptionUrl
const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
const latestNetwork = latestSubscriptionUrl
? dependencies.discovery.readHostNetwork()
: null;
if (
latestState.subscriptionUrl !== state.subscriptionUrl ||
latestSubscriptionUrl !== subscriptionUrl ||
!dependencies.transition.sameRoute(network, latestNetwork)
) {
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
@@ -1,6 +1,11 @@
import { isDeepStrictEqual } from 'node:util';
import type { RouteRule, StoredState } from '../../../shared/contracts/state.js';
import {
appliedProfile,
desiredProfile,
type RouteRule,
type StoredState,
} from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import { normalizeRouteRules } from '../../../shared/routingRules.js';
import type { RuntimeCommandResult } from '../connection/index.js';
@@ -11,7 +16,7 @@ interface RouteRulesDependencies {
read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
subscription: { readConfig(): unknown | null };
subscription: { readConfig(profileId: string): unknown | null };
config: {
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
read(): string | null;
@@ -30,8 +35,17 @@ interface RouteRulesDependencies {
export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
const subscriptionConfig = dependencies.subscription.readConfig();
if (!previousState.selectedServerId || !subscriptionConfig) {
const wasRunning = await dependencies.runtime.isRunning();
const targetProfile = wasRunning
? appliedProfile(previousState)
: desiredProfile(previousState);
const targetServerId = wasRunning
? previousState.appliedServerId
: targetProfile?.desiredServerId || '';
const subscriptionConfig = targetProfile
? dependencies.subscription.readConfig(targetProfile.id)
: null;
if (!targetServerId || !subscriptionConfig) {
let stateCommitStarted = false;
try {
stateCommitStarted = true;
@@ -50,11 +64,10 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const candidateConfig = dependencies.config.build(
subscriptionConfig,
previousState.selectedServerId,
targetServerId,
routeRules,
);
const previousConfig = dependencies.config.read();
const wasRunning = await dependencies.runtime.isRunning();
let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
+11 -6
View File
@@ -1,4 +1,5 @@
import type { HarborServer } from '../../../shared/contracts/state.js';
import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
export const SERVER_HEALTH_MAX_COUNT = 30;
export const SERVER_HEALTH_CONCURRENCY = 4;
@@ -36,19 +37,23 @@ export async function checkServerHealth(
}
interface ServerHealthDependencies {
readServers(): HarborServer[];
readProfiles(): StoredProfile[];
readDesiredProfileId(): string;
ping: Ping;
}
export function createServerHealthService(dependencies: ServerHealthDependencies) {
return {
check(serverIds: unknown) {
check(profileIdValue: unknown, serverIds: unknown) {
const requestedProfileId = String(profileIdValue || '').trim();
const profileId = requestedProfileId || dependencies.readDesiredProfileId();
const profile = dependencies.readProfiles().find((candidate) => candidate.id === profileId);
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
const servers = dependencies.readServers();
return checkServerHealth(
requestedIds.size
? servers.filter((server) => requestedIds.has(server.id))
: servers,
? profile.servers.filter((server) => requestedIds.has(server.id))
: profile.servers,
dependencies.ping,
);
},
@@ -1,14 +1,17 @@
import type {
GatewayAutoState,
HarborServer,
StoredState,
import crypto from 'node:crypto';
import {
profileById,
type GatewayAutoState,
type HarborServer,
type StoredProfile,
type StoredState,
} from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import { finishRollback } from '../../services/rollback.js';
interface ParsedSubscription {
config: unknown;
sourceConfig?: unknown;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt: string;
@@ -29,11 +32,6 @@ interface SubscriptionServiceDependencies {
read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
cache: {
read(): unknown;
write(value: unknown): void;
remove(): void;
};
config: {
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
read(): string | null;
@@ -57,216 +55,385 @@ interface SubscriptionServiceDependencies {
clearInterval(handle: TimerHandle): void;
};
onRefreshError(error: unknown): void;
now?: () => Date;
}
interface ResetOptions {
stopRuntime?: boolean;
expectedSubscription?: {
url: string;
generation: number;
};
}
export interface SubscriptionMutationResult extends Record<string, unknown> {
export interface ProfileMutationResult extends Record<string, unknown> {
success: true;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt: string;
selectedServerId: string;
selectedTag: string;
profileId: string;
label: string;
}
const TERMINAL_SUBSCRIPTION_CODES = new Set([
'SUBSCRIPTION_EXPIRED',
'SUBSCRIPTION_DISABLED',
'SUBSCRIPTION_REJECTED',
]);
const safeErrorCode = (error: unknown) => (
error && typeof error === 'object' && 'code' in error
? String(error.code)
: 'UNKNOWN'
);
const cleanLabel = (value: unknown) => String(value || '').trim();
const foldedLabel = (value: unknown) => cleanLabel(value).toLocaleLowerCase('ru');
function requireLabel(value: unknown) {
const label = cleanLabel(value);
if (!label || label.length > 64) throw new HarborError('REQUEST_INVALID');
return label;
}
function requireExpectedRevision(state: StoredState, expectedRevision: unknown) {
if (expectedRevision === undefined) return;
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) {
throw new HarborError('STATE_CONFLICT');
}
}
function requireProfile(state: StoredState, profileId: unknown) {
const profile = profileById(state, profileId);
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
return profile;
}
function assertUniqueLabel(state: StoredState, label: string, exceptProfileId = '') {
if (state.profiles.some((profile) => (
profile.id !== exceptProfileId && foldedLabel(profile.label) === foldedLabel(label)
))) throw new HarborError('PROFILE_NAME_CONFLICT');
}
function replaceProfile(state: StoredState, nextProfile: StoredProfile) {
return state.profiles.map((profile) => profile.id === nextProfile.id ? nextProfile : profile);
}
function mutationResult(profile: Pick<StoredProfile, 'id' | 'label'>): ProfileMutationResult {
return { success: true, profileId: profile.id, label: profile.label };
}
export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) {
let refreshPromise: Promise<SubscriptionMutationResult> | null = null;
const refreshPromises = new Map<string, Promise<ProfileMutationResult>>();
let refreshTimer: TimerHandle | null = null;
let subscriptionGeneration = 0;
const restoreCache = (previous: unknown) => {
if (previous !== null) dependencies.cache.write(previous);
else dependencies.cache.remove();
};
const now = dependencies.now || (() => new Date());
const restoreConfig = (previous: string | null) => {
if (previous === null) dependencies.config.remove();
else dependencies.config.restore(previous);
};
const commitSubscription = (
const preflightAddProfile = (labelValue: unknown, expectedRevision?: unknown) => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
assertUniqueLabel(state, requireLabel(labelValue));
};
const preflightRenameProfile = (
profileId: unknown,
labelValue: unknown,
expectedRevision?: unknown,
) => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
const profile = requireProfile(state, profileId);
assertUniqueLabel(state, requireLabel(labelValue), profile.id);
};
const addProfile = async (
labelValue: unknown,
subscriptionUrlValue: unknown,
expectedRevision?: unknown,
) => {
const label = requireLabel(labelValue);
const subscriptionUrl = String(subscriptionUrlValue || '').trim();
const preflight = dependencies.state.read();
requireExpectedRevision(preflight, expectedRevision);
assertUniqueLabel(preflight, label);
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
// Admission CAS already passed; background freshness may advance the global revision during provider I/O.
return dependencies.serialize(async () => {
const state = dependencies.state.read();
assertUniqueLabel(state, label);
const profile: StoredProfile = {
id: `profile_${crypto.randomUUID()}`,
label,
subscriptionUrl,
subscriptionConfig: parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
desiredServerId: '',
lastRefreshAttemptAt: parsed.fetchedAt,
lastRefreshErrorCode: null,
};
dependencies.state.update((current) => ({
...current,
profiles: [...current.profiles, profile],
desiredProfileId: current.profiles.length ? current.desiredProfileId : profile.id,
}));
return mutationResult(profile);
});
};
const renameProfile = (profileId: unknown, labelValue: unknown, expectedRevision?: unknown) => (
dependencies.serialize(async () => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
const profile = requireProfile(state, profileId);
const label = requireLabel(labelValue);
if (profile.label === label) return mutationResult(profile);
assertUniqueLabel(state, label, profile.id);
const renamed = { ...profile, label };
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, renamed),
}));
return mutationResult(renamed);
})
);
const selectProfileServer = (
profileId: unknown,
serverIdValue: unknown,
expectedRevision?: unknown,
) => dependencies.serialize(async () => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
const profile = requireProfile(state, profileId);
const serverId = String(serverIdValue || '').trim();
if (!profile.servers.some((server) => server.id === serverId)) {
throw new HarborError('SERVER_NOT_FOUND');
}
if (profile.desiredServerId === serverId) return mutationResult(profile);
const selected = { ...profile, desiredServerId: serverId };
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, selected),
}));
return mutationResult(selected);
});
const recordRefreshError = async (
profileId: string,
subscriptionUrl: string,
error: unknown,
) => dependencies.serialize(async () => {
const state = dependencies.state.read();
const profile = requireProfile(state, profileId);
if (profile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT');
const failed = {
...profile,
lastRefreshAttemptAt: now().toISOString(),
lastRefreshErrorCode: safeErrorCode(error),
};
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, failed),
}));
});
const commitRefresh = (
profileId: string,
subscriptionUrl: string,
parsed: ParsedSubscription,
{ resetSelection = false, expectedGeneration }: {
resetSelection?: boolean;
expectedGeneration?: number;
} = {},
) => dependencies.serialize(async () => {
// Re-read the profile after provider I/O and guard its owner instead of rejecting background-only revisions.
const previousState = dependencies.state.read();
if (
subscriptionGeneration !== expectedGeneration ||
(!resetSelection && previousState.subscriptionUrl !== subscriptionUrl)
) {
throw new HarborError('STATE_CONFLICT');
}
const previousProfile = requireProfile(previousState, profileId);
if (previousProfile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT');
const selectedServerId = resetSelection
? ''
: dependencies.provider.selectRefreshedServer(
previousState.selectedServerId,
previousState.servers,
const desiredServerId = dependencies.provider.selectRefreshedServer(
previousProfile.desiredServerId,
previousProfile.servers,
parsed.servers,
);
const candidateConfig = selectedServerId
? dependencies.config.build(parsed.config, selectedServerId, previousState.routeRules)
: null;
const previousCache = dependencies.cache.read();
const refreshedProfile: StoredProfile = {
...previousProfile,
subscriptionConfig: parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
desiredServerId,
lastRefreshAttemptAt: parsed.fetchedAt,
lastRefreshErrorCode: null,
};
const running = await dependencies.runtime.isRunning();
const refreshesApplied = running && previousState.appliedProfileId === profileId;
const nextAppliedServerId = refreshesApplied
? dependencies.provider.selectRefreshedServer(
previousState.appliedServerId,
previousProfile.servers,
parsed.servers,
)
: '';
if (!refreshesApplied || !nextAppliedServerId) {
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, refreshedProfile),
}));
return mutationResult(refreshedProfile);
}
const nextAppliedServer = parsed.servers.find((server) => server.id === nextAppliedServerId)!;
const candidateConfig = dependencies.config.build(
parsed.config,
nextAppliedServerId,
previousState.routeRules,
);
const previousConfig = dependencies.config.read();
const previousGatewayAuto = dependencies.gatewayAuto.read();
const wasRunning = await dependencies.runtime.isRunning();
let restoreRuntime = false;
let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
try {
if ((resetSelection || !candidateConfig) && wasRunning) {
restoreRuntime = true;
await dependencies.runtime.stop();
}
if (candidateConfig) dependencies.config.write(candidateConfig);
else dependencies.config.remove();
dependencies.cache.write({
url: subscriptionUrl,
config: parsed.sourceConfig || parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
if (!resetSelection && wasRunning && candidateConfig) {
restoreRuntime = true;
configMutationStarted = true;
dependencies.config.write(candidateConfig);
runtimeMutationStarted = true;
await dependencies.runtime.start();
}
if (resetSelection) dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
stateCommitStarted = true;
dependencies.state.update((state) => ({
...(resetSelection ? {
routeRules: state.routeRules,
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
connectionDesired: 'stopped',
} : state),
subscriptionUrl,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, refreshedProfile),
appliedServerId: nextAppliedServerId,
appliedServerSnapshot: nextAppliedServer,
}));
subscriptionGeneration += 1;
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
{ run: () => restoreCache(previousCache) },
{ run: () => restoreConfig(previousConfig) },
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription rollback failed');
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
...(runtimeMutationStarted ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription refresh rollback failed');
}
return {
success: true as const,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
};
return mutationResult(refreshedProfile);
});
const importSubscription = async (subscriptionUrl: string) => {
const expectedGeneration = subscriptionGeneration;
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true, expectedGeneration });
const refreshProfile = (profileIdValue: unknown, expectedRevision?: unknown) => {
const profileId = String(profileIdValue || '').trim();
const existing = refreshPromises.get(profileId);
if (existing) return existing;
const initialState = dependencies.state.read();
requireExpectedRevision(initialState, expectedRevision);
const initialProfile = requireProfile(initialState, profileId);
const operation = (async () => {
let parsed: ParsedSubscription;
try {
parsed = await dependencies.provider.fetchSubscription(initialProfile.subscriptionUrl);
} catch (error) {
if (safeErrorCode(error) !== 'STATE_CONFLICT') {
await recordRefreshError(
profileId,
initialProfile.subscriptionUrl,
error,
);
}
throw error;
}
return commitRefresh(
profileId,
initialProfile.subscriptionUrl,
parsed,
);
})().finally(() => refreshPromises.delete(profileId));
refreshPromises.set(profileId, operation);
return operation;
};
const deleteProfile = (
profileIdValue: unknown,
modeValue: unknown = 'delete',
expectedRevision?: unknown,
) => dependencies.serialize(async () => {
const previousState = dependencies.state.read();
requireExpectedRevision(previousState, expectedRevision);
const profile = requireProfile(previousState, profileIdValue);
const mode = String(modeValue || 'delete');
if (!['delete', 'stop-and-delete'].includes(mode)) throw new HarborError('REQUEST_INVALID');
const running = await dependencies.runtime.isRunning();
const applied = running && previousState.appliedProfileId === profile.id;
if (applied && mode !== 'stop-and-delete') throw new HarborError('PROFILE_IN_USE');
const previousConfig = dependencies.config.read();
const previousGatewayAuto = dependencies.gatewayAuto.read();
const removesAppliedTarget = previousState.appliedProfileId === profile.id;
let runtimeMutationStarted = false;
let configMutationStarted = false;
let gatewayMutationStarted = false;
let stateCommitStarted = false;
try {
if (applied) {
runtimeMutationStarted = true;
await dependencies.runtime.stop();
}
if (removesAppliedTarget) {
configMutationStarted = true;
dependencies.config.remove();
}
if (
previousState.desiredProfileId === profile.id
&& !(running && previousState.appliedProfileId !== profile.id)
) {
gatewayMutationStarted = true;
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
}
stateCommitStarted = true;
dependencies.state.update((current) => ({
...current,
profiles: current.profiles.filter((candidate) => candidate.id !== profile.id),
desiredProfileId: current.desiredProfileId === profile.id ? '' : current.desiredProfileId,
appliedProfileId: current.appliedProfileId === profile.id ? '' : current.appliedProfileId,
appliedServerId: current.appliedProfileId === profile.id ? '' : current.appliedServerId,
appliedServerSnapshot: current.appliedProfileId === profile.id
? null
: current.appliedServerSnapshot,
...(current.appliedProfileId === profile.id ? { connectionDesired: 'stopped' } : {}),
}));
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
...(gatewayMutationStarted ? [{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) }] : []),
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
...(runtimeMutationStarted ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription delete rollback failed');
}
return mutationResult(profile);
});
// One-release compatibility for the old single-subscription client.
const importSubscription = (subscriptionUrl: string, expectedRevision?: unknown) => {
const state = dependencies.state.read();
if (state.profiles.length) throw new HarborError('STATE_CONFLICT');
return addProfile('Основной', subscriptionUrl, expectedRevision);
};
const refreshSavedSubscription = (expectedRevision?: unknown) => {
const state = dependencies.state.read();
if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT');
return refreshProfile(state.profiles[0].id, expectedRevision);
};
const resetSavedSubscription = ({
stopRuntime = true,
expectedSubscription,
}: ResetOptions = {}) => (
dependencies.serialize(async () => {
const previousState = dependencies.state.read();
if (expectedSubscription && (
previousState.subscriptionUrl !== expectedSubscription.url ||
subscriptionGeneration !== expectedSubscription.generation
)) return false;
const previousCache = dependencies.cache.read();
const previousConfig = dependencies.config.read();
const previousGatewayAuto = dependencies.gatewayAuto.read();
const wasRunning = stopRuntime ? await dependencies.runtime.isRunning() : false;
let restoreRuntime = false;
let stateCommitStarted = false;
try {
if (stopRuntime) {
restoreRuntime = wasRunning;
await dependencies.runtime.stop();
}
dependencies.config.remove();
dependencies.cache.remove();
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
stateCommitStarted = true;
dependencies.state.update(() => ({ routeRules: previousState.routeRules }));
subscriptionGeneration += 1;
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
{ run: () => restoreCache(previousCache) },
{ run: () => restoreConfig(previousConfig) },
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription rollback failed');
}
return true;
})
);
const refreshSavedSubscription = () => {
if (refreshPromise) return refreshPromise;
const subscriptionUrl = dependencies.state.read().subscriptionUrl;
const expectedGeneration = subscriptionGeneration;
const operation = (async () => {
try {
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
return await commitSubscription(subscriptionUrl, parsed, { expectedGeneration });
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error
? String(error.code)
: '';
if (subscriptionUrl && TERMINAL_SUBSCRIPTION_CODES.has(code)) {
const reset = await resetSavedSubscription({
expectedSubscription: { url: subscriptionUrl, generation: expectedGeneration },
});
if (!reset) throw new HarborError('STATE_CONFLICT');
}
throw error;
}
})().finally(() => {
refreshPromise = null;
});
refreshPromise = operation;
return operation;
expectedRevision,
}: { stopRuntime?: boolean; expectedRevision?: unknown } = {}) => {
const state = dependencies.state.read();
if (!state.profiles.length) return Promise.resolve(false);
if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT');
return deleteProfile(
state.profiles[0].id,
stopRuntime ? 'stop-and-delete' : 'delete',
expectedRevision,
).then(() => true);
};
const startAutoRefresh = (intervalMs: number) => {
if (refreshTimer) return;
refreshTimer = dependencies.scheduler.setInterval(() => {
if (!dependencies.state.read().subscriptionUrl) return;
void refreshSavedSubscription().catch(dependencies.onRefreshError);
void (async () => {
for (const { id } of dependencies.state.read().profiles) {
try {
await refreshProfile(id);
} catch (error) {
dependencies.onRefreshError(error);
}
}
})();
}, intervalMs);
refreshTimer.unref();
};
@@ -278,6 +445,13 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen
};
return {
preflightAddProfile,
preflightRenameProfile,
addProfile,
renameProfile,
selectProfileServer,
refreshProfile,
deleteProfile,
importSubscription,
refreshSavedSubscription,
resetSavedSubscription,
+19 -4
View File
@@ -5,18 +5,33 @@ import type { ConnectionService } from '../../features/connection/index.js';
interface ServerApplyRouteDependencies {
connection: Pick<ConnectionService, 'apply'>;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
sendState(res: ServerResponse, extra: { serverId: string; selectedTag: string }): Promise<void>;
withOperation<T>(
kind: string,
operation: (operationRevision: number) => Promise<T>,
options?: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown },
): Promise<T>;
sendState(res: ServerResponse, extra: { profileId: string; serverId: string; selectedTag: string }): Promise<void>;
}
export function createServerApplyRoute(dependencies: ServerApplyRouteDependencies) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'POST' || req.url !== '/api/apply') return false;
const { serverId = '', selectedTag = '' } = await dependencies.readBody(req);
const {
profileId = '',
serverId = '',
selectedTag = '',
expectedRevision,
} = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'apply-server',
() => dependencies.connection.apply(serverId, selectedTag),
(operationRevision) => dependencies.connection.apply(
profileId,
serverId,
selectedTag,
operationRevision,
),
{ expectedRevision, profileId, serverId },
);
await dependencies.sendState(res, result);
return true;
+10 -4
View File
@@ -5,16 +5,22 @@ import type { ServerHealthService } from '../../features/servers/index.js';
interface ServerHealthRouteDependencies {
serverHealth: ServerHealthService;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
sendState(res: ServerResponse, extra: { results: Array<Record<string, unknown>> }): Promise<void>;
sendState(res: ServerResponse, extra: {
profileId: string;
results: Array<Record<string, unknown>>;
}): Promise<void>;
}
export function createServerHealthRoute(dependencies: ServerHealthRouteDependencies) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'POST' || req.url !== '/api/servers/ping-all') return false;
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/servers\/ping$/);
if (req.method !== 'POST' || (!profileMatch && pathname !== '/api/servers/ping-all')) return false;
const { serverIds = [] } = await dependencies.readBody(req);
const results = await dependencies.serverHealth.check(serverIds);
await dependencies.sendState(res, { results });
const profileId = profileMatch ? decodeURIComponent(profileMatch[1]) : '';
const results = await dependencies.serverHealth.check(profileId, serverIds);
await dependencies.sendState(res, { profileId, results });
return true;
},
};
+1 -1
View File
@@ -46,7 +46,7 @@ function withStateV0Compatibility(
singboxRunning: snapshot.connection.process === 'running',
singboxStartedAt: snapshot.connection.startedAt,
subscriptionHost: snapshot.subscription.host,
hasSubscription: snapshot.subscription.status === 'ready',
hasSubscription: snapshot.subscription.status !== 'missing',
selectedTag: stored.selectedTag,
userInfo: snapshot.subscription.userInfo,
fetchedAt: snapshot.subscription.fetchedAt,
@@ -1,43 +1,170 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { ConnectionService } from '../../features/connection/index.js';
import type { SubscriptionService } from '../../features/subscription/index.js';
interface OperationOptions {
expectedRevision?: unknown;
profileId?: unknown;
serverId?: unknown;
}
interface SubscriptionMutationRouteDependencies {
subscriptionService: Pick<
SubscriptionService,
'importSubscription' | 'refreshSavedSubscription' | 'resetSavedSubscription'
| 'preflightAddProfile'
| 'preflightRenameProfile'
| 'addProfile'
| 'renameProfile'
| 'selectProfileServer'
| 'refreshProfile'
| 'deleteProfile'
| 'importSubscription'
| 'refreshSavedSubscription'
| 'resetSavedSubscription'
>;
connection: Pick<ConnectionService, 'activate'>;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
withOperation<T>(
kind: string,
operation: (operationRevision: number) => Promise<T>,
options?: OperationOptions,
): Promise<T>;
sendState(res: ServerResponse, extra?: Record<string, unknown>): Promise<void>;
}
export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)$/);
const serverMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/server$/);
const activateMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/activate$/);
const refreshMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/refresh$/);
if (req.method === 'POST' && pathname === '/api/profiles') {
const { label = '', url = '', expectedRevision } = await dependencies.readBody(req);
dependencies.subscriptionService.preflightAddProfile(label, expectedRevision);
const result = await dependencies.withOperation(
'profile-add',
(operationRevision) => dependencies.subscriptionService.addProfile(
label,
url,
operationRevision,
),
{ expectedRevision },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'PATCH' && profileMatch) {
const profileId = decodeURIComponent(profileMatch[1]);
const { label = '', expectedRevision } = await dependencies.readBody(req);
dependencies.subscriptionService.preflightRenameProfile(profileId, label, expectedRevision);
const result = await dependencies.withOperation(
'profile-rename',
(operationRevision) => dependencies.subscriptionService.renameProfile(
profileId,
label,
operationRevision,
),
{ expectedRevision, profileId },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'PUT' && serverMatch) {
const profileId = decodeURIComponent(serverMatch[1]);
const { serverId = '', expectedRevision } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'profile-select-server',
(operationRevision) => dependencies.subscriptionService.selectProfileServer(
profileId,
serverId,
operationRevision,
),
{ expectedRevision, profileId, serverId },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'POST' && activateMatch) {
const profileId = decodeURIComponent(activateMatch[1]);
const { expectedRevision } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'profile-activate',
(operationRevision) => dependencies.connection.activate(profileId, operationRevision),
{ expectedRevision, profileId },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'POST' && refreshMatch) {
const profileId = decodeURIComponent(refreshMatch[1]);
const { expectedRevision } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'profile-refresh',
(operationRevision) => dependencies.subscriptionService.refreshProfile(
profileId,
operationRevision,
),
{ expectedRevision, profileId },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'DELETE' && profileMatch) {
const profileId = decodeURIComponent(profileMatch[1]);
const { mode = 'delete', expectedRevision } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'profile-delete',
(operationRevision) => dependencies.subscriptionService.deleteProfile(
profileId,
mode,
operationRevision,
),
{ expectedRevision, profileId },
);
await dependencies.sendState(res, result);
return true;
}
// One-release compatibility for the old single-subscription client.
if (req.method === 'POST' && pathname === '/api/subscription/fetch') {
const { url = '' } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'subscription-import',
() => dependencies.subscriptionService.importSubscription(String(url).trim()),
(operationRevision) => dependencies.subscriptionService.importSubscription(
String(url).trim(),
operationRevision,
),
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
const { success: _success, ...result } = await dependencies.withOperation(
if (req.method === 'POST' && pathname === '/api/subscription/refresh') {
const result = await dependencies.withOperation(
'subscription-refresh',
() => dependencies.subscriptionService.refreshSavedSubscription(),
(operationRevision) => dependencies.subscriptionService.refreshSavedSubscription(
operationRevision,
),
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
if (req.method === 'DELETE' && pathname === '/api/subscription') {
await dependencies.withOperation(
'subscription-forget',
() => dependencies.subscriptionService.resetSavedSubscription(),
(operationRevision) => dependencies.subscriptionService.resetSavedSubscription({
expectedRevision: operationRevision,
}),
);
await dependencies.sendState(res);
return true;
+272 -79
View File
@@ -28,13 +28,19 @@ import {
selectRefreshedServer,
} from './subscription.js';
import {
desiredProfile,
normalizeStoredState,
type OperationState,
type RouteRule,
type StoredState,
} from '../shared/contracts/state.js';
import { serverIdentityKey } from '../shared/serverIdentity.js';
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
import {
atomicWriteFile,
createJsonStore,
createStateStore,
} from './services/stateStore.js';
import { createDevicePolicyService } from './services/devicePolicyService.js';
import {
createDeviceInventoryService,
@@ -93,11 +99,49 @@ function errorMessage(error: unknown) {
fs.mkdirSync(settings.dataDir, { recursive: true });
const stateStore = createStateStore(settings.statePath);
const stateFileExisted = fs.existsSync(settings.statePath);
const legacyStateBytes = stateFileExisted
? fs.readFileSync(settings.statePath, 'utf8')
: null;
let legacyStateRecord: Record<string, unknown> = {};
try {
legacyStateRecord = record(legacyStateBytes === null ? null : JSON.parse(legacyStateBytes));
} catch {}
const legacyStateVersion = Number.isSafeInteger(legacyStateRecord.schemaVersion)
? Number(legacyStateRecord.schemaVersion)
: 0;
const legacySubscriptionCacheBytes = fs.existsSync(settings.subscriptionCachePath)
? fs.readFileSync(settings.subscriptionCachePath, 'utf8')
: null;
const subscriptionCacheStore = createJsonStore({
filePath: settings.subscriptionCachePath,
defaultValue: null,
});
const rawLegacySubscriptionCache = subscriptionCacheStore.read();
const legacyCacheRecord = record(rawLegacySubscriptionCache);
const legacyStateSubscriptionUrl = String(legacyStateRecord.subscriptionUrl || '').trim();
const legacyCacheSubscriptionUrl = String(legacyCacheRecord.url || '').trim();
const legacyCacheOwnerMismatch = legacyStateVersion < 5
&& Boolean(legacyCacheRecord.config)
&& (legacyStateSubscriptionUrl
? legacyCacheSubscriptionUrl !== legacyStateSubscriptionUrl
: !legacyCacheSubscriptionUrl);
let legacySubscriptionCache = rawLegacySubscriptionCache;
let legacySubscriptionCacheRejected = Boolean(subscriptionCacheStore.recovery);
if (legacyCacheOwnerMismatch) {
legacySubscriptionCache = null;
} else if (legacyCacheRecord.config) {
try {
legacySubscriptionCache = {
...legacyCacheRecord,
...normalizeSubscriptionConfig(legacyCacheRecord.config),
};
} catch {
legacySubscriptionCache = null;
legacySubscriptionCacheRejected = true;
}
}
const stateStore = createStateStore(settings.statePath, { legacySubscriptionCache });
const deviceStore = createJsonStore<InventoryState>({
filePath: settings.deviceStatePath,
defaultValue: migrateDeviceInventoryState({}),
@@ -112,32 +156,77 @@ if (deviceStore.migration) {
if (deviceStore.recovery) {
console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`);
}
let cacheRecoveryLogged = false;
function readRawSubscriptionCache() {
const cached = subscriptionCacheStore.read();
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
cacheRecoveryLogged = true;
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
return cached;
}
function readSubscriptionCache() {
const raw = readRawSubscriptionCache();
const cached = record(raw);
return cached.config
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: raw }
: raw && typeof raw === 'object' && !Array.isArray(raw) ? cached : null;
}
const initialStoredState = stateStore.read();
let initialStoredState = stateStore.read();
if (stateStore.migration) {
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
}
if (stateStore.recovery) {
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
}
if (subscriptionCacheStore.recovery) {
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
const rejectedLegacyMigration = legacySubscriptionCacheRejected
&& (
!stateFileExisted
|| Boolean(stateStore.recovery)
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
);
const mismatchedLegacyMigration = legacyCacheOwnerMismatch
&& (
!stateFileExisted
|| Boolean(stateStore.recovery)
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
);
if (rejectedLegacyMigration) {
initialStoredState = stateStore.update((state) => ({
...state,
profiles: [],
desiredProfileId: '',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
connectionDesired: 'stopped',
}));
removeSingboxConfig();
} else if (mismatchedLegacyMigration) {
initialStoredState = stateStore.update((state) => ({
...state,
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
connectionDesired: 'stopped',
}));
removeSingboxConfig();
}
if (
legacySubscriptionCacheBytes !== null
&& (
Boolean(subscriptionCacheStore.recovery)
|| (
Boolean(legacyCacheRecord.config)
&& (
legacySubscriptionCacheRejected
|| legacyCacheOwnerMismatch
|| normalizeStoredState(initialStoredState).profiles.some((profile) => profile.subscriptionConfig)
)
)
)
) {
const backupPath = subscriptionCacheStore.recovery?.backupPath
|| `${settings.subscriptionCachePath}.backup-v1-${new Date().toISOString().replace(/[:.]/g, '-')}`;
if (!subscriptionCacheStore.recovery) atomicWriteFile(backupPath, legacySubscriptionCacheBytes);
subscriptionCacheStore.remove();
console.log(`[storage] legacy subscription cache migrated; backup: ${backupPath}`);
}
function readProfileConfig(profileId = '') {
const state = normalizeStoredState(stateStore.read());
const profile = profileId
? state.profiles.find((candidate) => candidate.id === profileId)
: desiredProfile(state);
return profile?.subscriptionConfig || null;
}
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
const versionInfo = buildVersionInfo(settings.appMode);
@@ -215,7 +304,7 @@ const gatewayAutoService = createGatewayAutoService({
update: updateStoredState,
},
subscription: {
readConfig: () => readSubscriptionCache()?.config || null,
readConfig: (profileId) => readProfileConfig(profileId),
},
config: {
build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => (
@@ -238,6 +327,7 @@ const gatewayAutoService = createGatewayAutoService({
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
),
restoreRunning: () => startSingbox(),
stopCommand: () => captureRuntimeCommand(() => stopSingbox()),
},
discovery: {
readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath),
@@ -291,7 +381,7 @@ const deviceInventoryRoute = createDeviceInventoryRoute({
});
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
readState: () => stateStore.read(),
readState: () => normalizeStoredState(stateStore.read()),
runDiagnostics: async (services, target) => remoteDataplane
? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
: requireLocalConnectivityDiagnostics().run({
@@ -306,7 +396,7 @@ const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
});
const gatewayPresenceRoute = createGatewayPresenceRoute({
appMode: settings.appMode,
readState: () => stateStore.read(),
readState: () => normalizeStoredState(stateStore.read()),
getHwid,
});
const sharedProxyRoute = createSharedProxyRoute({
@@ -332,11 +422,6 @@ const subscriptionService = createSubscriptionService({
read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState,
},
cache: {
read: readRawSubscriptionCache,
write: (value) => { subscriptionCacheStore.write(value); },
remove: () => subscriptionCacheStore.remove(),
},
config: {
build: (subscriptionConfig, selectedServerId, routeRules) => (
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
@@ -364,16 +449,12 @@ const subscriptionService = createSubscriptionService({
clearInterval: (timer) => clearInterval(timer),
},
onRefreshError: (error) => console.warn(`[control] подписка не обновлена: ${errorMessage(error)}`),
});
const subscriptionMutationRoute = createSubscriptionMutationRoute({
subscriptionService,
readBody,
withOperation,
sendState: (res, extra) => stateRoute.send(res, extra),
now: () => new Date(),
});
const serverHealthRoute = createServerHealthRoute({
serverHealth: createServerHealthService({
readServers: () => normalizeStoredState(stateStore.read()).servers,
readProfiles: () => normalizeStoredState(stateStore.read()).profiles,
readDesiredProfileId: () => normalizeStoredState(stateStore.read()).desiredProfileId,
ping: tcpPing,
}),
readBody,
@@ -384,11 +465,7 @@ const connectionService = createConnectionService({
read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState,
},
subscription: {
readConfig: () => readSubscriptionCache()?.config || null,
},
config: {
exists: () => fs.existsSync(settings.configPath),
build: (subscriptionConfig, selectedServerId, routeRules) => (
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
),
@@ -399,6 +476,10 @@ const connectionService = createConnectionService({
restore: restoreSingboxConfig,
remove: removeSingboxConfig,
},
route: {
isGatewayDirect: () => settings.appMode === 'client'
&& gatewayAutoService.read().mode === 'gateway-direct',
},
runtime: {
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
start: () => startSingbox(),
@@ -412,6 +493,13 @@ const connectionService = createConnectionService({
serialize: serializeControl,
now: () => new Date(),
});
const subscriptionMutationRoute = createSubscriptionMutationRoute({
subscriptionService,
connection: connectionService,
readBody,
withOperation,
sendState: (res, extra) => stateRoute.send(res, extra),
});
const serverApplyRoute = createServerApplyRoute({
connection: connectionService,
readBody,
@@ -429,7 +517,7 @@ const routeRulesService = createRouteRulesService({
update: updateStoredState,
},
subscription: {
readConfig: () => readSubscriptionCache()?.config || null,
readConfig: (profileId) => readProfileConfig(profileId),
},
config: {
build: (subscriptionConfig, selectedServerId, routeRules) => (
@@ -460,27 +548,64 @@ const routeRulesRoute = createRouteRulesRoute({
});
function updateStoredState(update: (state: StoredState) => Record<string, unknown>) {
return stateStore.update((stored) => {
return normalizeStoredState(stateStore.update((stored) => {
const current = normalizeStoredState(stored);
const schemaVersion = stored.schemaVersion;
const next = normalizeStoredState({ schemaVersion, ...update(current) });
revision = Math.max(revision, current.revision) + 1;
next.revision = revision;
return { ...next, schemaVersion };
});
}));
}
async function withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T> {
async function withOperation<T>(
kind: string,
operation: (operationRevision: number) => Promise<T>,
{
expectedRevision,
profileId = null,
serverId = null,
}: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown } = {},
): Promise<T> {
if (operationState.status === 'running') throw new HarborError('OPERATION_IN_PROGRESS');
const currentRevision = normalizeStoredState(stateStore.read()).revision;
if (expectedRevision !== undefined) {
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== currentRevision) {
throw new HarborError('STATE_CONFLICT');
}
}
operationState = {
kind,
status: 'running',
startedAt: new Date().toISOString(),
error: null,
profileId: profileId == null ? null : String(profileId),
serverId: serverId == null ? null : String(serverId),
};
updateStoredState((state) => state);
let operationRevision: number;
try {
const result = await operation();
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
operationRevision = updateStoredState((state) => state).revision;
} catch (error) {
operationState = {
kind: null,
status: 'idle',
startedAt: null,
error: null,
profileId: null,
serverId: null,
};
throw error;
}
try {
const result = await operation(operationRevision);
operationState = {
kind: null,
status: 'idle',
startedAt: null,
error: null,
profileId: null,
serverId: null,
};
updateStoredState((state) => state);
return result;
} catch (error) {
@@ -536,7 +661,8 @@ function buildActiveConfig(
selectedServerId: string,
routeRules: RouteRule[] = stateStore.read().routeRules,
) {
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
const normalizedConfig = normalizeSubscriptionConfig(subscriptionConfig).config;
return buildGatewayConfig(normalizedConfig, selectedServerId, {
clientDirect: settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct',
routeRules,
});
@@ -545,33 +671,22 @@ function buildActiveConfig(
const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply();
function writeCurrentConfig(onlyIfSelectionChanged = false) {
function writeCurrentConfig() {
const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache();
if (!state.selectedServerId || !cached?.config) return false;
const cachedServers = cached.servers as StoredState['servers'];
const selectedServerId = selectRefreshedServer(
state.selectedServerId,
state.servers,
cachedServers,
);
if (onlyIfSelectionChanged && selectedServerId === state.selectedServerId) return false;
const activeConfig = selectedServerId
? buildActiveConfig(cached.config, selectedServerId)
: null;
const hasAppliedTarget = Boolean(state.appliedProfileId && state.appliedServerId);
const profile = hasAppliedTarget
? state.profiles.find((candidate) => candidate.id === state.appliedProfileId) || null
: desiredProfile(state);
const serverId = hasAppliedTarget ? state.appliedServerId : profile?.desiredServerId;
const server = profile?.servers.find((candidate) => candidate.id === serverId);
const subscriptionConfig = profile ? readProfileConfig(profile.id) : null;
if (!profile || !server || !subscriptionConfig) return null;
const activeConfig = buildActiveConfig(subscriptionConfig, server.id);
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
try {
if (activeConfig) writeSingboxConfig(activeConfig);
else removeSingboxConfig();
updateStoredState((current) => ({
...current,
servers: cachedServers,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
}));
writeSingboxConfig(activeConfig);
} catch (error) {
try {
if (previousConfig === null) removeSingboxConfig();
@@ -581,7 +696,61 @@ function writeCurrentConfig(onlyIfSelectionChanged = false) {
}
throw error;
}
return Boolean(selectedServerId);
return { profile, server };
}
const CONFIG_PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
function currentConfigMatchesAppliedTarget(state: StoredState) {
if (!state.appliedProfileId || !state.appliedServerId || !state.appliedServerSnapshot) return false;
let config: Record<string, unknown>;
try {
config = record(JSON.parse(fs.readFileSync(settings.configPath, 'utf8')));
} catch {
return false;
}
const proxyOutbounds = (Array.isArray(config.outbounds) ? config.outbounds : [])
.map(record)
.filter((outbound) => CONFIG_PROXY_TYPES.has(String(outbound.type || '')));
const exactMatches = proxyOutbounds.filter((outbound) => (
String(outbound.tag || '') === state.appliedServerId
));
const targetMatches = exactMatches.length
? exactMatches
: proxyOutbounds.filter((outbound) => (
serverIdentityKey(outbound) === serverIdentityKey(state.appliedServerSnapshot)
));
if (targetMatches.length !== 1) return false;
const outboundTag = String(targetMatches[0].tag || '');
const routeFinal = String(record(config.route).final || '');
const expectsGatewayDirect = settings.appMode === 'client'
&& gatewayAutoService.read().mode === 'gateway-direct';
return expectsGatewayDirect ? routeFinal === 'direct' : routeFinal === outboundTag;
}
async function reconcileStoppedBoot({ removeConfig = false } = {}) {
try {
await stopSingbox();
} catch (error) {
console.warn(`[control] sing-box не остановлен при startup reconcile: ${errorMessage(error)}`);
return;
}
if (removeConfig) removeSingboxConfig();
const state = normalizeStoredState(stateStore.read());
if (
state.connectionDesired !== 'stopped'
|| state.appliedProfileId
|| state.appliedServerId
|| state.appliedServerSnapshot
) {
updateStoredState((current) => ({
...current,
connectionDesired: 'stopped',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
}));
}
}
async function handleApi(req: IncomingMessage, res: ServerResponse) {
@@ -654,21 +823,45 @@ process.on('SIGINT', shutdown);
await gatewayAutoService.refresh({ reconfigure: false })
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`));
const bootState = normalizeStoredState(stateStore.read());
const bootWantsRunning = bootState.connectionDesired === 'running'
|| (bootState.connectionDesired === undefined && fs.existsSync(settings.configPath));
if (bootWantsRunning) {
let target: ReturnType<typeof writeCurrentConfig> = null;
try {
writeCurrentConfig(settings.appMode !== 'client' && fs.existsSync(settings.configPath));
target = writeCurrentConfig();
} catch (error) {
const candidate = record(error);
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
console.warn(`[storage] не удалось собрать сохранённую подписку: ${errorMessage(error)}`);
}
const canReuseCurrentConfig = target === null
&& fs.existsSync(settings.configPath)
&& currentConfigMatchesAppliedTarget(normalizeStoredState(stateStore.read()));
if (target || canReuseCurrentConfig) {
await startSingbox()
.then(() => {
if (fs.existsSync(settings.configPath)) {
updateStoredState((state: StoredState) => ({ ...state, appliedRouteRules: state.routeRules }));
const current = normalizeStoredState(stateStore.read());
const appliedProfile = target?.profile
|| current.profiles.find((profile) => profile.id === current.appliedProfileId);
const appliedServer = target?.server
|| current.appliedServerSnapshot;
if (appliedProfile && appliedServer) {
updateStoredState((state: StoredState) => ({
...state,
connectionDesired: 'running',
appliedProfileId: appliedProfile.id,
appliedServerId: appliedServer.id,
appliedServerSnapshot: appliedServer,
...(target ? { appliedRouteRules: state.routeRules } : {}),
}));
}
})
.catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`));
} else {
await reconcileStoppedBoot({ removeConfig: true });
}
} else {
await reconcileStoppedBoot();
}
if (deviceInventory) {
await deviceInventory.reconcilePolicies()
+92 -10
View File
@@ -1,10 +1,19 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeStoredState, type StoredState } from '../../shared/contracts/state.js';
import {
normalizeStoredState,
type PersistedState,
} from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
import {
normalizeServers,
resolveServerId,
serverIdentityKey,
type NormalizedServer,
} from '../../shared/serverIdentity.js';
export const STATE_SCHEMA_VERSION = 4;
export const STATE_SCHEMA_VERSION = 5;
export interface AtomicWriteOptions {
beforeRename?: (temporaryPath: string, filePath: string) => void;
@@ -59,6 +68,23 @@ function record(value: unknown): Record<string, unknown> {
: {};
}
function remapLegacyServerId(
previousServers: NormalizedServer[],
nextServers: NormalizedServer[],
serverId: unknown,
legacyTag: unknown = '',
) {
const direct = resolveServerId(nextServers, serverId, legacyTag);
if (direct) return direct;
const previousId = resolveServerId(previousServers, serverId, legacyTag);
const previous = previousServers.find((server) => server.id === previousId);
if (!previous) return '';
const matches = nextServers.filter((server) => (
serverIdentityKey(server) === serverIdentityKey(previous)
));
return matches.length === 1 ? matches[0].id : '';
}
function syncDirectory(directory: string) {
let descriptor: number | undefined;
try {
@@ -105,7 +131,10 @@ export function atomicWriteJson(filePath: string, value: unknown, options?: Atom
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
}
export function migrateStoredState(value: unknown): StoredState & { schemaVersion: number } {
export function migrateStoredState(
value: unknown,
legacySubscriptionCache: unknown = null,
): PersistedState & { schemaVersion: number } {
const stored = record(value);
const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0;
if (version < 0 || version > STATE_SCHEMA_VERSION) {
@@ -114,10 +143,60 @@ export function migrateStoredState(value: unknown): StoredState & { schemaVersio
const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
: stored.routeRules;
const legacyCache = record(legacySubscriptionCache);
const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim();
const cachedSubscriptionUrl = String(legacyCache.url || '').trim();
const cacheOwnsStoredSubscription = Boolean(cachedSubscriptionUrl)
&& (!storedSubscriptionUrl || cachedSubscriptionUrl === storedSubscriptionUrl);
const previousServers = normalizeServers(stored.servers);
const cachedServers = cacheOwnsStoredSubscription
? normalizeServers(legacyCache.servers)
: [];
const migratedServers = cachedServers.length ? cachedServers : previousServers;
const selectedServerId = remapLegacyServerId(
previousServers,
migratedServers,
stored.selectedServerId,
stored.selectedTag,
);
const appliedServerId = remapLegacyServerId(
previousServers,
migratedServers,
stored.appliedServerId,
stored.appliedTag || stored.selectedTag,
);
const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped');
const normalized = normalizeStoredState({
...stored,
routeRules,
...(version < 5 && !Array.isArray(stored.profiles) ? {
subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''),
subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null,
servers: migratedServers,
selectedServerId,
selectedTag: '',
appliedServerId: keepLegacyApplied ? appliedServerId : '',
appliedServerSnapshot: keepLegacyApplied ? stored.appliedServerSnapshot : null,
appliedTag: '',
userInfo: stored.userInfo || (cacheOwnsStoredSubscription ? legacyCache.userInfo : undefined),
fetchedAt: stored.fetchedAt || (cacheOwnsStoredSubscription ? legacyCache.fetchedAt : undefined),
} : {}),
});
const canonical = { ...normalized } as Record<string, unknown>;
for (const key of [
'subscriptionUrl',
'selectedServerId',
'selectedTag',
'appliedTag',
'servers',
'userInfo',
'fetchedAt',
'subscriptionConfig',
]) delete canonical[key];
return {
...normalizeStoredState({ ...stored, routeRules }),
...canonical,
schemaVersion: STATE_SCHEMA_VERSION,
};
} as PersistedState & { schemaVersion: number };
}
export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
@@ -204,14 +283,17 @@ export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStor
export function createStateStore(
filePath: string,
options: Partial<Omit<JsonStoreOptions<StoredState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> = {},
options: Partial<Omit<JsonStoreOptions<PersistedState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> & {
legacySubscriptionCache?: unknown;
} = {},
) {
return createJsonStore<StoredState & { schemaVersion: number }>({
const { legacySubscriptionCache = null, ...storeOptions } = options;
return createJsonStore<PersistedState & { schemaVersion: number }>({
filePath,
defaultValue: migrateStoredState({}),
migrate: migrateStoredState,
defaultValue: migrateStoredState({}, legacySubscriptionCache),
migrate: (value) => migrateStoredState(value, legacySubscriptionCache),
initializeMissing: true,
backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion,
...options,
...storeOptions,
});
}
+238 -36
View File
@@ -20,18 +20,53 @@ export interface RouteRule {
enabled: boolean;
}
export interface StoredProfile {
id: string;
label: string;
subscriptionUrl: string;
subscriptionConfig: unknown;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt: string | null;
desiredServerId: string;
lastRefreshAttemptAt: string | null;
lastRefreshErrorCode: string | null;
}
export interface ProfileSnapshot {
id: string;
label: string;
subscription: {
status: 'ready' | 'stale';
host: string;
fetchedAt: string | null;
userInfo: Record<string, unknown>;
lastRefreshAttemptAt: string | null;
errorCode: string | null;
};
desiredServerId: string;
servers: HarborServer[];
}
export interface StateSnapshot {
apiVersion: 1;
revision: number;
generatedAt: string;
mode: HarborMode;
profiles: ProfileSnapshot[];
subscription: {
status: 'missing' | 'ready';
status: 'missing' | 'ready' | 'stale';
host: string;
fetchedAt: string | null;
userInfo: Record<string, unknown>;
};
selection: { desiredServerId: string; appliedServerId: string };
selection: {
desiredProfileId: string;
desiredServerId: string;
appliedProfileId: string;
appliedServerId: string;
appliedServerSnapshot: HarborServer | null;
};
connection: {
desired: ConnectionState;
process: ConnectionState;
@@ -56,24 +91,36 @@ export interface StateSnapshot {
status: OperationStatus;
startedAt: string | null;
error: string | null;
profileId: string | null;
serverId: string | null;
};
// One-release projection of the desired profile for older clients.
servers: HarborServer[];
}
export interface StoredState extends Record<string, unknown> {
export interface PersistedState extends Record<string, unknown> {
revision: number;
selectedServerId: string;
profiles: StoredProfile[];
desiredProfileId: string;
appliedProfileId: string;
appliedServerId: string;
selectedTag: string;
appliedTag: string;
servers: HarborServer[];
appliedServerSnapshot: HarborServer | null;
routeRules: RouteRule[];
appliedRouteRules: RouteRule[];
routeRulesRevision: number;
subscriptionUrl?: string;
connectionDesired?: ConnectionState;
gatewayAutoEnabled?: boolean;
userInfo?: Record<string, unknown>;
}
// Legacy fields are derived in memory for bounded callers during the v5 cutover.
// migrateStoredState strips them before every persisted write.
export interface StoredState extends PersistedState {
subscriptionUrl: string;
selectedServerId: string;
selectedTag: string;
appliedTag: string;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt?: string;
}
@@ -97,6 +144,8 @@ export interface OperationState {
status: OperationStatus;
startedAt: string | null;
error: string | null;
profileId?: string | null;
serverId?: string | null;
}
const MODES = new Set<HarborMode>(['client', 'gateway']);
@@ -109,37 +158,152 @@ const dateOrNull = (value: unknown) => (
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
);
export function normalizeStoredState(value: unknown): StoredState {
const state: Record<string, unknown> = value && typeof value === 'object' && !Array.isArray(value)
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const servers = normalizeServers(state.servers) as HarborServer[];
const selectedServerId = resolveServerId(
}
function publicSubscriptionHost(value: unknown) {
try {
return `${new URL(String(value)).host}/…`;
} catch {
return '';
}
}
function normalizeProfile(value: unknown, index: number): StoredProfile {
const candidate = record(value);
const servers = normalizeServers(candidate.servers) as HarborServer[];
const desiredServerId = resolveServerId(
servers,
identityText(candidate.desiredServerId),
identityText(candidate.selectedTag),
);
return {
id: identityText(candidate.id) || `profile_${index + 1}`,
label: identityText(candidate.label) || `Подписка ${index + 1}`,
subscriptionUrl: identityText(candidate.subscriptionUrl),
subscriptionConfig: candidate.subscriptionConfig ?? null,
servers,
userInfo: record(candidate.userInfo),
fetchedAt: dateOrNull(candidate.fetchedAt),
desiredServerId,
lastRefreshAttemptAt: dateOrNull(candidate.lastRefreshAttemptAt),
lastRefreshErrorCode: nullableText(candidate.lastRefreshErrorCode),
};
}
function normalizeAppliedServer(value: unknown): HarborServer | null {
return (normalizeServers(value ? [value] : []) as HarborServer[])[0] || null;
}
export function profileById(state: Pick<PersistedState, 'profiles'>, profileId: unknown) {
const id = identityText(profileId);
return state.profiles.find((profile) => profile.id === id) || null;
}
export function desiredProfile(state: Pick<PersistedState, 'profiles' | 'desiredProfileId'>) {
return profileById(state, state.desiredProfileId);
}
export function appliedProfile(state: Pick<PersistedState, 'profiles' | 'appliedProfileId'>) {
return profileById(state, state.appliedProfileId);
}
export function normalizeStoredState(value: unknown): StoredState {
const state = record(value);
const legacyServers = normalizeServers(state.servers) as HarborServer[];
const legacySelectedServerId = resolveServerId(
legacyServers,
identityText(state.selectedServerId),
identityText(state.selectedTag),
);
const appliedServerId = Object.hasOwn(state, 'appliedServerId')
? resolveServerId(servers, identityText(state.appliedServerId))
: resolveServerId(servers, '', identityText(state.appliedTag) || identityText(state.selectedTag));
const selectedServer = servers.find((server: HarborServer) => server.id === selectedServerId);
const appliedServer = servers.find((server: HarborServer) => server.id === appliedServerId);
const legacyAppliedServerId = Object.hasOwn(state, 'appliedServerId')
? resolveServerId(legacyServers, identityText(state.appliedServerId))
: resolveServerId(legacyServers, '', identityText(state.appliedTag) || identityText(state.selectedTag));
const suppliedProfiles = Array.isArray(state.profiles)
? state.profiles.map(normalizeProfile)
: [];
const profiles = Array.isArray(state.profiles)
? suppliedProfiles.filter((profile, index) => (
suppliedProfiles.findIndex((candidate) => candidate.id === profile.id) === index
))
: identityText(state.subscriptionUrl) || legacyServers.length
? [normalizeProfile({
id: 'profile_primary',
label: 'Основной',
subscriptionUrl: state.subscriptionUrl,
subscriptionConfig: state.subscriptionConfig,
servers: legacyServers,
userInfo: state.userInfo,
fetchedAt: state.fetchedAt,
desiredServerId: legacySelectedServerId,
}, 0)]
: [];
const requestedDesiredProfileId = identityText(state.desiredProfileId);
const desiredProfileId = profileById({ profiles }, requestedDesiredProfileId)?.id
|| (profiles.length === 1 ? profiles[0].id : '');
const requestedAppliedProfileId = identityText(state.appliedProfileId);
const appliedProfileId = profileById({ profiles }, requestedAppliedProfileId)?.id
|| (legacyAppliedServerId && profiles.length === 1 ? profiles[0].id : '');
const selectedProfile = profileById({ profiles }, desiredProfileId);
const currentAppliedProfile = profileById({ profiles }, appliedProfileId);
const normalizedSnapshot = normalizeAppliedServer(state.appliedServerSnapshot);
const explicitAppliedServerId = identityText(state.appliedServerId);
const appliedServerId = explicitAppliedServerId && (
currentAppliedProfile?.servers.some((server) => server.id === explicitAppliedServerId)
|| normalizedSnapshot?.id === explicitAppliedServerId
)
? explicitAppliedServerId
: legacyAppliedServerId;
const appliedServerSnapshot = currentAppliedProfile?.servers.find(
(server) => server.id === appliedServerId,
) || (normalizedSnapshot?.id === appliedServerId ? normalizedSnapshot : null);
const selectedServerId = selectedProfile?.desiredServerId || '';
const selectedServer = selectedProfile?.servers.find((server) => server.id === selectedServerId);
return {
...state,
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) && state.revision >= 0
? state.revision
: 0,
selectedServerId,
profiles,
desiredProfileId,
appliedProfileId,
appliedServerId,
selectedTag: selectedServer?.label || '',
appliedTag: appliedServer?.label || '',
servers,
appliedServerSnapshot,
routeRules: normalizeRouteRules(state.routeRules) as RouteRule[],
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules) as RouteRule[],
routeRulesRevision: typeof state.routeRulesRevision === 'number'
&& Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
? state.routeRulesRevision
: 0,
subscriptionUrl: selectedProfile?.subscriptionUrl || '',
selectedServerId,
selectedTag: selectedServer?.label || '',
appliedTag: appliedServerSnapshot?.label || '',
servers: selectedProfile?.servers || [],
userInfo: selectedProfile?.userInfo || {},
fetchedAt: selectedProfile?.fetchedAt || undefined,
};
}
function profileSnapshot(profile: StoredProfile): ProfileSnapshot {
return {
id: profile.id,
label: profile.label,
subscription: {
status: profile.lastRefreshErrorCode ? 'stale' : 'ready',
host: publicSubscriptionHost(profile.subscriptionUrl),
fetchedAt: dateOrNull(profile.fetchedAt),
userInfo: profile.userInfo,
lastRefreshAttemptAt: dateOrNull(profile.lastRefreshAttemptAt),
errorCode: nullableText(profile.lastRefreshErrorCode),
},
desiredServerId: profile.desiredServerId,
servers: profile.servers,
};
}
@@ -149,7 +313,6 @@ export function createStateSnapshot({
gatewayAuto,
appMode,
configExists,
subscriptionHost,
operation = { kind: null, status: 'idle', startedAt: null, error: null },
now = new Date(),
}: {
@@ -158,17 +321,19 @@ export function createStateSnapshot({
gatewayAuto?: GatewayAutoState | null;
appMode?: string;
configExists: boolean;
subscriptionHost: string;
subscriptionHost?: string;
operation?: OperationState;
now?: Date;
}): StateSnapshot {
const stored = normalizeStoredState(storedState);
const mode: HarborMode = appMode === 'client' || appMode === 'gateway' ? appMode : 'gateway';
const hasSubscription = Boolean(stored.subscriptionUrl);
const selectedProfile = desiredProfile(stored);
const profiles = stored.profiles.map(profileSnapshot);
const selectedSnapshot = profiles.find((profile) => profile.id === stored.desiredProfileId) || null;
const desired: ConnectionState = stored.connectionDesired && CONNECTION_STATES.has(stored.connectionDesired)
? stored.connectionDesired
: configExists ? 'running' : 'stopped';
const servers = stored.servers;
const running = Boolean(runtime?.running);
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
const routeReason = mode !== 'client'
@@ -178,26 +343,36 @@ export function createStateSnapshot({
: routeMode === 'gateway-direct'
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
const activeLocalRules = running ? stored.appliedRouteRules : [];
const appliedServerSnapshot = stored.appliedServerSnapshot;
return assertStateSnapshot({
apiVersion: 1,
revision: stored.revision,
generatedAt: now.toISOString(),
mode,
subscription: {
status: hasSubscription ? 'ready' : 'missing',
host: hasSubscription ? subscriptionHost : '',
fetchedAt: dateOrNull(stored.fetchedAt),
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
profiles,
subscription: selectedSnapshot ? {
status: selectedSnapshot.subscription.status,
host: selectedSnapshot.subscription.host,
fetchedAt: selectedSnapshot.subscription.fetchedAt,
userInfo: selectedSnapshot.subscription.userInfo,
} : {
status: 'missing',
host: '',
fetchedAt: null,
userInfo: {},
},
selection: {
desiredServerId: stored.selectedServerId,
desiredProfileId: selectedProfile?.id || '',
desiredServerId: selectedProfile?.desiredServerId || '',
appliedProfileId: stored.appliedProfileId,
appliedServerId: stored.appliedServerId,
appliedServerSnapshot,
},
connection: {
desired,
process: runtime?.running ? 'running' : 'stopped',
process: running ? 'running' : 'stopped',
startedAt: dateOrNull(runtime?.startedAt),
lastError: null,
},
@@ -219,8 +394,10 @@ export function createStateSnapshot({
status: operation.status,
startedAt: nullableText(operation.startedAt),
error: nullableText(operation.error),
profileId: nullableText(operation.profileId),
serverId: nullableText(operation.serverId),
},
servers: servers as HarborServer[],
servers: selectedProfile?.servers || [],
});
}
@@ -245,6 +422,23 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
Boolean(rule.value) &&
typeof rule.enabled === 'boolean'
);
const validProfile = (profile: ProfileSnapshot) => (
profile &&
typeof profile.id === 'string' && Boolean(profile.id) &&
typeof profile.label === 'string' && Boolean(profile.label) &&
!Object.hasOwn(profile, 'subscriptionUrl') &&
!Object.hasOwn(profile, 'subscriptionConfig') &&
profile.subscription &&
['ready', 'stale'].includes(profile.subscription.status) &&
typeof profile.subscription.host === 'string' &&
!Object.hasOwn(profile.subscription, 'url') &&
nullableDate(profile.subscription.fetchedAt) &&
profile.subscription.userInfo && typeof profile.subscription.userInfo === 'object' &&
nullableDate(profile.subscription.lastRefreshAttemptAt) &&
nullableString(profile.subscription.errorCode) &&
typeof profile.desiredServerId === 'string' &&
Array.isArray(profile.servers) && profile.servers.every(validServer)
);
if (
!snapshot ||
@@ -253,16 +447,22 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
candidate.revision < 0 ||
!validDate(candidate.generatedAt) ||
!MODES.has(candidate.mode) ||
!Array.isArray(candidate.profiles) ||
!candidate.profiles.every(validProfile) ||
new Set(candidate.profiles.map((profile) => profile.id)).size !== candidate.profiles.length ||
!candidate.subscription ||
!['missing', 'ready'].includes(candidate.subscription.status) ||
!['missing', 'ready', 'stale'].includes(candidate.subscription.status) ||
typeof candidate.subscription.host !== 'string' ||
Object.hasOwn(candidate.subscription, 'url') ||
!nullableDate(candidate.subscription.fetchedAt) ||
!candidate.subscription.userInfo ||
typeof candidate.subscription.userInfo !== 'object' ||
!candidate.selection ||
typeof candidate.selection.desiredProfileId !== 'string' ||
typeof candidate.selection.desiredServerId !== 'string' ||
typeof candidate.selection.appliedProfileId !== 'string' ||
typeof candidate.selection.appliedServerId !== 'string' ||
!(candidate.selection.appliedServerSnapshot === null || validServer(candidate.selection.appliedServerSnapshot)) ||
!candidate.connection ||
!CONNECTION_STATES.has(candidate.connection.desired) ||
!CONNECTION_STATES.has(candidate.connection.process) ||
@@ -288,6 +488,8 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
!OPERATION_STATES.has(candidate.operation.status) ||
!nullableDate(candidate.operation.startedAt) ||
!nullableString(candidate.operation.error) ||
!nullableString(candidate.operation.profileId) ||
!nullableString(candidate.operation.serverId) ||
!Array.isArray(candidate.servers) ||
!candidate.servers.every(validServer)
) {
+3
View File
@@ -14,6 +14,9 @@ export const ERROR_DEFINITIONS = Object.freeze({
SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false },
SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false },
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true },
PROFILE_NOT_FOUND: { status: 404, message: 'Подписка больше недоступна.', retryable: false },
PROFILE_NAME_CONFLICT: { status: 409, message: 'Подписка с таким именем уже существует.', retryable: false },
PROFILE_IN_USE: { status: 409, message: 'Сначала переключите или остановите активную подписку.', retryable: false },
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false },
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.22.1',
gatewayClient: '0.23.1',
gatewayBackend: '0.23.1',
macClient: '0.23.0',
gatewayClient: '0.24.0',
gatewayBackend: '0.24.0',
});
export interface ParsedVersion {
+144 -49
View File
@@ -18,7 +18,6 @@ import {
} from './state/operations.js';
const componentActions = {
validateSubscription: api.subscription.validate,
listDevices: api.devices.list,
refreshDevices: api.devices.refresh,
updateDevice: api.devices.update,
@@ -29,23 +28,43 @@ const componentActions = {
interface UiError {
context: string;
profileId: string;
message: string;
code: string;
correlationId: string;
retry: (() => unknown) | null;
}
const operationErrorContext: Record<string, string> = {
start: 'connection',
stop: 'connection',
'apply-server': 'connection',
'profile-activate': 'connection',
'gateway-auto': 'connection',
'profile-add': 'subscription',
'profile-rename': 'subscription',
'profile-select-server': 'subscription',
'profile-refresh': 'subscription',
'profile-delete': 'subscription',
'subscription-import': 'subscription',
'subscription-refresh': 'subscription',
'subscription-forget': 'subscription',
'route-rules': 'routing',
};
export function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
const [{ snapshot: state, transport }, dispatch] = useReducer(
harborReducer,
initialHarborState,
);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [operations, setOperations] = useState<OperationRegistrySnapshot>({});
const [error, setError] = useState<UiError | null>(null);
const [dismissedCanonicalError, setDismissedCanonicalError] = useState('');
const [versionInfo, setVersionInfo] = useState<unknown>(null);
const pollGeneration = useRef(0);
const revisionRef = useRef(0);
const hasAcceptedSnapshotRef = useRef(false);
const operationRegistry = useRef<ReturnType<typeof createOperationRegistry> | null>(null);
if (!operationRegistry.current) {
operationRegistry.current = createOperationRegistry((next) => {
@@ -53,16 +72,16 @@ export function App() {
});
}
function setPendingServerId(serverId: string) {
dispatch({ type: 'select-server', serverId });
}
async function loadState({ retry = false }: { retry?: boolean } = {}) {
if (retry) dispatch({ type: 'retry-sync' });
const generation = pollGeneration.current;
try {
const snapshot = await harborClient.getState();
if (generation === pollGeneration.current) {
if (!hasAcceptedSnapshotRef.current || snapshot.revision > revisionRef.current) {
hasAcceptedSnapshotRef.current = true;
revisionRef.current = snapshot.revision;
}
dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
}
} catch (requestError) {
@@ -98,30 +117,43 @@ export function App() {
if (favicon) favicon.href = isGateway ? '/harbor-gateway.svg?v=2' : '/harbor-connect.svg?v=2';
}, [state?.mode]);
function run(key: OperationKey, action: () => Promise<unknown>, context: string) {
const canonicalErrorId = state?.operation?.status === 'failed' && state.operation.error
? [state.operation.kind, state.operation.startedAt, state.operation.profileId, state.operation.error].join(':')
: '';
useEffect(() => setDismissedCanonicalError(''), [canonicalErrorId]);
function run(
key: OperationKey,
action: () => Promise<unknown>,
context: string,
target = '',
profileId = '',
) {
setError(null);
return operationRegistry.current!.run(key, async () => {
try {
return await applyMutation(action);
} catch (err) {
await loadState();
const candidate = err && typeof err === 'object' ? err as Record<string, unknown> : {};
const safeError = err instanceof HarborApiError
? err
: new HarborApiError({ code: candidate.code }, Number(candidate.status));
setError({
context,
profileId,
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
: safeError.message,
code: safeError.code,
correlationId: safeError.correlationId,
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
? () => run(key, action, context)
? () => run(key, action, context, target, profileId)
: null,
});
return false;
}
});
}, target);
}
async function applyMutation(action: () => Promise<unknown>) {
@@ -133,6 +165,10 @@ export function App() {
const result = response as Record<string, unknown>;
if (!result.state) throw new Error('Harbor API не вернул state snapshot');
const snapshot = parseHarborState(result.state);
if (!hasAcceptedSnapshotRef.current || snapshot.revision > revisionRef.current) {
hasAcceptedSnapshotRef.current = true;
revisionRef.current = snapshot.revision;
}
dispatch({
type: 'sync-succeeded',
snapshot,
@@ -141,36 +177,59 @@ export function App() {
return result;
}
async function fetchSubscription() {
return run('subscriptionImport', async () => {
const data = await api.subscription.fetch(subscriptionUrl);
dispatch({ type: 'clear-pending-server' });
return data;
}, 'subscription');
}
async function refreshSubscription() {
return run('subscriptionRefresh', api.subscription.refresh, 'subscription');
}
async function forgetSubscription() {
return run('subscriptionDelete', async () => {
const data = await api.subscription.forget();
setSubscriptionUrl('');
dispatch({ type: 'clear-pending-server' });
return data;
}, 'subscription');
}
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
const previewServer = {
id: 'preview-amsterdam',
label: 'Amsterdam',
host: '127.0.0.1',
port: 443,
protocol: 'vless',
};
const displayState = previewReady ? {
...state,
mode: 'client' as const,
profiles: [{
id: 'preview-personal',
label: 'Личный',
subscription: {
status: 'ready' as const,
host: 'harbor.example/…',
fetchedAt: new Date().toISOString(),
userInfo: {},
lastRefreshAttemptAt: new Date().toISOString(),
errorCode: null,
},
desiredServerId: previewServer.id,
servers: [previewServer],
}],
subscription: { ...state.subscription, status: 'ready' as const, host: 'harbor.example' },
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
selection: {
...state.selection,
desiredProfileId: 'preview-personal',
desiredServerId: 'preview-amsterdam',
appliedProfileId: 'preview-personal',
appliedServerId: 'preview-amsterdam',
appliedServerSnapshot: previewServer,
},
servers: [previewServer],
clientRuntime: { ...state.clientRuntime, proxyPort: 8082 },
} : state;
const canonicalErrorContext = operationErrorContext[state.operation.kind || ''];
const visibleError = error || (
canonicalErrorId
&& canonicalErrorId !== dismissedCanonicalError
&& canonicalErrorContext
? {
context: canonicalErrorContext,
profileId: state.operation.profileId || '',
message: state.operation.error || 'Операция не выполнена.',
code: 'UNKNOWN',
correlationId: '',
retry: null,
}
: null
);
return (
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
@@ -182,22 +241,55 @@ export function App() {
state={displayState}
versionInfo={versionInfo}
operations={operations}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={previewReady ? [{
id: 'preview-amsterdam',
label: 'Amsterdam',
host: '127.0.0.1',
port: 443,
protocol: 'vless',
}] : state.servers || []}
pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId}
setPendingServerId={setPendingServerId}
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onApply={(serverId: string) => run('serverApply', () => api.apply(serverId), 'connection')}
error={visibleError}
onAddProfile={(label: string, url: string) => run(
'profileAdd',
() => api.profiles.add(label, url, revisionRef.current),
'subscription',
label,
)}
onRenameProfile={(profileId: string, label: string) => run(
'profileRename',
() => api.profiles.rename(profileId, label, revisionRef.current),
'subscription',
profileId,
profileId,
)}
onSelectProfileServer={(profileId: string, serverId: string) => run(
'profileSelect',
() => api.profiles.selectServer(profileId, serverId, revisionRef.current),
'subscription',
`${profileId}:${serverId}`,
profileId,
)}
onActivateProfile={(profileId: string) => run(
'profileActivate',
() => api.profiles.activate(profileId, revisionRef.current),
'connection',
profileId,
profileId,
)}
onRefreshProfile={(profileId: string) => run(
'profileRefresh',
() => api.profiles.refresh(profileId, revisionRef.current),
'subscription',
profileId,
profileId,
)}
onForgetProfile={(profileId: string, mode: 'delete' | 'stop-and-delete') => run(
'profileDelete',
() => api.profiles.forget(profileId, mode, revisionRef.current),
'subscription',
profileId,
profileId,
)}
onApply={(profileId: string, serverId: string) => run(
'serverApply',
() => api.apply(profileId, serverId, revisionRef.current),
'connection',
`${profileId}:${serverId}`,
profileId,
)}
onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled: boolean) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
@@ -206,7 +298,10 @@ export function App() {
() => api.routeRules.update(rules, expectedRevision),
'routing',
)}
onDismissError={() => setError(null)}
onDismissError={() => {
setError(null);
setDismissedCanonicalError(canonicalErrorId);
}}
/>
</main>
</div>
+49 -5
View File
@@ -96,10 +96,50 @@ export const api = {
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (serverId: string) => request('/api/apply', {
profiles: {
add: (label: string, url: string, expectedRevision: number) => request('/api/profiles', {
method: 'POST',
// selectedTag keeps this client compatible with pre-ID Harbor backends.
body: JSON.stringify({ serverId, selectedTag: serverId }),
body: JSON.stringify({ label, url, expectedRevision }),
}),
rename: (profileId: string, label: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}`,
{
method: 'PATCH',
body: JSON.stringify({ label, expectedRevision }),
},
),
selectServer: (profileId: string, serverId: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}/server`,
{
method: 'PUT',
body: JSON.stringify({ serverId, expectedRevision }),
},
),
activate: (profileId: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}/activate`,
{
method: 'POST',
body: JSON.stringify({ expectedRevision }),
},
),
refresh: (profileId: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}/refresh`,
{
method: 'POST',
body: JSON.stringify({ expectedRevision }),
},
),
forget: (profileId: string, mode: 'delete' | 'stop-and-delete', expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}`,
{
method: 'DELETE',
body: JSON.stringify({ mode, expectedRevision }),
},
),
},
apply: (profileId: string, serverId: string, expectedRevision: number) => request('/api/apply', {
method: 'POST',
body: JSON.stringify({ profileId, serverId, expectedRevision }),
}),
gatewayAuto: {
setEnabled: (enabled: boolean) => request('/api/gateway-auto', {
@@ -145,10 +185,13 @@ export const api = {
restart: () => request('/api/singbox/restart', { method: 'POST' }),
},
servers: {
ping: (serverIds: string[]) => request('/api/servers/ping-all', {
ping: (profileId: string, serverIds: string[]) => request(
`/api/profiles/${encodeURIComponent(profileId)}/servers/ping`,
{
method: 'POST',
body: JSON.stringify({ serverIds }),
}),
},
),
},
};
@@ -177,6 +220,7 @@ export function parseHarborState(value: unknown): HarborClientState {
revision: snapshot.revision,
generatedAt: snapshot.generatedAt,
mode: snapshot.mode,
profiles: snapshot.profiles,
subscription: snapshot.subscription,
selection: snapshot.selection,
connection: snapshot.connection,
+163 -83
View File
@@ -51,7 +51,7 @@ import {
versionCompatibility,
} from '../../shared/versions.js';
import type {
HarborServer,
ProfileSnapshot,
RouteRule,
StateSnapshot,
} from '../../shared/contracts/state.js';
@@ -64,6 +64,7 @@ const VERSION_PARTS = [
interface UiError {
context?: string;
profileId?: string;
message?: string;
correlationId?: string;
retry?: (() => unknown) | null;
@@ -79,12 +80,11 @@ interface VersionBadgeProps {
}
interface ComponentActions {
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
listDevices: () => Promise<unknown>;
refreshDevices: () => Promise<unknown>;
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
pingServers: (ids: string[]) => Promise<unknown>;
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise<unknown>;
}
@@ -102,15 +102,13 @@ interface ClientOverviewPageProps {
versionInfo: unknown;
operations?: OperationRegistrySnapshot;
error: UiError | null;
subscriptionUrl: string;
setSubscriptionUrl: (value: string) => void;
servers: HarborServer[];
pendingServerId: string;
setPendingServerId: (id: string) => void;
onFetchSubscription: () => Promise<unknown>;
onRefreshSubscription: () => Promise<unknown>;
onForgetSubscription: () => Promise<unknown>;
onApply: (serverId: string) => Promise<unknown>;
onAddProfile: (label: string, url: string) => Promise<unknown>;
onRenameProfile: (profileId: string, label: string) => Promise<unknown>;
onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
onActivateProfile: (profileId: string) => Promise<unknown>;
onRefreshProfile: (profileId: string) => Promise<unknown>;
onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
onApply: (profileId: string, serverId: string) => Promise<unknown>;
onRestart: () => Promise<unknown>;
onStop: () => Promise<unknown>;
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
@@ -243,11 +241,30 @@ function InlineError({ error, context }: { error?: UiError | null; context: stri
const operationProgress: Partial<Record<keyof OperationRegistrySnapshot, readonly [string, string]>> = {
connection: ['connection', 'Меняем состояние подключения…'],
serverApply: ['connection', 'Применяем сервер…'],
subscriptionImport: ['subscription', 'Загружаем подписку…'],
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
profileActivate: ['connection', 'Переключаем подписку…'],
profileAdd: ['subscription', 'Добавляем подписку…'],
profileRefresh: ['subscription', 'Обновляем подписку…'],
profileDelete: ['subscription', 'Удаляем подписку…'],
routeRules: ['routing', 'Применяем локальные правила…'],
};
const canonicalOperationKeys: Record<string, OperationKey> = {
start: 'connection',
stop: 'connection',
'apply-server': 'serverApply',
'profile-add': 'profileAdd',
'profile-rename': 'profileRename',
'profile-select-server': 'profileSelect',
'profile-activate': 'profileActivate',
'profile-refresh': 'profileRefresh',
'profile-delete': 'profileDelete',
'gateway-auto': 'gatewayAuto',
'route-rules': 'routeRules',
'subscription-import': 'profileAdd',
'subscription-refresh': 'profileRefresh',
'subscription-forget': 'profileDelete',
};
function InlineProgress({ operations, context }: {
operations: OperationRegistrySnapshot;
context: string;
@@ -266,6 +283,29 @@ function InlineProgress({ operations, context }: {
);
}
function AppliedIdentity({ identity, operation }: { identity: string; operation: string }) {
const [current, setCurrent] = useState(identity);
const [previous, setPrevious] = useState('');
useEffect(() => {
if (identity === current) return undefined;
setPrevious(current);
setCurrent(identity);
const timer = setTimeout(() => setPrevious(''), 360);
return () => clearTimeout(timer);
}, [identity]);
return <div className="client-applied-identity" aria-label={identity}>
<span className="client-applied-value" aria-hidden="true">
{previous && <strong className="is-leaving">{previous}</strong>}
<strong key={current} className="is-active">{current}</strong>
</span>
<div className="client-applied-operation">
{operation && <span>{operation}</span>}
</div>
</div>;
}
function HarborBrand({ isGateway, connected, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: {
isGateway: boolean;
connected: boolean;
@@ -369,14 +409,12 @@ export function ClientOverviewPage({
versionInfo,
operations = {},
error,
subscriptionUrl,
setSubscriptionUrl,
servers,
pendingServerId,
setPendingServerId,
onFetchSubscription,
onRefreshSubscription,
onForgetSubscription,
onAddProfile,
onRenameProfile,
onSelectProfileServer,
onActivateProfile,
onRefreshProfile,
onForgetProfile,
onApply,
onRestart,
onStop,
@@ -388,12 +426,18 @@ export function ClientOverviewPage({
const gatewayDirect = !isGateway && state?.route?.mode === 'gateway-direct';
const gatewayAvailable = !isGateway && Boolean(state?.clientRuntime?.gatewayAvailable);
const connected = state?.connection?.process === 'running';
const hasSubscription = state?.subscription?.status === 'ready';
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
const profiles = state?.profiles || [];
const hasSubscription = profiles.length > 0;
const desiredProfile = profiles.find(({ id }) => id === state?.selection?.desiredProfileId);
const appliedProfile = profiles.find(({ id }) => id === state?.selection?.appliedProfileId);
const selectedServerId = desiredProfile?.desiredServerId || '';
const appliedServerId = state?.selection?.appliedServerId || '';
const appliedServer = servers.find(({ id }) => id === appliedServerId);
const desiredServer = servers.find(({ id }) => id === selectedServerId);
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
const appliedServer = appliedProfile?.servers.find(({ id }) => id === appliedServerId)
|| (state?.selection?.appliedServerSnapshot?.id === appliedServerId
? state.selection.appliedServerSnapshot
: null);
const desiredServer = desiredProfile?.servers.find(({ id }) => id === selectedServerId);
const showPower = hasSubscription;
const [now, setNow] = useState(Date.now());
const [showIntro, setShowIntro] = useState(true);
const [copyFeedback, setCopyFeedback] = useState<CopyFeedbackMap>({});
@@ -403,32 +447,62 @@ export function ClientOverviewPage({
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const controlHost = window.location.host || `${gatewayAddress}:3456`;
const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress);
const connectionBlocked = operationBlocked(operations, 'connection');
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
const switchingServer = Boolean(
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
);
const canonicalOperationKey = state.operation.status === 'running'
? canonicalOperationKeys[state.operation.kind || '']
: undefined;
const canonicalTarget = state.operation.profileId
? `${state.operation.profileId}${state.operation.serverId ? `:${state.operation.serverId}` : ''}`
: '';
const visibleOperations = canonicalOperationKey && !operations[canonicalOperationKey]
? {
...operations,
[canonicalOperationKey]: {
status: 'running' as const,
startedAt: state.operation.startedAt || state.generatedAt,
target: canonicalTarget,
},
}
: operations;
const connectionBlocked = operationBlocked(visibleOperations, 'connection');
const serverApplyBlocked = operationBlocked(visibleOperations, 'serverApply');
const gatewayAutoBlocked = operationBlocked(visibleOperations, 'gatewayAuto');
const localApplyTarget = operations.serverApply?.target.split(':') || [];
const canonicalSwitch = state.operation.status === 'running'
&& ['profile-activate', 'apply-server'].includes(state.operation.kind || '');
const operationProfileId = canonicalSwitch
? state.operation.profileId || ''
: operations.profileActivate?.target || localApplyTarget[0] || '';
const operationProfile = profiles.find(({ id }) => id === operationProfileId);
const operationServerId = canonicalSwitch
? state.operation.serverId || operationProfile?.desiredServerId || ''
: localApplyTarget[1] || operationProfile?.desiredServerId || '';
const operationServer = operationProfile?.servers.find(({ id }) => id === operationServerId);
const localSwitch = operations.profileActivate?.status === 'running'
|| operations.serverApply?.status === 'running';
const switchingServer = connected
&& !gatewayDirect
&& (canonicalSwitch || localSwitch)
&& Boolean(operationProfile && operationServer)
&& (operationProfile?.id !== appliedProfile?.id || operationServer?.id !== appliedServer?.id);
const subscriptionFeature = useSubscriptionFeature({
subscription: state?.subscription,
subscriptionUrl,
setSubscriptionUrl,
operations,
profiles,
selection: state.selection,
connected,
operations: visibleOperations,
error,
serverCount: servers.length,
isGateway,
gatewayDirect,
validateSubscription: actions.validateSubscription,
onImport: onFetchSubscription,
onRefresh: onRefreshSubscription,
onForget: onForgetSubscription,
onAdd: onAddProfile,
onRename: onRenameProfile,
onRefresh: onRefreshProfile,
onForget: onForgetProfile,
onActivate: onActivateProfile,
onDismissError,
});
const subscriptionContentReady = subscriptionFeature.contentReady;
const routingFeature = useRoutingFeature({
route: state?.route,
connected,
operations,
operations: visibleOperations,
onSave: onSaveRouteRules,
onDismissError,
});
@@ -446,7 +520,7 @@ export function ClientOverviewPage({
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
controlHost,
});
const diagnosticsAvailable = isGateway || (hasSubscription && subscriptionContentReady);
const diagnosticsAvailable = hasSubscription;
useEffect(() => {
setNow(Date.now());
@@ -465,12 +539,10 @@ export function ClientOverviewPage({
useEffect(() => {
if (!hasSubscription) {
routingFeature.forceClose();
if (!isGateway) {
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
}
}
}, [hasSubscription, isGateway]);
useEffect(() => {
@@ -482,9 +554,12 @@ export function ClientOverviewPage({
copyAttemptsRef.current = {};
}, []);
function selectServer(serverId: string) {
setPendingServerId(serverId);
if (connected && serverId) onApply(serverId);
function selectServer(profile: ProfileSnapshot, serverId: string) {
if (connected && !gatewayDirect && state.selection.appliedProfileId === profile.id) {
onApply(profile.id, serverId);
return;
}
onSelectProfileServer(profile.id, serverId);
}
async function copyProxy(kind: CopyKind) {
@@ -529,16 +604,31 @@ export function ClientOverviewPage({
routingFeature.open();
}
const mainIdentity = gatewayDirect
? 'Gateway · сервер не определён'
: connected
? appliedProfile && appliedServer
? `${appliedProfile.label} · ${appliedServer.label}`
: 'VPN · сервер не определён'
: desiredProfile && desiredServer
? `Выбран: ${desiredProfile.label} · ${desiredServer.label}`
: 'Сервер не выбран';
const switchIdentity = gatewayDirect
? 'Данные применённого сервера Gateway недоступны'
: switchingServer && operationProfile && operationServer
? `Переключаем на ${operationProfile.label} · ${operationServer.label}`
: '';
return (
<div
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
className={`client-shell${!hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
<span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span>
</div>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{isGateway && <SubscriptionToggle
{hasSubscription && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
<SubscriptionToggle
feature={subscriptionFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
@@ -547,7 +637,7 @@ export function ClientOverviewPage({
diagnosticsFeature.close();
subscriptionFeature.toggle();
}}
/>}
/>
<InstructionsToggle
feature={instructionsFeature}
onToggle={() => {
@@ -586,14 +676,13 @@ export function ClientOverviewPage({
onOpen={openRouting}
/>
</nav>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway && hasSubscription ? ' is-gateway-home' : ''}`}>
<ConnectionPanel
visible={showPower}
isGateway={isGateway}
connected={connected}
gatewayDirect={gatewayDirect}
selectedServerId={selectedServerId}
configured={Boolean(state?.clientRuntime?.configured)}
startedAt={state?.connection?.startedAt}
gatewayAddress={gatewayAddress}
gatewayUiOrigin={state?.route?.gatewayUiOrigin}
@@ -603,7 +692,7 @@ export function ClientOverviewPage({
blocked={connectionBlocked}
brandSlot={<HarborBrand
isGateway={isGateway}
connected={connected}
connected={connected || gatewayDirect}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
@@ -611,56 +700,47 @@ export function ClientOverviewPage({
/>}
copyFeedback={copyFeedback}
onCopyProxy={copyProxy}
onApply={onApply}
onRestart={onRestart}
onApply={(serverId) => desiredProfile && onApply(desiredProfile.id, serverId)}
onStop={onStop}
routingSlot={<RoutingPendingStatus
feature={routingFeature}
blocked={connectionBlocked}
onRestart={onRestart}
/>}
serverSlot={isGateway && <div className="client-gateway-route-summary" aria-labelledby="gateway-summary-title">
<span className="client-gateway-summary-kicker">Сейчас</span>
<strong id="gateway-summary-title">
{appliedServer?.label || 'VPN-сервер не используется'}
</strong>
<div className="client-gateway-route-slot">
{switchingServer && desiredServer && <span>Переключаем на {desiredServer.label}</span>}
</div>
</div>}
serverSlot={<AppliedIdentity identity={mainIdentity} operation={switchIdentity} />}
statusSlot={<>
<InlineError error={error} context="connection" />
<InlineProgress operations={operations} context="connection" />
<InlineProgress operations={visibleOperations} context="connection" />
</>}
/>
{isGateway && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
{isGateway && hasSubscription && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
<SubscriptionPanel
feature={subscriptionFeature}
statusSlot={<>
<InlineError error={subscriptionFeature.error || error} context="subscription" />
<InlineProgress operations={operations} context="subscription" />
</>}
serverSlot={hasSubscription && subscriptionContentReady && <ServerPicker
renderServerPicker={(profile, pickerState) => <ServerPicker
profileId={profile.id}
pingServers={actions.pingServers}
servers={servers}
selectedServerId={selectedServerId}
disabled={serverApplyBlocked}
prompt={!showPower}
leaving={subscriptionFeature.serversLeaving}
revealVersion={subscriptionFeature.serverRevealVersion}
onSelect={selectServer}
servers={profile.servers}
selectedServerId={profile.desiredServerId}
disabled={serverApplyBlocked || pickerState.disabled}
prompt={!profile.desiredServerId}
leaving={pickerState.leaving}
revealVersion={pickerState.revealVersion}
onSelect={(serverId) => selectServer(profile, serverId)}
/>}
/>
</main>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <InstructionsPanel
{hasSubscription && <InstructionsPanel
feature={instructionsFeature}
isGateway={isGateway}
/>}
{isGateway && <DevicesPanel feature={devicesFeature} />}
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature}
@@ -668,11 +748,11 @@ export function ClientOverviewPage({
isGateway={isGateway}
/>}
{hasSubscription && subscriptionContentReady && <RoutingPanel
{hasSubscription && <RoutingPanel
feature={routingFeature}
statusSlot={<>
<InlineError error={error} context="routing" />
<InlineProgress operations={operations} context="routing" />
<InlineProgress operations={visibleOperations} context="routing" />
</>}
/>}
<RoutingDiscardDialog feature={routingFeature} />
+21 -21
View File
@@ -26,7 +26,6 @@ interface ConnectionPanelProps {
connected: boolean;
gatewayDirect: boolean;
selectedServerId: string;
configured: boolean;
startedAt?: string | null;
gatewayAddress: string;
gatewayUiOrigin?: string | null;
@@ -41,7 +40,6 @@ interface ConnectionPanelProps {
statusSlot?: ReactNode;
onCopyProxy: (kind: CopyKind) => unknown;
onApply: (serverId: string) => unknown;
onRestart: () => unknown;
onStop: () => unknown;
}
@@ -67,7 +65,6 @@ export function ConnectionPanel({
connected,
gatewayDirect,
selectedServerId,
configured,
startedAt,
gatewayAddress,
gatewayUiOrigin,
@@ -82,7 +79,6 @@ export function ConnectionPanel({
statusSlot,
onCopyProxy,
onApply,
onRestart,
onStop,
}: ConnectionPanelProps) {
const [durationMode, setDurationMode] = useState(() => {
@@ -93,8 +89,9 @@ export function ConnectionPanel({
}
});
const [confirmingStop, setConfirmingStop] = useState(false);
const canStart = Boolean(selectedServerId || configured);
const powerUnavailable = isGateway && !connected && !canStart;
const remoteOwned = !isGateway && gatewayDirect;
const canStart = Boolean(selectedServerId);
const powerUnavailable = remoteOwned || (!connected && !canStart);
const proxyUrls = localProxyUrls(proxyPort, gatewayAddress);
const duration = connectionDurationParts(startedAt, now);
const clockUnits: Array<[string, DurationUnit]> = [
@@ -104,9 +101,9 @@ export function ConnectionPanel({
];
const wordClockDuration = clockUnits
.filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено';
const connectionTitle = remoteOwned
? 'Gateway подключён'
: connected ? 'VPN включён' : 'Подключение выключено';
const proxyKinds: Array<[CopyKind, string]> = isGateway
? [
['gateway', 'GATEWAY'],
@@ -119,13 +116,12 @@ export function ConnectionPanel({
];
function toggleConnection() {
const action = connectionAction({ connected, selectedServerId, configExists: configured });
const action = connectionAction({ connected, selectedServerId });
if (action?.type === 'stop') {
setConfirmingStop(true);
return;
}
if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart();
}
async function stopConnection() {
@@ -149,12 +145,14 @@ export function ConnectionPanel({
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={isGateway
aria-checked={connected || remoteOwned}
aria-label={remoteOwned
? 'Подключением управляет Harbor Gateway'
: isGateway
? connected ? 'Остановить VPN' : 'Запустить VPN'
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
disabled={blocked || (!connected && !canStart)}
disabled={blocked || powerUnavailable}
onClick={toggleConnection}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -165,7 +163,7 @@ export function ConnectionPanel({
return <>
{visible ? <section className={`client-power-section${isGateway ? ' is-gateway' : ''}`} aria-labelledby="connection-title">
<div
className={`client-power-control${isGateway ? ' client-tooltip-anchor' : ''}`}
className={`client-power-control${powerUnavailable ? ' client-tooltip-anchor' : ''}`}
tabIndex={powerUnavailable ? 0 : undefined}
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
@@ -173,11 +171,11 @@ export function ConnectionPanel({
{brandSlot}
{powerButton}
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip">
Сначала добавьте подписку и выберите сервер
{remoteOwned ? 'Подключением управляет Harbor Gateway' : 'Сначала добавьте подписку и выберите сервер'}
</span>}
</div>
<div className="client-state-detail">
{connected ? (
{connected && !remoteOwned ? (
<button
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button"
@@ -224,16 +222,18 @@ export function ConnectionPanel({
</button>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
{remoteOwned
? 'Управляется Harbor Gateway'
: canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
{routingSlot}
<div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
<span className={!connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={remoteOwned ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2>
{serverSlot}
</div>
+21 -13
View File
@@ -53,9 +53,9 @@ function write(key: string, value: string | string[]) {
}
}
function readAuto() {
function readAuto(key: string) {
try {
return localStorage.getItem(AUTO_KEY) === 'true';
return localStorage.getItem(key) === 'true';
} catch {
return false;
}
@@ -153,7 +153,8 @@ function ServerRow({
}
interface ServerPickerProps {
pingServers: (ids: string[]) => Promise<unknown>;
profileId: string;
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
servers: PickerServer[];
selectedServerId: string;
disabled: boolean;
@@ -164,6 +165,7 @@ interface ServerPickerProps {
}
export function ServerPicker({
profileId,
pingServers,
servers,
selectedServerId,
@@ -173,13 +175,16 @@ export function ServerPicker({
revealVersion,
onSelect,
}: ServerPickerProps) {
const favoritesKey = `${FAVORITES_KEY}:${profileId}`;
const recentKey = `${RECENT_KEY}:${profileId}`;
const autoKey = `${AUTO_KEY}:${profileId}`;
const [query, setQuery] = useState('');
const [advanced, setAdvanced] = useState(false);
const [view, setView] = useState<'all' | 'favorites' | 'recent'>('all');
const [page, setPage] = useState(0);
const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY));
const [recent, setRecent] = useState(() => readList(RECENT_KEY));
const [autoActive, setAutoActive] = useState(readAuto);
const [favorites, setFavorites] = useState(() => readList(favoritesKey));
const [recent, setRecent] = useState(() => readList(recentKey));
const [autoActive, setAutoActive] = useState(() => readAuto(autoKey));
const [collapsed, setCollapsed] = useState<string[]>([]);
const [pings, setPings] = useState<PingState>({});
const [checking, setChecking] = useState(false);
@@ -208,18 +213,18 @@ export function ServerPicker({
function toggleFavorite(id: string) {
setFavorites((current) => {
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
write(FAVORITES_KEY, next);
write(favoritesKey, next);
return next;
});
}
function select(id: string, automatic = false) {
setAutoActive(automatic);
write(AUTO_KEY, String(automatic));
write(autoKey, String(automatic));
if (!automatic) {
setRecent((current) => {
const next = [id, ...current.filter((item) => item !== id)].slice(0, 5);
write(RECENT_KEY, next);
write(recentKey, next);
return next;
});
}
@@ -236,7 +241,7 @@ export function ServerPicker({
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])),
}));
try {
const results = parseServerPingResults(await pingServers(ids)) as PingResult[];
const results = parseServerPingResults(await pingServers(profileId, ids)) as PingResult[];
setPings((current) => ({
...current,
...Object.fromEntries(results.map((result) => [result.id, { ...result, checking: true }])),
@@ -251,7 +256,10 @@ export function ServerPicker({
}])),
}));
} finally {
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 900 - (performance.now() - startedAt))));
const elapsed = performance.now() - startedAt;
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const completeAt = reduced ? elapsed : Math.max(900, Math.ceil(elapsed / 900) * 900);
await new Promise((resolve) => setTimeout(resolve, Math.max(0, completeAt - elapsed)));
setPings((current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])),
@@ -324,7 +332,7 @@ export function ServerPicker({
inert={advanced ? true : undefined}
>
<div className="client-server-mode-panel-inner">
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${serverKey}:${revealVersion}`}>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${revealVersion}`}>
<div className="client-server-grid">
{simpleServers.map((server, index) => <ServerRow
key={server.id}
@@ -401,7 +409,7 @@ export function ServerPicker({
/>}
</div>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${serverKey}:${revealVersion}`}>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${revealVersion}`}>
{!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
{grouped ? groupServers(visible).map(([group, items]) => {
const isCollapsed = collapsed.includes(group);
File diff suppressed because it is too large Load Diff
-24
View File
@@ -4,7 +4,6 @@ export type SyncErrorKind = 'incompatible-api' | 'control-unreachable' | 'fatal'
export interface HarborReducerState {
snapshot: HarborClientState | null;
pendingServerId: string;
transport: {
bootStatus: 'loading' | 'ready' | SyncErrorKind;
lastSuccessfulSyncAt: string | null;
@@ -15,15 +14,12 @@ export interface HarborReducerState {
}
export type HarborAction =
| { type: 'select-server'; serverId: string }
| { type: 'clear-pending-server' }
| { type: 'retry-sync' }
| { type: 'sync-failed'; error: unknown }
| { type: 'sync-succeeded'; snapshot: HarborClientState; receivedAt: string };
export const initialHarborState: HarborReducerState = {
snapshot: null,
pendingServerId: '',
transport: {
bootStatus: 'loading',
lastSuccessfulSyncAt: null,
@@ -43,24 +39,7 @@ export function classifySyncError(error: unknown): SyncErrorKind {
return 'fatal';
}
function reconcilePendingServer(pendingServerId: string, snapshot: HarborClientState) {
if (!pendingServerId || snapshot.selection.desiredServerId === pendingServerId) return '';
return snapshot.servers.some((server) => server.id === pendingServerId)
? pendingServerId
: '';
}
export function harborReducer(current: HarborReducerState, action: HarborAction): HarborReducerState {
if (action.type === 'select-server') {
return action.serverId === current.pendingServerId
? current
: { ...current, pendingServerId: action.serverId };
}
if (action.type === 'clear-pending-server') {
return current.pendingServerId ? { ...current, pendingServerId: '' } : current;
}
if (action.type === 'retry-sync') {
return current.snapshot ? current : {
...current,
@@ -95,9 +74,6 @@ export function harborReducer(current: HarborReducerState, action: HarborAction)
return {
snapshot: newer ? snapshot : current.snapshot,
pendingServerId: newer
? reconcilePendingServer(current.pendingServerId, snapshot)
: current.pendingServerId,
transport: {
bootStatus: 'ready',
lastSuccessfulSyncAt: action.receivedAt,
+35 -21
View File
@@ -1,23 +1,36 @@
export type OperationKey = 'connection' | 'serverApply' | 'subscriptionImport'
| 'subscriptionRefresh' | 'subscriptionDelete' | 'gatewayAuto' | 'routeRules';
export interface OperationState { status: 'running'; startedAt: string }
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
| 'gatewayAuto' | 'routeRules';
export interface OperationState {
status: 'running';
startedAt: string;
target: string;
}
export type OperationRegistrySnapshot = Partial<Record<OperationKey, OperationState>>;
export const OPERATION_CONFLICTS: Readonly<Record<OperationKey, readonly OperationKey[]>> = Object.freeze({
connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto', 'routeRules'],
gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'routeRules'],
routeRules: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
});
const OPERATION_KEYS: readonly OperationKey[] = [
'connection',
'serverApply',
'profileAdd',
'profileRename',
'profileSelect',
'profileActivate',
'profileRefresh',
'profileDelete',
'gatewayAuto',
'routeRules',
];
// The backend has one canonical revision and one mutation queue, so the UI mirrors that lock.
export const OPERATION_CONFLICTS = Object.freeze(Object.fromEntries(
OPERATION_KEYS.map((key) => [key, OPERATION_KEYS.filter((candidate) => candidate !== key)]),
) as unknown as Record<OperationKey, readonly OperationKey[]>);
export function operationBlocked(operations: OperationRegistrySnapshot, key: OperationKey) {
if (operations[key]?.status === 'running') return true;
return (OPERATION_CONFLICTS[key] || []).some(
(conflict) => operations[conflict]?.status === 'running',
);
return OPERATION_CONFLICTS[key].some((conflict) => operations[conflict]?.status === 'running');
}
export function createOperationRegistry(
@@ -25,14 +38,15 @@ export function createOperationRegistry(
now = () => new Date().toISOString(),
) {
let operations: OperationRegistrySnapshot = {};
const inFlight = new Map<OperationKey, Promise<unknown>>();
const inFlight = new Map<string, Promise<unknown>>();
function run<T>(key: OperationKey, action: () => T | Promise<T>): Promise<T | false> {
const existing = inFlight.get(key);
function run<T>(key: OperationKey, action: () => T | Promise<T>, target = ''): Promise<T | false> {
const identity = `${key}:${target}`;
const existing = inFlight.get(identity);
if (existing) return existing as Promise<T>;
if (operationBlocked(operations, key)) return Promise.resolve(false);
operations = { ...operations, [key]: { status: 'running', startedAt: now() } };
operations = { ...operations, [key]: { status: 'running', startedAt: now(), target } };
onChange(operations);
const promise: Promise<T> = Promise.resolve()
@@ -40,10 +54,10 @@ export function createOperationRegistry(
.finally(() => {
const { [key]: completed, ...remaining } = operations;
operations = remaining;
inFlight.delete(key);
inFlight.delete(identity);
onChange(operations);
});
inFlight.set(key, promise as Promise<unknown>);
inFlight.set(identity, promise as Promise<unknown>);
return promise;
}
+59
View File
@@ -28,6 +28,65 @@
min-height: 76px;
}
.client-applied-identity {
min-height: 38px;
display: grid;
justify-items: center;
align-content: start;
gap: 3px;
padding-top: 5px;
}
.client-applied-value {
max-width: min(320px, calc(100vw - 42px));
display: grid;
}
.client-applied-value strong {
grid-area: 1 / 1;
max-width: min(320px, calc(100vw - 42px));
overflow: hidden;
color: var(--client-text);
font-size: 12px;
font-weight: 650;
letter-spacing: -0.025em;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity 260ms ease, filter 360ms cubic-bezier(0.16, 1, 0.3, 1), transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-applied-value strong.is-active {
animation: client-applied-identity-in 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-applied-value strong.is-leaving {
opacity: 0;
filter: blur(5px);
transform: translateY(-2px);
}
.client-applied-operation {
width: 100%;
min-height: 15px;
overflow: hidden;
color: var(--client-accent);
font-size: 8px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes client-applied-identity-in {
from { opacity: 0; filter: blur(5px); transform: translateY(2px); }
}
@media (prefers-reduced-motion: reduce) {
.client-applied-value strong {
transition: none;
animation: none;
}
}
.client-power-control {
position: relative;
width: 96px;
-1
View File
@@ -1099,7 +1099,6 @@
text-align: center;
}
.client-gateway-summary-kicker,
.client-gateway-traffic-total > small {
color: var(--client-muted);
font-size: 9px;
+439 -452
View File
@@ -1,454 +1,3 @@
.client-icon-tooltip {
width: 32px;
height: 32px;
display: grid;
place-items: center;
}
.client-subscription-drawer .client-subscription,
.client-subscription-drawer .client-usage,
.client-subscription-drawer .client-servers {
width: min(100%, 300px);
margin-inline: auto;
}
.client-subscription {
position: relative;
min-width: 0;
min-height: 88px;
display: grid;
align-items: center;
justify-items: center;
}
.client-subscription-refresh,
.client-subscription-delete {
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--client-muted);
cursor: pointer;
transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-refresh svg,
.client-subscription-delete svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.client-subscription-delete .client-trash-lid {
transform-origin: 12px 7px;
transition: transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-delete:hover:not(:disabled) .client-trash-lid,
.client-subscription-delete:focus-visible .client-trash-lid {
transform: translateY(-2px) rotate(-10deg);
}
.client-subscription-refresh:hover:not(:disabled) {
color: var(--client-accent);
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 55%, transparent));
transform: rotate(90deg);
}
.client-subscription-refresh:disabled {
color: var(--client-accent);
cursor: wait;
animation: client-spin 900ms linear infinite;
}
.client-subscription-refresh:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-subscription-delete:hover:not(:disabled) {
color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 6px oklch(0.68 0.15 28 / 0.45));
}
.client-subscription-delete:disabled {
opacity: 0.4;
cursor: wait;
}
.client-subscription-delete:focus-visible {
outline: 2px solid oklch(0.68 0.15 28);
outline-offset: 2px;
}
.client-subscription.is-editing .client-subscription-refresh,
.client-subscription.is-editing .client-subscription-delete {
opacity: 0;
pointer-events: none;
}
.client-subscription.is-editing .client-icon-tooltip {
opacity: 0;
pointer-events: none;
}
.client-subscription-edit,
.client-subscription-summary {
grid-area: 1 / 1;
width: 100%;
border: 0;
background: transparent;
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1), transform 650ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-edit {
width: min(100%, 380px);
min-height: 44px;
display: block;
position: relative;
opacity: 0;
filter: blur(16px);
transform: translateY(6px) scale(0.96);
pointer-events: none;
}
.client-subscription-edit::before,
.client-subscription-edit::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
pointer-events: none;
}
.client-subscription-edit::before {
background: var(--client-border);
}
.client-subscription-edit::after {
background: var(--client-accent);
opacity: 0.72;
filter: blur(0.5px);
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
transition: background 500ms ease, box-shadow 500ms ease;
}
.client-subscription-edit.is-invalid::after {
background: oklch(0.68 0.15 28);
box-shadow: 0 0 8px oklch(0.68 0.15 28 / 0.72), 0 0 18px oklch(0.68 0.15 28 / 0.24);
}
.client-subscription.is-timing-out .client-subscription-edit::after {
animation: client-subscription-timeout 5s linear forwards;
}
@keyframes client-subscription-timeout {
0% {
opacity: 0.9;
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
}
100% {
opacity: 0.08;
box-shadow: 0 0 0 transparent;
}
}
.client-subscription.is-editing .client-subscription-edit {
opacity: 1;
filter: blur(0);
transform: translateY(0);
pointer-events: auto;
}
.client-subscription-edit input {
width: 100%;
min-width: 0;
height: 44px;
padding: 0 42px;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
caret-color: transparent;
font-size: 12px;
text-align: center;
}
.client-subscription-edit input::placeholder {
color: var(--client-muted);
}
.client-subscription-edit input.has-value {
color: transparent;
caret-color: transparent;
}
.client-subscription-edit input:-webkit-autofill,
.client-subscription-edit input:-webkit-autofill:hover,
.client-subscription-edit input:-webkit-autofill:focus {
box-shadow: 0 0 0 1000px var(--client-bg) inset;
-webkit-text-fill-color: transparent;
}
.client-subscription-domain {
position: absolute;
top: 50%;
left: 42px;
right: 42px;
overflow: hidden;
color: var(--client-text);
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
transform: translateY(-50%);
white-space: nowrap;
pointer-events: none;
}
.client-subscription-submit {
position: absolute;
top: 4px;
right: 0;
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font-size: 19px;
font-weight: 700;
cursor: pointer;
transition: color 250ms ease, filter 500ms ease, opacity 250ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-submit:hover:not(:disabled),
.client-subscription-submit:focus-visible {
filter: drop-shadow(0 0 5px var(--client-accent)) drop-shadow(0 0 14px var(--client-accent));
transform: scale(1.14);
}
.client-subscription-submit:disabled {
cursor: default;
}
.client-subscription-edit.is-invalid .client-subscription-submit {
color: oklch(0.68 0.15 28);
opacity: 0.9;
}
.client-subscription-heading {
width: 100%;
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 64px;
align-items: center;
}
.client-subscription-status {
grid-column: 2;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--client-accent);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-subscription-status i {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.client-subscription-actions {
grid-column: 3;
display: flex;
justify-content: flex-end;
}
.client-subscription-domain-button {
width: 100%;
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.client-subscription-domain-button .client-subscription-label {
display: block;
margin-bottom: 3px;
}
.client-subscription-summary {
min-width: 0;
max-width: 100%;
min-height: 88px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 2px;
color: var(--client-muted);
text-align: center;
cursor: default;
overflow: visible;
}
.client-subscription.is-editing .client-subscription-summary {
opacity: 0;
filter: blur(18px);
transform: translateY(-4px) scale(1.06);
pointer-events: none;
}
.client-subscription-label {
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.client-subscription-domain-button strong {
display: block;
overflow: hidden;
color: var(--client-text);
font-size: 30px;
font-weight: 650;
letter-spacing: -0.05em;
opacity: 0.96;
text-shadow: 0 0 20px color-mix(in oklch, var(--client-accent) 24%, transparent);
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
text-overflow: ellipsis;
white-space: nowrap;
}
.client-subscription-domain-button:hover strong {
opacity: 1;
transform: translateY(-2px);
text-shadow: 0 0 28px color-mix(in oklch, var(--client-accent) 42%, transparent);
}
.client-usage {
width: min(100%, 280px);
min-height: 76px;
display: grid;
align-content: start;
gap: 9px;
margin: -6px auto 0;
color: var(--client-muted);
text-align: center;
}
.client-usage-summary {
display: grid;
gap: 3px;
}
.client-usage-summary > span,
.client-usage-details > span {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-usage-summary > strong {
color: var(--client-text);
font-size: 16px;
font-weight: 600;
transition: color 700ms ease, filter 700ms ease, text-shadow 700ms ease;
}
.client-usage.is-updated {
animation: client-usage-glow 1100ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-usage.is-updated .client-usage-summary > strong {
color: var(--client-accent);
filter: brightness(1.25);
text-shadow: 0 0 8px var(--client-accent), 0 0 22px color-mix(in oklch, var(--client-accent) 70%, transparent);
}
.client-usage.is-updated .client-usage-bar i {
animation: client-bar-flare 1100ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes client-usage-glow {
30% { filter: drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 78%, transparent)); }
}
@keyframes client-bar-flare {
30% { box-shadow: 0 0 6px var(--client-accent), 0 0 20px var(--client-accent); filter: brightness(1.45); }
}
.client-usage-summary > strong small {
color: var(--client-muted);
font-size: 10px;
font-weight: 500;
}
.client-usage-bar {
height: 2px;
overflow: hidden;
background: var(--client-border);
}
.client-usage-bar i {
display: block;
height: 100%;
background: var(--client-accent);
box-shadow: 0 0 8px var(--client-accent);
transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-usage-details {
min-height: 24px;
display: grid;
grid-template-columns: auto auto;
justify-content: center;
align-items: baseline;
column-gap: 6px;
font-size: 9px;
white-space: nowrap;
}
.client-usage-details > span {
grid-column: 1 / -1;
font-size: 8px;
opacity: 0.72;
}
.client-usage-details > strong {
color: var(--client-text);
font-size: 10px;
font-weight: 600;
}
.client-usage-details > small {
color: var(--client-muted);
font-size: 9px;
}
.client-inline-error.is-subscription {
top: calc(100% + 2px);
}
@@ -483,8 +32,446 @@
}
}
.client-subscription-sheet {
width: 100%;
display: block;
padding: 32px 30px 76px;
}
.client-profiles-header {
min-height: 44px;
display: flex;
align-items: center;
gap: 8px;
padding-right: 76px;
}
.client-profiles-header h2 {
flex: 1;
margin: 0;
font-size: 16px;
letter-spacing: 0.02em;
}
.client-profiles-header > button:not(.client-drawer-close) {
width: 40px;
height: 40px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 300 24px/1 inherit;
cursor: pointer;
}
.client-profiles-header > button:hover,
.client-profiles-header > button:focus-visible {
color: var(--client-accent);
}
.client-profiles-operation,
.client-profiles-current {
min-height: 42px;
display: flex;
align-items: center;
margin-top: 14px;
padding: 8px 12px;
border: 1px solid var(--client-border);
color: var(--client-muted);
font-size: 9px;
}
.client-profiles-operation {
gap: 8px;
}
.client-profiles-operation.is-active::before {
content: '';
width: 7px;
height: 7px;
border: 1px solid currentColor;
border-radius: 50%;
}
.client-profiles-current {
gap: 8px;
margin-bottom: 18px;
}
.client-profiles-current strong {
min-width: 0;
overflow: hidden;
color: var(--client-text);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-profile-list {
border-top: 1px solid var(--client-border);
}
.client-profile-group {
position: relative;
border-bottom: 1px solid var(--client-border);
}
.client-profile-header {
min-height: 72px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto 36px 36px;
align-items: center;
gap: 4px;
}
.client-profile-disclosure {
min-width: 0;
min-height: 56px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
padding: 6px 0;
border: 0;
background: transparent;
color: var(--client-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.client-profile-chevron {
color: var(--client-muted);
font-size: 20px;
transform: rotate(0);
transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-profile-disclosure[aria-expanded='true'] .client-profile-chevron {
transform: rotate(90deg);
}
.client-profile-title {
min-width: 0;
display: grid;
gap: 4px;
}
.client-profile-title > span {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.client-profile-title strong,
.client-profile-title small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-profile-title strong {
font-size: 12px;
letter-spacing: -0.02em;
}
.client-profile-title small {
color: var(--client-muted);
font-size: 9px;
}
.client-profile-title em {
padding: 3px 6px;
border: 1px solid color-mix(in oklch, var(--client-accent) 30%, transparent);
border-radius: 3px;
background: color-mix(in oklch, var(--client-accent) 8%, transparent);
color: var(--client-accent);
font-size: 7px;
font-style: normal;
letter-spacing: 0.08em;
}
.client-profile-usage {
max-width: 126px;
overflow: hidden;
color: var(--client-muted);
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-profile-refresh,
.client-profile-menu-toggle {
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
font: 400 18px/1 inherit;
cursor: pointer;
}
.client-profile-refresh:hover:not(:disabled),
.client-profile-refresh:focus-visible,
.client-profile-menu-toggle:hover,
.client-profile-menu-toggle:focus-visible {
color: var(--client-accent);
}
.client-profile-refresh.is-refreshing {
color: var(--client-accent);
animation: client-spin 900ms linear infinite;
}
.client-profile-refresh:disabled,
.client-profile-menu-toggle:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-menu-wrap {
position: relative;
}
.client-profile-menu {
position: absolute;
top: 34px;
right: 0;
z-index: 4;
width: 150px;
display: grid;
padding: 6px;
border: 1px solid var(--client-border);
background: var(--client-bg);
box-shadow: 0 16px 38px color-mix(in oklch, var(--client-bg) 80%, transparent);
}
.client-profile-menu button {
min-height: 34px;
padding: 0 8px;
border: 0;
background: transparent;
color: var(--client-text);
font: 500 9px/1.2 inherit;
text-align: left;
cursor: pointer;
}
.client-profile-menu button:hover,
.client-profile-menu button:focus-visible {
background: color-mix(in oklch, var(--client-accent) 7%, transparent);
}
.client-profile-menu button.is-danger {
color: oklch(0.68 0.15 28);
}
.client-profile-body {
max-height: 3200px;
padding: 0 0 20px 18px;
overflow: hidden;
opacity: 1;
visibility: visible;
transition: max-height 560ms cubic-bezier(0.16, 1, 0.3, 1), opacity 260ms ease, padding 420ms ease, visibility 0s;
}
.client-profile-body[aria-hidden='true'] {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
visibility: hidden;
transition: max-height 420ms cubic-bezier(0.16, 1, 0.3, 1), opacity 180ms ease, padding 320ms ease, visibility 0s 420ms;
}
.client-profile-body .client-servers {
width: 100%;
margin: 0;
}
.client-profile-activate,
.client-profile-add-trigger {
min-height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font: 600 9px/1 inherit;
letter-spacing: 0.03em;
text-transform: uppercase;
cursor: pointer;
}
.client-profile-activate {
display: block;
margin-left: auto;
}
.client-profile-activate:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-add-trigger {
width: 100%;
margin-top: 24px;
text-align: left;
}
.client-profile-server-hint,
.client-profile-local-status,
.client-profile-form-status {
min-height: 18px;
margin: 0;
color: var(--client-muted);
font-size: 8px;
line-height: 1.45;
}
.client-profile-local-status {
padding-left: 18px;
color: oklch(0.68 0.14 72);
}
.client-profile-add,
.client-profile-rename {
display: grid;
gap: 10px;
padding: 16px 0 20px;
border-bottom: 1px solid var(--client-border);
}
.client-profile-add label {
display: grid;
gap: 6px;
color: var(--client-muted);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-profile-add input,
.client-profile-rename input {
min-width: 0;
height: 40px;
padding: 0 10px;
border: 1px solid var(--client-border);
border-radius: 0;
outline: 0;
background: transparent;
color: var(--client-text);
font: 500 10px/1 inherit;
}
.client-profile-add input:focus,
.client-profile-rename input:focus {
border: 1px solid var(--client-accent);
}
.client-profile-add input[aria-invalid='true'],
.client-profile-rename input[aria-invalid='true'] {
border: 1px solid oklch(0.68 0.15 28);
}
.client-profile-form-actions,
.client-profile-rename {
grid-template-columns: 1fr auto auto;
align-items: center;
}
.client-profile-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.client-profile-form-actions button,
.client-profile-rename button {
min-height: 34px;
padding: 0 10px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 600 9px/1 inherit;
cursor: pointer;
}
.client-profile-form-actions button.is-primary,
.client-profile-rename button[type='submit'] {
color: var(--client-accent);
}
.client-profile-form-actions button:disabled,
.client-profile-rename button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-rename {
grid-template-columns: minmax(0, 1fr) auto auto;
padding-left: 18px;
}
.client-profile-rename > span {
grid-column: 1 / -1;
min-height: 12px;
color: oklch(0.68 0.15 28);
font-size: 8px;
}
.client-subscription-first-run {
gap: 18px;
}
.client-first-run-copy {
display: grid;
gap: 6px;
text-align: center;
}
.client-first-run-copy span {
color: var(--client-muted);
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-first-run-copy strong {
font-size: 16px;
}
@media (max-width: 520px) {
.client-subscription-sheet {
padding: 24px 18px 64px;
}
.client-profile-header {
grid-template-columns: minmax(0, 1fr) 36px 36px;
}
.client-profile-usage {
display: none;
}
.client-profile-body {
padding-left: 8px;
}
.client-profile-disclosure,
.client-profile-refresh,
.client-profile-menu-toggle,
.client-profiles-header > button:not(.client-drawer-close) {
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.client-usage-summary > strong {
.client-profile-chevron,
.client-profile-body,
.client-profile-refresh {
transition: none;
animation: none;
}
}
-42
View File
@@ -558,40 +558,6 @@
row-gap: 36px;
}
.client-gateway-route-summary {
min-height: 54px;
display: grid;
justify-items: center;
gap: 2px;
margin-top: 6px;
}
.client-gateway-route-summary strong {
max-width: min(300px, 100%);
min-height: 20px;
overflow: hidden;
color: var(--client-text);
font-size: 15px;
letter-spacing: -0.035em;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-gateway-route-slot {
width: 100%;
min-height: 16px;
overflow: hidden;
color: var(--client-accent);
font-size: 9px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-panel.has-subscription .client-form {
height: var(--client-work-height);
}
.client-panel.is-gateway-home .client-state-copy {
min-height: 84px;
}
@@ -602,10 +568,6 @@
--client-power-top: calc((var(--client-work-height) - 96px) / 2);
transform: translateY(-9vh);
}
.client-panel.has-subscription .client-form-content {
padding-top: var(--client-power-top);
}
}
@media (max-width: 920px) {
@@ -715,10 +677,6 @@
bottom: 0;
}
.client-form-content {
gap: 24px;
}
.harbor-brand {
transform: translateX(-50%) scale(1.35);
}
+1 -18
View File
@@ -490,15 +490,6 @@
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-form-content {
display: grid;
align-content: start;
gap: 24px;
opacity: 1;
filter: blur(0);
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-drawer {
width: min(480px, 100vw);
}
@@ -645,13 +636,7 @@
cursor: wait;
}
.client-form.is-waiting {
opacity: 0;
filter: blur(10px);
pointer-events: none;
}
.client-shell:has(.harbor-brand.is-gateway-active) .client-form:not(.is-waiting) {
.client-shell:has(.harbor-brand.is-gateway-active) .client-form {
opacity: 0.34;
filter: grayscale(1) saturate(0);
}
@@ -660,8 +645,6 @@
to { transform: rotate(360deg); }
}
.client-subscription-edit button:focus-visible,
.client-subscription-domain-button:focus-visible,
.client-server:focus-visible,
.client-power:focus-visible {
outline: 2px solid var(--client-accent);
+1 -13
View File
@@ -21,16 +21,11 @@
.client-power::before,
.client-power::after,
.client-power svg,
.client-usage-bar i,
.client-subscription-refresh,
.client-subscription-delete,
.client-power-section,
.client-gateway-traffic-chart,
.client-gateway-traffic-freshness,
.client-subscription-drawer,
.client-form,
.client-usage,
.client-usage > strong,
.client-server,
.client-server-auto,
.client-server-health > span,
@@ -93,11 +88,6 @@
.client-devices-refresh-ring circle,
.client-devices-refresh-icon,
.client-text-morph-value,
.client-subscription-edit,
.client-subscription-edit::after,
.client-subscription-submit,
.client-subscription-summary,
.client-subscription-summary strong,
.client-proxy-label > span {
transition: none;
animation: none;
@@ -180,12 +170,10 @@
animation: none;
}
.client-form-content,
.client-confirmation-popup,
.client-confirmation-dialog,
.client-confirmation-dialog > *,
.client-confirmation-actions button,
.client-subscription-delete .client-trash-lid {
.client-confirmation-actions button {
transition: none;
animation: none;
}
+1 -4
View File
@@ -1,17 +1,14 @@
interface ConnectionActionInput {
connected: boolean;
selectedServerId: string;
configExists: boolean;
}
export function connectionAction({ connected, selectedServerId, configExists }: ConnectionActionInput):
export function connectionAction({ connected, selectedServerId }: ConnectionActionInput):
| { type: 'stop' }
| { type: 'apply'; serverId: string }
| { type: 'restart' }
| null {
if (connected) return { type: 'stop' };
if (selectedServerId) return { type: 'apply', serverId: selectedServerId };
if (configExists) return { type: 'restart' };
return null;
}
+5 -1
View File
@@ -48,7 +48,11 @@ test('data invariant: one canonical snapshot owns server selection and never exp
});
assert.equal(snapshot.revision, 9);
assert.deepEqual(snapshot.selection, { desiredServerId: selectedServerId, appliedServerId: selectedServerId });
assert.equal(snapshot.selection.desiredProfileId, 'profile_primary');
assert.equal(snapshot.selection.desiredServerId, selectedServerId);
assert.equal(snapshot.selection.appliedProfileId, 'profile_primary');
assert.equal(snapshot.selection.appliedServerId, selectedServerId);
assert.equal(snapshot.selection.appliedServerSnapshot?.id, selectedServerId);
assert.equal(snapshot.servers.find((server) => server.id === selectedServerId)?.host, 'vpn-17.example.test');
assert.equal(JSON.stringify(snapshot).includes('private-token'), false);
});
+220 -386
View File
@@ -12,130 +12,113 @@ import { HarborError } from '../../dist/shared/errors.js';
const serverA = { id: 'a', label: 'Alpha', host: 'a.example', port: 1, protocol: 'vless' };
const serverB = { id: 'b', label: 'Beta', host: 'b.example', port: 2, protocol: 'vless' };
const duplicateA = { id: 'a2', label: 'Alpha', host: 'a2.example', port: 3, protocol: 'vless' };
const rules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? {
const profile = (id, label, desiredServerId = 'a', servers = [serverA, serverB]) => ({
id,
label,
subscriptionUrl: `https://${id}.example/sub`,
subscriptionConfig: { outbounds: [] },
servers,
userInfo: {},
fetchedAt: '2026-08-08T10:00:00.000Z',
desiredServerId,
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
});
function initialState() {
return {
revision: 5,
servers: [serverA, serverB],
selectedServerId: serverA.id,
appliedServerId: serverA.id,
profiles: [profile('personal', 'Личный'), profile('work', 'Работа', 'b')],
desiredProfileId: 'personal',
appliedProfileId: 'personal',
appliedServerId: 'a',
appliedServerSnapshot: serverA,
routeRules: rules,
appliedRouteRules: rules,
routeRulesRevision: 1,
connectionDesired: 'running',
});
};
}
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? initialState());
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
let running = overrides.running ?? true;
let tail = Promise.resolve();
let active = 0;
let peak = 0;
let stateUpdateIndex = 0;
let stateUpdates = 0;
let startFailure = overrides.failStart || null;
const events = [];
const failures = {
...overrides.failures,
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
runtimeStarts: [...(overrides.failures?.runtimeStarts || [])],
runtimeStops: [...(overrides.failures?.runtimeStops || [])],
runtimeRestarts: [...(overrides.failures?.runtimeRestarts || [])],
};
function applyState(mutator) {
const failure = failures.stateUpdates[stateUpdateIndex++];
if (failure instanceof Error) throw failure;
const revision = state.revision + 1;
state = { ...structuredClone(mutator(structuredClone(state))), revision };
events.push(stateUpdateIndex === 1 ? 'state.desired' : stateUpdateIndex === 2 ? 'state.applied' : 'state.restore');
if (failure?.after) throw failure.after;
return structuredClone(state);
}
const serialize = (operation) => {
const run = async () => {
active += 1;
peak = Math.max(peak, active);
try {
return await operation();
} finally {
active -= 1;
}
try { return await operation(); } finally { active -= 1; }
};
const result = tail.then(run, run);
tail = result.then(() => undefined, () => undefined);
return result;
};
const stopRuntime = async () => {
events.push('runtime.stop');
const failure = failures.runtimeStops.shift();
if (failure) {
running = false;
throw failure;
}
running = false;
const updateState = (mutator) => {
stateUpdates += 1;
const next = structuredClone(mutator(structuredClone(state)));
state = { ...next, revision: state.revision + 1 };
events.push('state.update');
if (overrides.failStateAt === stateUpdates) throw new Error('state failed');
return structuredClone(state);
};
const restartRuntime = async () => {
events.push('runtime.restart');
const failure = failures.runtimeRestarts.shift();
if (failure) {
if (failure.code !== 'CONFIG_INVALID') running = false;
throw failure;
}
running = true;
};
const service = createConnectionService({
state: {
read: () => structuredClone(state),
update: applyState,
},
subscription: {
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
},
config: {
exists: () => config !== null,
build: (_subscription, selectedServerId, routeRules) => ({ selectedServerId, routeRules }),
read: () => config,
write: (value) => {
events.push('config.write');
if (failures.configWrite) throw failures.configWrite;
config = JSON.stringify(value);
if (failures.configWriteAfter) throw failures.configWriteAfter;
},
restore: (value) => {
events.push('config.restore');
if (failures.configRestore) throw failures.configRestore;
config = value;
},
remove: () => {
events.push('config.remove');
if (failures.configRemove) throw failures.configRemove;
config = null;
},
},
runtime: {
isRunning: async () => {
if (failures.runtimeStatus) throw failures.runtimeStatus;
return running;
},
start: async () => {
const start = async () => {
events.push('runtime.start');
const failure = failures.runtimeStarts.shift();
if (failure) {
if (startFailure) {
const failure = startFailure;
startFailure = null;
running = false;
throw failure;
}
if (overrides.startGate) await overrides.startGate();
running = true;
};
const stop = async () => {
events.push('runtime.stop');
if (overrides.failStop) throw overrides.failStop;
running = false;
};
const restart = async () => {
events.push('runtime.restart');
if (overrides.failRestart) throw overrides.failRestart;
running = true;
};
const service = createConnectionService({
state: { read: () => structuredClone(state), update: updateState },
config: {
build: (_subscription, selectedServerId, routeRules) => ({ selectedServerId, routeRules }),
read: () => config,
write: (value) => {
events.push('config.write');
config = JSON.stringify(value);
if (overrides.failConfigWrite) throw overrides.failConfigWrite;
},
stop: stopRuntime,
stopCommand: () => captureRuntimeCommand(stopRuntime),
restartCommand: () => captureRuntimeCommand(
restartRuntime,
{ preMutationErrorCodes: ['CONFIG_INVALID'] },
),
restore: (value) => { events.push('config.restore'); config = value; },
remove: () => { events.push('config.remove'); config = null; },
},
runtime: {
isRunning: async () => {
if (overrides.failStatus) throw overrides.failStatus;
return running;
},
start,
stop,
stopCommand: () => captureRuntimeCommand(stop),
restartCommand: () => captureRuntimeCommand(restart, { preMutationErrorCodes: ['CONFIG_INVALID'] }),
},
route: { isGatewayDirect: () => overrides.gatewayDirect === true },
serialize,
now: () => new Date('2026-08-08T12:00:00.000Z'),
});
@@ -143,344 +126,195 @@ function createHarness(overrides = {}) {
return {
service,
events,
enqueue: serialize,
setState: (value) => { state = structuredClone(value); },
serialize,
snapshot: () => structuredClone({ state, config, running, peak }),
};
}
function assertDomainRestored(actual, expected) {
const actualDomain = structuredClone(actual);
const expectedDomain = structuredClone(expected);
delete actualDomain.state.revision;
delete expectedDomain.state.revision;
delete actualDomain.peak;
delete expectedDomain.peak;
assert.deepEqual(actualDomain, expectedDomain);
assert.ok(actual.state.revision >= expected.state.revision);
function domain(value) {
const copy = structuredClone(value);
delete copy.state.revision;
delete copy.peak;
return copy;
}
test('apply resolves ID inside the queue and commits desired, config, runtime, then applied state', async () => {
const harness = createHarness({ state: {
revision: 5,
servers: [serverA, serverB, duplicateA],
selectedServerId: serverA.id,
appliedServerId: serverA.id,
routeRules: rules,
appliedRouteRules: rules,
} });
assert.deepEqual(await harness.service.apply(' b ', 'Alpha'), { serverId: 'b', selectedTag: 'Beta' });
const snapshot = harness.snapshot();
assert.equal(snapshot.state.selectedServerId, 'b');
assert.equal(snapshot.state.appliedServerId, 'b');
assert.equal(snapshot.state.connectionDesired, 'running');
assert.equal(snapshot.state.appliedAt, '2026-08-08T12:00:00.000Z');
assert.deepEqual(snapshot.state.appliedRouteRules, rules);
assert.deepEqual(harness.events, ['state.desired', 'config.write', 'runtime.start', 'state.applied']);
await assert.rejects(harness.service.apply('', 'Alpha'), (error) => error.code === 'SERVER_NOT_FOUND');
await assert.rejects(harness.service.apply('missing', 'Beta'), (error) => error.code === 'SERVER_NOT_FOUND');
test('apply validates the profile pair and publishes applied identity only after runtime succeeds', async () => {
const harness = createHarness();
assert.deepEqual(await harness.service.apply('work', 'a', '', 5), {
profileId: 'work', serverId: 'a', selectedTag: 'Alpha',
});
test('apply accepts one legacy label and rejects missing config before mutation', async () => {
const unique = createHarness();
assert.deepEqual(await unique.service.apply('', ' Beta '), { serverId: 'b', selectedTag: 'Beta' });
const missing = createHarness({ missingSubscription: true });
const before = missing.snapshot();
await assert.rejects(missing.service.apply('b', ''), (error) => error.code === 'CONFIG_INVALID');
assertDomainRestored(missing.snapshot(), before);
assert.deepEqual(missing.events, []);
});
test('apply restores previous running domain/config/runtime after mutation failures', async () => {
const failureCases = [
{ failures: { stateUpdates: [new Error('desired')] }, expected: 'desired' },
{ failures: { configWriteAfter: new Error('config') }, expected: 'config' },
{ failures: { runtimeStarts: [new Error('start')] }, expected: 'start' },
{ failures: { stateUpdates: [null, { after: new Error('applied') }] }, expected: 'applied' },
];
for (const { failures, expected } of failureCases) {
const harness = createHarness({ failures });
const before = harness.snapshot();
await assert.rejects(harness.service.apply('b', ''), new RegExp(expected));
assertDomainRestored(harness.snapshot(), before);
}
});
test('apply compensates a desired state update that persists and then throws', async () => {
const failure = new Error('desired persisted then failed');
const harness = createHarness({ failures: { stateUpdates: [{ after: failure }] } });
const before = harness.snapshot();
await assert.rejects(harness.service.apply('b', ''), (error) => error === failure);
const after = harness.snapshot();
assertDomainRestored(after, before);
assert.ok(after.state.revision > before.state.revision);
assert.equal(after.state.desiredProfileId, 'work');
assert.equal(after.state.profiles.find(({ id }) => id === 'work').desiredServerId, 'a');
assert.equal(after.state.appliedProfileId, 'work');
assert.equal(after.state.appliedServerId, 'a');
assert.deepEqual(after.state.appliedServerSnapshot, serverA);
assert.deepEqual(harness.events, ['config.write', 'runtime.start', 'state.update']);
await assert.rejects(harness.service.apply('personal', 'missing'), (error) => error.code === 'SERVER_NOT_FOUND');
await assert.rejects(harness.service.apply('missing', 'a'), (error) => error.code === 'PROFILE_NOT_FOUND');
await assert.rejects(harness.service.apply('personal', 'a', '', 4), (error) => error.code === 'STATE_CONFLICT');
});
test('apply restores previous stopped state and prior config absence', async () => {
const failure = new Error('start');
const harness = createHarness({ config: null, running: false, failures: { runtimeStarts: [failure] } });
test('failed candidate config or runtime restores the old profile, config and running state', async () => {
for (const failure of [
{ failConfigWrite: new Error('write failed') },
{ failStart: new Error('start failed') },
{ failStateAt: 1 },
]) {
const harness = createHarness(failure);
const before = harness.snapshot();
await assert.rejects(harness.service.apply('b', ''), (error) => error === failure);
assertDomainRestored(harness.snapshot(), before);
assert.ok(harness.events.includes('config.remove'));
assert.ok(harness.events.includes('runtime.stop'));
await assert.rejects(harness.service.apply('work', 'b'));
assert.deepEqual(domain(harness.snapshot()), domain(before));
}
});
test('apply rollback continues after restore failures and marks runtime rollback failure', async () => {
const original = new Error('apply failed');
const configFailure = new Error('config restore failed');
const nonRuntime = createHarness({
failures: { runtimeStarts: [original], configRestore: configFailure },
});
await assert.rejects(nonRuntime.service.apply('b', ''), (error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [original, configFailure]);
return true;
});
assert.ok(nonRuntime.events.filter((event) => event === 'runtime.start').length >= 2);
assert.ok(nonRuntime.events.filter((event) => event.startsWith('state.')).length >= 2);
const rollbackFailure = new Error('runtime rollback failed');
const runtimeBroken = createHarness({ failures: { runtimeStarts: [original, rollbackFailure] } });
await assert.rejects(runtimeBroken.service.apply('b', ''), (error) => {
assert.equal(error.code, 'PROCESS_START_FAILED');
assert.deepEqual(error.cause.errors, [original, rollbackFailure]);
return true;
});
assert.ok(runtimeBroken.events.filter((event) => event.startsWith('state.')).length >= 2);
});
test('queued apply resolves latest state and concurrent valid applies remain serial', async () => {
test('desired and applied targets stay old while candidate runtime is still starting', async () => {
let entered;
let release;
const blocker = new Promise((resolve) => { release = resolve; });
const stale = createHarness();
const blocked = stale.enqueue(() => blocker);
const apply = stale.service.apply('b', '');
stale.setState({ ...stale.snapshot().state, servers: [serverA] });
const started = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
const harness = createHarness({ startGate: async () => { entered(); await gate; } });
const switching = harness.service.apply('work', 'b');
await started;
assert.equal(harness.snapshot().state.desiredProfileId, 'personal');
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
release();
await blocked;
await assert.rejects(apply, (error) => error.code === 'SERVER_NOT_FOUND');
const serial = createHarness();
const [first, second] = await Promise.all([
serial.service.apply('a', ''),
serial.service.apply('b', ''),
]);
assert.equal(first.serverId, 'a');
assert.equal(second.serverId, 'b');
assert.equal(serial.snapshot().state.appliedServerId, 'b');
assert.equal(serial.snapshot().peak, 1);
await switching;
});
test('server apply route preserves defaults, operation kind, response, and single ownership', async () => {
const calls = [];
const sent = [];
const route = createServerApplyRoute({
connection: {
apply: async (serverId, selectedTag) => {
calls.push([serverId, selectedTag]);
return { serverId: 'resolved', selectedTag: 'Resolved' };
},
},
readBody: async () => ({}),
withOperation: async (kind, operation) => {
calls.push(kind);
return operation();
},
sendState: async (_res, extra) => { sent.push(extra); },
});
const response = {};
test('activate only changes desired while stopped and transactionally switches while running', async () => {
const stopped = createHarness({ running: false });
await stopped.service.activate('work');
assert.equal(stopped.snapshot().state.desiredProfileId, 'work');
assert.equal(stopped.snapshot().state.appliedProfileId, 'personal');
assert.equal(stopped.events.includes('config.write'), false);
assert.equal(await route.handle({ method: 'GET', url: '/api/apply' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/apply' }, response), true);
assert.deepEqual(calls, ['apply-server', ['', '']]);
assert.deepEqual(sent, [{ serverId: 'resolved', selectedTag: 'Resolved' }]);
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(source, /createServerApplyRoute\(\{/);
assert.doesNotMatch(source, /function applySelectedServer|req\.url === ['"]\/api\/apply['"]/);
const running = createHarness();
await running.service.activate('work');
assert.equal(running.snapshot().state.appliedProfileId, 'work');
assert.equal(running.snapshot().state.appliedServerId, 'b');
});
test('stop always cleans runtime and commits only desired stopped state', async () => {
for (const running of [true, false]) {
const harness = createHarness({ running });
const before = harness.snapshot();
test('gateway-direct changes only local desired state and never claims a remote applied target', async () => {
const harness = createHarness({ gatewayDirect: true });
await harness.service.apply('work', 'b');
const after = harness.snapshot();
assert.equal(after.state.desiredProfileId, 'work');
assert.equal(after.state.appliedProfileId, 'personal');
assert.equal(harness.events.includes('config.write'), false);
assert.equal(harness.events.includes('runtime.start'), false);
});
test('stop clears applied identity but preserves the desired pair', async () => {
const harness = createHarness();
await harness.service.stop();
const after = harness.snapshot();
assert.equal(after.running, false);
assert.equal(after.state.connectionDesired, 'stopped');
assert.equal(after.state.selectedServerId, before.state.selectedServerId);
assert.equal(after.state.appliedServerId, before.state.appliedServerId);
assert.deepEqual(after.state.appliedRouteRules, before.state.appliedRouteRules);
assert.equal(harness.events.filter((event) => event === 'runtime.stop').length, 1);
}
assert.equal(after.state.desiredProfileId, 'personal');
assert.equal(after.state.profiles[0].desiredServerId, 'a');
assert.equal(after.state.appliedProfileId, '');
assert.equal(after.state.appliedServerId, '');
assert.equal(after.state.appliedServerSnapshot, null);
});
test('restart validates config inside the queue and commits applied runtime fields', async () => {
const missing = createHarness({ config: null });
const beforeMissing = missing.snapshot();
await assert.rejects(missing.service.restart(), (error) => error.code === 'CONFIG_INVALID');
assertDomainRestored(missing.snapshot(), beforeMissing);
assert.deepEqual(missing.events, []);
test('restart uses applied pair while running and desired pair while stopped', async () => {
const running = createHarness();
await running.service.restart();
assert.match(running.snapshot().config, /"selectedServerId":"a"/);
for (const running of [true, false]) {
const harness = createHarness({ running, state: {
revision: 1,
servers: [serverA, serverB],
selectedServerId: serverB.id,
appliedServerId: serverA.id,
routeRules: rules,
appliedRouteRules: [],
appliedAt: 'preserved',
connectionDesired: 'stopped',
} });
const stoppedState = initialState();
stoppedState.desiredProfileId = 'work';
stoppedState.connectionDesired = 'stopped';
const stopped = createHarness({ running: false, state: stoppedState });
await stopped.service.restart();
assert.equal(stopped.snapshot().state.appliedProfileId, 'work');
assert.equal(stopped.snapshot().state.appliedServerId, 'b');
assert.match(stopped.snapshot().config, /"selectedServerId":"b"/);
});
test('running restart preserves a pending desired profile while restoring the applied pair', async () => {
const state = initialState();
state.desiredProfileId = 'work';
const harness = createHarness({ state });
await harness.service.restart();
const after = harness.snapshot();
assert.equal(after.running, true);
assert.equal(after.state.appliedServerId, serverB.id);
assert.equal(after.state.connectionDesired, 'running');
assert.deepEqual(after.state.appliedRouteRules, rules);
assert.equal(after.state.appliedAt, 'preserved');
}
assert.equal(harness.snapshot().state.desiredProfileId, 'work');
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
});
test('stop and restart compensate runtime and pre/post-write state failures', async () => {
const statusFailure = new Error('status failed');
for (const command of ['stop', 'restart']) {
const status = createHarness({ failures: { runtimeStatus: statusFailure } });
await status.service[command]();
assert.ok(status.events.includes(`runtime.${command}`));
}
for (const stateFailure of [new Error('state before'), { after: new Error('state after') }]) {
const stopHarness = createHarness({ failures: { stateUpdates: [stateFailure] } });
const stopBefore = stopHarness.snapshot();
await assert.rejects(stopHarness.service.stop(), /state/);
assertDomainRestored(stopHarness.snapshot(), stopBefore);
const restartHarness = createHarness({
running: false,
failures: { stateUpdates: [stateFailure] },
});
const restartBefore = restartHarness.snapshot();
await assert.rejects(restartHarness.service.restart(), /state/);
assertDomainRestored(restartHarness.snapshot(), restartBefore);
}
const stopFailure = new Error('stop failed');
const stopped = createHarness({ failures: { runtimeStops: [stopFailure] } });
const stoppedBefore = stopped.snapshot();
await assert.rejects(stopped.service.stop(), (error) => error === stopFailure);
assertDomainRestored(stopped.snapshot(), stoppedBefore);
const restartFailure = new Error('restart failed');
const restarted = createHarness({ running: false, failures: { runtimeRestarts: [restartFailure] } });
const restartedBefore = restarted.snapshot();
await assert.rejects(restarted.service.restart(), (error) => error === restartFailure);
assertDomainRestored(restarted.snapshot(), restartedBefore);
});
test('restart preserves CONFIG_INVALID when validation fails before runtime mutation', async () => {
const invalid = new HarborError('CONFIG_INVALID');
const harness = createHarness({ failures: { runtimeRestarts: [invalid] } });
test('restart fails closed when runtime status is unknown', async () => {
const state = initialState();
state.desiredProfileId = 'work';
const failure = new Error('status unavailable');
const harness = createHarness({ state, failStatus: failure });
const before = harness.snapshot();
await assert.rejects(harness.service.restart(), (error) => error === invalid);
assertDomainRestored(harness.snapshot(), before);
assert.equal(harness.events.filter((event) => event === 'runtime.restart').length, 1);
assert.equal(harness.events.includes('runtime.start'), false);
await assert.rejects(harness.service.restart(), (error) => error === failure);
assert.deepEqual(domain(harness.snapshot()), domain(before));
});
test('runtime command adapter makes local and remote mutation phase explicit', async () => {
const localFailure = new HarborError('CONFIG_INVALID');
assert.deepEqual(
await captureRuntimeCommand(
async () => { throw localFailure; },
{ preMutationErrorCodes: ['CONFIG_INVALID'] },
),
{ ok: false, mutationStarted: false, error: localFailure },
);
const remoteFailure = new HarborError('PROCESS_START_FAILED');
assert.deepEqual(
await captureRuntimeCommand(async () => { throw remoteFailure; }),
{ ok: false, mutationStarted: true, error: remoteFailure },
);
test('all connection mutations share one deterministic queue', async () => {
let release;
const gate = new Promise((resolve) => { release = resolve; });
const harness = createHarness({ startGate: () => gate });
const apply = harness.service.apply('work', 'b');
const stop = harness.service.stop();
release();
await Promise.all([apply, stop]);
assert.equal(harness.snapshot().peak, 1);
assert.equal(harness.snapshot().state.connectionDesired, 'stopped');
});
test('runtime command rollback continues through state errors and maps runtime restore failure', async () => {
const original = new Error('stop state failed');
const stateRestore = new Error('state restore failed');
const aggregate = createHarness({
failures: { stateUpdates: [{ after: original }, stateRestore] },
});
await assert.rejects(aggregate.service.stop(), (error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [original, stateRestore]);
return true;
});
assert.equal(aggregate.snapshot().running, true);
const stopFailure = new Error('stop failed');
const runtimeRestore = new Error('start rollback failed');
const broken = createHarness({
failures: { runtimeStops: [stopFailure], runtimeStarts: [runtimeRestore] },
});
await assert.rejects(broken.service.stop(), (error) => {
assert.equal(error.code, 'PROCESS_START_FAILED');
assert.deepEqual(error.cause.errors, [stopFailure, runtimeRestore]);
return true;
});
});
test('apply, stop, and restart share one deterministic connection queue', async () => {
const applyThenStop = createHarness();
await Promise.all([
applyThenStop.service.apply('b', ''),
applyThenStop.service.stop(),
]);
assert.equal(applyThenStop.snapshot().state.connectionDesired, 'stopped');
assert.equal(applyThenStop.snapshot().peak, 1);
const stopThenRestart = createHarness();
await Promise.all([
stopThenRestart.service.stop(),
stopThenRestart.service.restart(),
]);
assert.equal(stopThenRestart.snapshot().state.connectionDesired, 'running');
assert.equal(stopThenRestart.snapshot().running, true);
assert.equal(stopThenRestart.snapshot().peak, 1);
});
test('connection runtime route preserves operation kinds, response extras, and single ownership', async () => {
test('pair-aware apply and runtime routes preserve one HTTP owner', async () => {
const calls = [];
const sent = [];
const route = createConnectionRuntimeRoute({
const applyRoute = createServerApplyRoute({
connection: {
stop: async () => { calls.push('service.stop'); },
restart: async () => { calls.push('service.restart'); },
apply: async (...args) => {
calls.push(args);
return { profileId: 'personal', serverId: 'a', selectedTag: 'Alpha' };
},
withOperation: async (kind, operation) => {
calls.push(kind);
return operation();
},
readBody: async () => ({ profileId: 'personal', serverId: 'a', expectedRevision: 7 }),
withOperation: async (kind, operation, options) => {
calls.push([kind, options]);
return operation(8);
},
sendState: async (_res, extra) => { sent.push(extra); },
});
const response = {};
assert.equal(await applyRoute.handle({ method: 'POST', url: '/api/apply' }, {}), true);
assert.deepEqual(calls, [
['apply-server', { expectedRevision: 7, profileId: 'personal', serverId: 'a' }],
['personal', 'a', '', 8],
]);
assert.deepEqual(sent, [{ profileId: 'personal', serverId: 'a', selectedTag: 'Alpha' }]);
assert.equal(await route.handle({ method: 'GET', url: '/api/singbox/stop' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/singbox/stop' }, response), true);
assert.equal(await route.handle({ method: 'POST', url: '/api/singbox/restart' }, response), true);
assert.deepEqual(calls, ['stop', 'service.stop', 'start', 'service.restart']);
assert.deepEqual(sent, [{ singboxRunning: false }, { singboxRunning: true }]);
const runtimeCalls = [];
const runtimeRoute = createConnectionRuntimeRoute({
connection: {
stop: async () => { runtimeCalls.push('service.stop'); },
restart: async () => { runtimeCalls.push('service.restart'); },
},
withOperation: async (kind, operation) => { runtimeCalls.push(kind); return operation(); },
sendState: async () => {},
});
await runtimeRoute.handle({ method: 'POST', url: '/api/singbox/stop' }, {});
await runtimeRoute.handle({ method: 'POST', url: '/api/singbox/restart' }, {});
assert.deepEqual(runtimeCalls, ['stop', 'service.stop', 'start', 'service.restart']);
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(source, /createConnectionRuntimeRoute\(\{/);
assert.doesNotMatch(source, /req\.url === ['"]\/api\/singbox\/(?:stop|restart)['"]/);
assert.doesNotMatch(source, /function applySelectedServer|req\.url === ['"]\/api\/apply['"]/);
});
test('runtime command adapter distinguishes failures before and after mutation', async () => {
const invalid = new HarborError('CONFIG_INVALID');
assert.deepEqual(
await captureRuntimeCommand(async () => { throw invalid; }, { preMutationErrorCodes: ['CONFIG_INVALID'] }),
{ ok: false, mutationStarted: false, error: invalid },
);
const failed = new Error('remote failed');
assert.deepEqual(
await captureRuntimeCommand(async () => { throw failed; }),
{ ok: false, mutationStarted: true, error: failed },
);
});
+18 -4
View File
@@ -68,12 +68,17 @@ test('connectivity diagnostics endpoint is available in Connect and Gateway thro
test('connectivity use case captures applied server before probes and preserves result fields', async () => {
const events = [];
let state = {
desiredProfileId: 'primary',
appliedProfileId: 'primary',
appliedServerId: 'applied',
selectedServerId: 'selected',
profiles: [{
id: 'primary',
desiredServerId: 'selected',
servers: [
{ id: 'applied', label: 'Applied before probe' },
{ id: 'selected', label: 'Selected' },
],
}],
};
let releaseProbe;
const probe = new Promise((resolve) => { releaseProbe = resolve; });
@@ -95,7 +100,7 @@ test('connectivity use case captures applied server before probes and preserves
},
});
const resultPromise = useCase.run({ raw: true }, 42);
state.servers[0].label = 'Changed during probe';
state.profiles[0].servers[0].label = 'Changed during probe';
releaseProbe();
const result = await resultPromise;
@@ -114,9 +119,13 @@ test('connectivity use case keeps applied priority, selected fallback and error
const result = { vpn: { available: false }, marker: true };
const selected = createConnectivityDiagnosticsUseCase({
readState: () => ({
desiredProfileId: 'primary',
appliedServerId: '',
selectedServerId: 'selected',
profiles: [{
id: 'primary',
desiredServerId: 'selected',
servers: [{ id: 'selected', label: 'Selected' }],
}],
}),
runDiagnostics: async () => result,
});
@@ -127,9 +136,14 @@ test('connectivity use case keeps applied priority, selected fallback and error
const missingApplied = createConnectivityDiagnosticsUseCase({
readState: () => ({
desiredProfileId: 'primary',
appliedProfileId: 'primary',
appliedServerId: 'missing',
selectedServerId: 'selected',
profiles: [{
id: 'primary',
desiredServerId: 'selected',
servers: [{ id: 'selected', label: 'Selected' }],
}],
}),
runDiagnostics: async () => result,
});
+71 -24
View File
@@ -15,6 +15,30 @@ import { createGatewayAutoRoute } from '../../dist/server/http/routes/gatewayAut
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
const storedProfile = (servers = [server]) => ({
id: 'primary',
label: 'Основной',
subscriptionUrl,
subscriptionConfig: { outbounds: [] },
servers,
userInfo: {},
fetchedAt: null,
desiredServerId: servers[0]?.id || '',
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
});
const canonicalState = (profiles = [storedProfile()]) => ({
revision: 10,
profiles,
desiredProfileId: profiles[0]?.id || '',
appliedProfileId: profiles[0]?.id || '',
appliedServerId: profiles[0]?.desiredServerId || '',
appliedServerSnapshot: profiles[0]?.servers[0] || null,
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
gatewayAutoEnabled: true,
});
const networkA = {
gateway: '192.168.50.111',
interface: 'en0',
@@ -51,17 +75,7 @@ function deferred() {
}
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? {
revision: 10,
servers: [server],
selectedServerId: server.id,
appliedServerId: server.id,
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
subscriptionUrl,
gatewayAutoEnabled: true,
});
let state = structuredClone(overrides.state ?? canonicalState());
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
let network = Object.hasOwn(overrides, 'network') ? overrides.network : networkA;
let tail = Promise.resolve();
@@ -109,11 +123,14 @@ function createHarness(overrides = {}) {
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
},
config: {
build: (_subscription, selectedServerId, routeRules, gatewayAuto) => ({
build: (_subscription, selectedServerId, routeRules, gatewayAuto) => {
if (failures.configBuild) throw failures.configBuild;
return {
selectedServerId,
routeRules,
clientDirect: gatewayAuto.mode === 'gateway-direct',
}),
};
},
read: () => config,
write: (value) => {
events.push('config.write');
@@ -144,6 +161,10 @@ function createHarness(overrides = {}) {
events.push('runtime.restore');
if (failures.runtimeRestore) throw failures.runtimeRestore;
},
stopCommand: async () => {
events.push('runtime.stop');
return overrides.stopCommandResult || { ok: true, mutationStarted: true };
},
},
discovery: {
readHostNetwork: () => structuredClone(network),
@@ -224,16 +245,8 @@ test('gateway-auto handles no-op and metadata-only discovery without config/runt
test('gateway-auto mode changes separate state-only, stopped and running paths', async () => {
const stateOnly = createHarness({
gatewayAuto: directState(),
state: {
revision: 1,
servers: [],
selectedServerId: '',
appliedServerId: '',
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
gatewayAutoEnabled: true,
},
state: { ...canonicalState([]), revision: 1 },
running: false,
});
await stateOnly.service.setEnabled(false);
assert.equal(stateOnly.snapshot().gatewayAuto.mode, 'local-vpn');
@@ -266,6 +279,38 @@ test('gateway-auto startup writes candidate config before publication without ap
]);
});
test('gateway loss safely stops when the applied server disappeared from refreshed config', async () => {
const state = canonicalState();
state.profiles[0].servers = [];
state.profiles[0].desiredServerId = '';
const buildError = Object.assign(new Error('server removed'), { code: 'SERVER_NOT_FOUND' });
const harness = createHarness({
state,
gatewayAuto: directState(),
network: networkB,
failures: { configBuild: buildError },
});
await assert.rejects(harness.service.refresh(), (error) => error === buildError);
const after = harness.snapshot();
assert.equal(after.gatewayAuto.mode, 'local-vpn');
assert.equal(after.state.connectionDesired, 'stopped');
assert.equal(after.state.appliedProfileId, '');
assert.equal(after.state.appliedServerId, '');
assert.equal(harness.events.includes('runtime.stop'), true);
});
test('Gateway discovery preserves a stale applied local runtime when direct config cannot build', async () => {
const state = canonicalState();
state.profiles[0].servers = [];
state.profiles[0].desiredServerId = '';
const buildError = Object.assign(new Error('server removed'), { code: 'SERVER_NOT_FOUND' });
const harness = createHarness({ state, failures: { configBuild: buildError } });
const before = harness.snapshot();
await assert.rejects(harness.service.refresh(), (error) => error === buildError);
assert.deepEqual(withoutRevision(harness.snapshot()), withoutRevision(before));
assert.equal(harness.events.includes('runtime.stop'), false);
});
test('disable and re-enable preserve verified Gateway identity and persisted preference commits', async () => {
const harness = createHarness({ gatewayAuto: directState() });
await harness.service.setEnabled(false);
@@ -382,7 +427,9 @@ test('gateway-auto discards stale subscription and route probe results', async (
staleSubscription.service.set(directState());
const refresh = staleSubscription.service.refresh();
await new Promise((resolve) => setImmediate(resolve));
staleSubscription.patchState({ subscriptionUrl: `${subscriptionUrl}-new` });
const changedState = staleSubscription.snapshot().state;
changedState.profiles[0].subscriptionUrl = `${subscriptionUrl}-new`;
staleSubscription.setState(changedState);
gate.resolve(verifiedA);
await refresh;
assert.deepEqual(staleSubscription.snapshot().gatewayAuto, createGatewayAutoState());
+24 -9
View File
@@ -8,17 +8,32 @@ import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesR
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true }];
const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true }];
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? {
const storedProfile = (servers = [server]) => ({
id: 'primary',
label: 'Основной',
subscriptionUrl: 'https://provider.example/sub',
subscriptionConfig: { outbounds: [] },
servers,
userInfo: {},
fetchedAt: null,
desiredServerId: servers[0]?.id || '',
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
});
const canonicalState = (profiles = [storedProfile()]) => ({
revision: 10,
routeRulesRevision: 2,
routeRules: oldRules,
appliedRouteRules: oldRules,
servers: [server],
selectedServerId: server.id,
appliedServerId: server.id,
profiles,
desiredProfileId: profiles[0]?.id || '',
appliedProfileId: profiles[0]?.id || '',
appliedServerId: profiles[0]?.desiredServerId || '',
appliedServerSnapshot: profiles[0]?.servers[0] || null,
});
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? canonicalState());
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
const failures = {
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
@@ -140,10 +155,10 @@ test('route rules support explicit domain revision and legacy global revision wi
test('route rules state-only path leaves config and applied rules unchanged', async () => {
for (const state of [
{ revision: 1, routeRulesRevision: 0, routeRules: oldRules, appliedRouteRules: oldRules, servers: [], selectedServerId: '', appliedServerId: '' },
{ revision: 1, routeRulesRevision: 0, routeRules: oldRules, appliedRouteRules: oldRules, servers: [server], selectedServerId: server.id, appliedServerId: server.id },
{ ...canonicalState([]), revision: 1, routeRulesRevision: 0 },
{ ...canonicalState(), revision: 1, routeRulesRevision: 0 },
]) {
const harness = createHarness({ state, missingSubscription: Boolean(state.selectedServerId) });
const harness = createHarness({ state, missingSubscription: Boolean(state.profiles.length) });
await harness.service.update(newRules, 0, undefined);
assert.deepEqual(harness.snapshot().state.routeRules, newRules);
assert.deepEqual(harness.snapshot().state.appliedRouteRules, oldRules);
+20 -11
View File
@@ -39,23 +39,29 @@ test('server health selection coerces IDs, deduplicates, ignores unknowns, and k
];
const checked = [];
const service = createServerHealthService({
readServers: () => servers,
readProfiles: () => [{ id: 'personal', servers }],
readDesiredProfileId: () => 'personal',
ping: async (host, port) => {
checked.push([host, port]);
return { ok: true };
},
});
assert.deepEqual((await service.check(['3', 1, '3', 'missing'])).map(({ id }) => id), ['1', '3']);
assert.deepEqual((await service.check('personal', ['3', 1, '3', 'missing'])).map(({ id }) => id), ['1', '3']);
assert.deepEqual(checked, [['one.example', 1], ['three.example', 3]]);
assert.deepEqual((await service.check(undefined)).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual((await service.check('2')).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual((await service.check([])).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual(await service.check(['missing']), []);
assert.deepEqual((await service.check('', undefined)).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual((await service.check('personal', '2')).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual((await service.check('personal', [])).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual(await service.check('personal', ['missing']), []);
assert.throws(() => service.check('missing', []), (error) => error.code === 'PROFILE_NOT_FOUND');
const failure = new Error('ping failed');
await assert.rejects(
createServerHealthService({ readServers: () => servers, ping: async () => { throw failure; } }).check([]),
createServerHealthService({
readProfiles: () => [{ id: 'personal', servers }],
readDesiredProfileId: () => 'personal',
ping: async () => { throw failure; },
}).check('personal', []),
(error) => error === failure,
);
});
@@ -63,15 +69,18 @@ test('server health selection coerces IDs, deduplicates, ignores unknowns, and k
test('server health route is the only endpoint adapter', async () => {
const sent = [];
const route = createServerHealthRoute({
serverHealth: { check: async (ids) => [{ ids }] },
serverHealth: { check: async (profileId, ids) => [{ profileId, ids }] },
readBody: async () => ({ serverIds: ['chosen'] }),
sendState: async (_res, extra) => { sent.push(extra); },
});
const response = {};
assert.equal(await route.handle({ method: 'GET', url: '/api/servers/ping-all' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/servers/ping-all' }, response), true);
assert.deepEqual(sent, [{ results: [{ ids: ['chosen'] }] }]);
assert.equal(await route.handle({ method: 'GET', url: '/api/profiles/personal/servers/ping' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/profiles/personal/servers/ping' }, response), true);
assert.deepEqual(sent, [{
profileId: 'personal',
results: [{ profileId: 'personal', ids: ['chosen'] }],
}]);
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(source, /createServerHealthRoute\(\{/);
+340
View File
@@ -0,0 +1,340 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '../..');
function listen(server, ...args) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(...args, () => {
server.off('error', reject);
resolve(server.address());
});
});
}
function close(server) {
return new Promise((resolve) => server.close(resolve));
}
async function freePort() {
const server = http.createServer();
const address = await listen(server, 0, '127.0.0.1');
await close(server);
return address.port;
}
async function waitForState(port, child, stderr) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) throw new Error(`Harbor exited early: ${stderr()}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/api/state`);
if (response.ok) return response.json();
} catch {
// The disposable listener is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`Harbor did not start: ${stderr()}`);
}
async function stopChild(child) {
if (child.exitCode !== null) return;
child.kill('SIGTERM');
await new Promise((resolve) => child.once('exit', resolve));
}
function fakeSingbox(directory) {
const binDirectory = path.join(directory, 'bin');
const markerPath = path.join(directory, 'sing-box-runs.log');
fs.mkdirSync(binDirectory);
const executable = path.join(binDirectory, 'sing-box');
fs.writeFileSync(executable, `#!/usr/bin/env node
const fs = require('node:fs');
if (process.argv[2] === 'check') process.exit(0);
if (process.argv[2] === 'version') {
console.log('sing-box version 1.13.18');
process.exit(0);
}
if (process.argv[2] === 'run') {
fs.appendFileSync(process.env.HARBOR_TEST_RUN_MARKER, 'run\\n');
setInterval(() => {}, 60_000);
}
`);
fs.chmodSync(executable, 0o755);
return { binDirectory, markerPath };
}
function profileState(server) {
return {
schemaVersion: 5,
revision: 4,
profiles: [{
id: 'profile-a',
label: 'Основной',
subscriptionUrl: 'https://provider.example/subscription-a',
subscriptionConfig: null,
servers: [server],
userInfo: {},
fetchedAt: null,
desiredServerId: server.id,
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
}],
desiredProfileId: 'profile-a',
appliedProfileId: 'profile-a',
appliedServerId: server.id,
appliedServerSnapshot: server,
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
connectionDesired: 'running',
};
}
function generatedConfig(server, final = server.id) {
return {
outbounds: [
{
type: server.protocol,
tag: server.id,
server: server.host,
server_port: server.port,
},
{ type: 'direct', tag: 'direct' },
],
route: { final },
};
}
async function startClientFixture(t, {
state,
cacheContents,
config,
}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
const { binDirectory, markerPath } = fakeSingbox(directory);
if (state !== undefined) fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify(state));
if (cacheContents !== undefined) {
fs.writeFileSync(path.join(directory, 'subscription-cache.json'), cacheContents);
}
if (config !== undefined) {
fs.writeFileSync(path.join(directory, 'sing-box-config.json'), JSON.stringify(config));
}
const port = await freePort();
const child = spawn(process.execPath, ['dist/server/main.js'], {
cwd: root,
env: {
...process.env,
APP_COMPONENT: 'control',
APP_MODE: 'client',
DATA_DIR: directory,
PORT: String(port),
PATH: `${binDirectory}:${process.env.PATH || ''}`,
HARBOR_HOST_NETWORK_STATE: path.join(directory, 'missing-network.json'),
HARBOR_TEST_RUN_MARKER: markerPath,
},
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; });
t.after(async () => {
await stopChild(child);
fs.rmSync(directory, { recursive: true, force: true });
});
return {
directory,
markerPath,
state: await waitForState(port, child, () => stderr),
};
}
test('corrupt legacy cache with no canonical subscription fails closed instead of starting stale config', async (t) => {
const staleServer = {
id: 'stale-server',
label: 'Stale server',
host: 'stale.example',
port: 443,
protocol: 'vless',
};
const fixture = await startClientFixture(t, {
state: { schemaVersion: 4, revision: 2, connectionDesired: 'running' },
cacheContents: '{broken',
config: generatedConfig(staleServer),
});
assert.equal(fixture.state.subscription.status, 'missing');
assert.deepEqual(fixture.state.profiles, []);
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fs.existsSync(fixture.markerPath), false);
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
name.startsWith('subscription-cache.json.corrupt-')
)));
});
test('legacy cache owned by another URL is backed up without mixing providers and boots stopped', async (t) => {
const stateServer = {
id: 'server-a',
label: 'State server',
host: 'state.example',
port: 443,
protocol: 'vless',
};
const cacheServer = {
id: 'server-b',
label: 'Cache server',
host: 'cache.example',
port: 8443,
protocol: 'vless',
type: 'vless',
tag: 'Cache server',
server: 'cache.example',
server_port: 8443,
};
const fixture = await startClientFixture(t, {
state: {
schemaVersion: 4,
revision: 2,
subscriptionUrl: 'https://state.example/subscription-a',
servers: [stateServer],
selectedServerId: stateServer.id,
appliedServerId: stateServer.id,
connectionDesired: 'running',
},
cacheContents: JSON.stringify({
url: 'https://cache.example/subscription-b',
config: { outbounds: [cacheServer] },
servers: [cacheServer],
}),
config: generatedConfig(stateServer),
});
assert.equal(fixture.state.profiles.length, 1);
assert.equal(fixture.state.profiles[0].subscription.host, 'state.example/…');
assert.deepEqual(fixture.state.profiles[0].servers.map(({ id }) => id), [stateServer.id]);
assert.equal(JSON.stringify(fixture.state).includes('cache.example'), false);
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fixture.state.selection.appliedServerId, '');
assert.equal(fs.existsSync(path.join(fixture.directory, 'subscription-cache.json')), false);
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
name.startsWith('subscription-cache.json.backup-v1-')
)));
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
assert.equal(fs.existsSync(fixture.markerPath), false);
});
test('boot rejects an existing config whose route mode disagrees with the current route', async (t) => {
const server = {
id: 'server-a',
label: 'Server A',
host: 'a.example',
port: 443,
protocol: 'vless',
};
const fixture = await startClientFixture(t, {
state: profileState(server),
config: generatedConfig(server, 'direct'),
});
assert.equal(fixture.state.route.mode, 'local-vpn');
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fs.existsSync(fixture.markerPath), false);
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
});
test('boot rejects an existing config owned by a different applied target', async (t) => {
const appliedServer = {
id: 'server-a',
label: 'Server A',
host: 'a.example',
port: 443,
protocol: 'vless',
};
const otherServer = {
id: 'server-b',
label: 'Server B',
host: 'b.example',
port: 8443,
protocol: 'vless',
};
const fixture = await startClientFixture(t, {
state: profileState(appliedServer),
config: generatedConfig(otherServer),
});
assert.equal(fixture.state.selection.appliedServerId, '');
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fs.existsSync(fixture.markerPath), false);
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
});
test('stopped Gateway boot explicitly stops an already running remote dataplane', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-'));
const socketPath = path.join(directory, 'dataplane.sock');
const requests = [];
let running = true;
const dataplane = http.createServer((req, res) => {
requests.push(`${req.method} ${req.url}`);
if (req.method === 'GET' && req.url === '/status') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ running, startedAt: running ? '2026-08-11T12:00:00.000Z' : null }));
}
if (req.method === 'POST' && req.url === '/stop') {
running = false;
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ running: false, startedAt: null }));
}
res.writeHead(503, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: 'not used by this fixture' }));
});
await listen(dataplane, socketPath);
fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify({
schemaVersion: 5,
revision: 3,
profiles: [],
desiredProfileId: '',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
connectionDesired: 'stopped',
}));
fs.writeFileSync(path.join(directory, 'sing-box-config.json'), '{}');
const port = await freePort();
const child = spawn(process.execPath, ['dist/server/main.js'], {
cwd: root,
env: {
...process.env,
APP_COMPONENT: 'control',
APP_MODE: 'gateway',
DATA_DIR: directory,
DATAPLANE_SOCKET: socketPath,
PORT: String(port),
},
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; });
t.after(async () => {
await stopChild(child);
await close(dataplane);
fs.rmSync(directory, { recursive: true, force: true });
});
const state = await waitForState(port, child, () => stderr);
assert.equal(requests.includes('POST /stop'), true);
assert.equal(running, false);
assert.equal(state.connection.desired, 'stopped');
assert.equal(state.connection.process, 'stopped');
});
+145 -68
View File
@@ -60,7 +60,7 @@ async function waitForState(port, child, stderr) {
throw new Error(`Harbor did not start: ${stderr()}`);
}
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
test('state v1 projects legacy storage through the canonical profile snapshot', () => {
const legacyServer = { tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 };
const legacyServerId = createServerId(legacyServer);
const stored = normalizeStoredState({
@@ -79,9 +79,15 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
});
assert.equal(snapshot.apiVersion, 1);
assert.equal(snapshot.profiles.length, 1);
assert.equal(snapshot.profiles[0].id, 'profile_primary');
assert.equal(snapshot.profiles[0].desiredServerId, legacyServerId);
assert.deepEqual(snapshot.selection, {
desiredProfileId: 'profile_primary',
desiredServerId: legacyServerId,
appliedProfileId: 'profile_primary',
appliedServerId: legacyServerId,
appliedServerSnapshot: snapshot.profiles[0].servers[0],
});
assert.equal(snapshot.connection.process, 'running');
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
@@ -145,7 +151,7 @@ test('startup discards a rejected cached subscription and returns to first-run',
assert.equal(child.exitCode, null);
});
test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => {
test('data invariant: canonical profile API mutates one snapshot and preserves migrated profile data', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
const binDir = path.join(dir, 'bin');
const config = {
@@ -177,12 +183,9 @@ setInterval(() => {}, 60_000);
fs.chmodSync(singboxPath, 0o755);
let providerFetchCount = 0;
let delayedPath = '';
let invalidNextPath = '';
let trafficExhaustedNextPath = '';
let delayedRequestStarted = null;
let releaseDelayedRequest = null;
const subscriptionServer = http.createServer(async (req, res) => {
const subscriptionServer = http.createServer((req, res) => {
providerFetchCount += 1;
if (req.url === '/timeout') return;
if (req.url === '/unavailable') {
@@ -234,11 +237,6 @@ setInterval(() => {}, 60_000);
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === delayedPath) {
delayedRequestStarted?.();
await new Promise((resolve) => { releaseDelayedRequest = resolve; });
delayedPath = '';
}
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100',
@@ -256,6 +254,7 @@ setInterval(() => {}, 60_000);
url: subscriptionUrl,
config,
}));
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
const port = await freePort();
const child = spawn(process.execPath, ['dist/server/main.js'], {
@@ -289,7 +288,13 @@ setInterval(() => {}, 60_000);
runtime: { singBox: '1.12.13' },
});
assertStateSnapshot(initial);
assert.equal(initial.profiles.length, 1);
assert.equal(initial.profiles[0].id, 'profile_primary');
assert.equal(initial.profiles[0].label, 'Основной');
assert.equal(initial.selection.desiredProfileId, 'profile_primary');
assert.equal(initial.selection.appliedServerId, testServerId);
assert.equal(initial.selection.appliedProfileId, 'profile_primary');
assert.equal(initial.selection.appliedServerSnapshot.id, testServerId);
assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
@@ -297,6 +302,15 @@ setInterval(() => {}, 60_000);
assert.equal(initial.route.localRulesRevision, 0);
assert.equal(initial.route.localRulesPendingRestart, false);
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(migratedState.schemaVersion, 5);
assert.equal(migratedState.profiles.length, 1);
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
assert.equal(migratedState.profiles[0].subscriptionConfig.outbounds[0].tag, testServerId);
assert.equal(Object.hasOwn(migratedState, 'subscriptionUrl'), false);
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
assert.ok(fs.readdirSync(dir).some((name) => name.startsWith('subscription-cache.json.backup-v1-')));
const stateKeys = Object.keys(initial).sort();
assert.deepEqual(stateKeys, [
'apiVersion',
@@ -309,6 +323,7 @@ setInterval(() => {}, 60_000);
'mode',
'operation',
'port',
'profiles',
'proxyPort',
'revision',
'route',
@@ -350,11 +365,11 @@ setInterval(() => {}, 60_000);
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
assert.equal(providerUnavailable.payload.error.retryable, true);
const preservedSubscription = {
state: JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')),
cache: fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
config: fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
};
const primaryProfileId = initial.profiles[0].id;
const preservedPrimary = structuredClone(
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).profiles[0],
);
const preservedConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
for (const [pathname, expectedCode] of [
['/timeout', 'PROVIDER_UNAVAILABLE'],
['/invalid', 'SUBSCRIPTION_INVALID'],
@@ -362,46 +377,59 @@ setInterval(() => {}, 60_000);
['/traffic', 'SUBSCRIPTION_TRAFFIC_EXHAUSTED'],
['/disabled', 'SUBSCRIPTION_DISABLED'],
]) {
const failedImport = await rawRequest(
const failedAdd = await rawRequest(
port,
'/api/subscription/fetch',
'/api/profiles',
'POST',
{ url: `http://127.0.0.1:${subscriptionPort}${pathname}` },
{
label: `Новая ${pathname}`,
url: `http://127.0.0.1:${subscriptionPort}${pathname}`,
expectedRevision: revision,
},
);
assert.equal(failedImport.payload.error.code, expectedCode);
assert.equal(failedAdd.payload.error.code, expectedCode);
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(storedAfterFailure.subscriptionUrl, preservedSubscription.state.subscriptionUrl);
assert.equal(storedAfterFailure.selectedTag, preservedSubscription.state.selectedTag);
assert.deepEqual(storedAfterFailure.servers, preservedSubscription.state.servers);
assert.equal(
fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
preservedSubscription.cache,
);
assert.deepEqual(storedAfterFailure.profiles, [preservedPrimary]);
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
assert.equal(
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
preservedSubscription.config,
preservedConfig,
);
revision = (await request(port, '/api/state')).revision;
}
trafficExhaustedNextPath = '/subscription/test';
const exhaustedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
const exhaustedRefresh = await rawRequest(
port,
`/api/profiles/${primaryProfileId}/refresh`,
'POST',
{ expectedRevision: revision },
);
assert.equal(exhaustedRefresh.response.status, 400);
assert.equal(exhaustedRefresh.payload.error.code, 'SUBSCRIPTION_TRAFFIC_EXHAUSTED');
const stateAfterExhaustedRefresh = await request(port, '/api/state');
assert.equal(stateAfterExhaustedRefresh.hasSubscription, true);
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), preservedSubscription.cache);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedSubscription.config);
assert.equal(stateAfterExhaustedRefresh.profiles[0].subscription.status, 'stale');
assert.equal(
stateAfterExhaustedRefresh.profiles[0].subscription.errorCode,
'SUBSCRIPTION_TRAFFIC_EXHAUSTED',
);
assert.equal(stateAfterExhaustedRefresh.profiles[0].servers[0].id, testServerId);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedConfig);
revision = stateAfterExhaustedRefresh.revision;
const missingServer = await rawRequest(
port,
'/api/apply',
'POST',
{ selectedTag: 'missing-server' },
{ profileId: primaryProfileId, serverId: 'missing-server', expectedRevision: revision },
);
assert.equal(missingServer.response.status, 404);
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
assert.equal((await request(port, '/api/state')).selection.desiredServerId, testServerId);
const stateAfterMissingServer = await request(port, '/api/state');
assert.equal(stateAfterMissingServer.selection.desiredProfileId, primaryProfileId);
assert.equal(stateAfterMissingServer.selection.desiredServerId, testServerId);
revision = stateAfterMissingServer.revision;
async function stateResponse(pathname, method = 'POST', body) {
const result = await request(port, pathname, method, body);
@@ -417,27 +445,76 @@ setInterval(() => {}, 60_000);
return result;
}
const fetchesBeforeImport = providerFetchCount;
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'))).config.outbounds[0].tag,
'test-vpn',
);
const applied = await mutation('/api/apply', 'POST', { serverId: testServerId });
assert.deepEqual(applied.state.selection, {
desiredServerId: testServerId,
appliedServerId: testServerId,
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
const fetchesBeforeDuplicate = providerFetchCount;
const duplicateProfile = await rawRequest(port, '/api/profiles', 'POST', {
label: 'основной',
url: replacementUrl,
expectedRevision: revision,
});
assert.equal(duplicateProfile.response.status, 409);
assert.equal(duplicateProfile.payload.error.code, 'PROFILE_NAME_CONFLICT');
assert.equal(providerFetchCount, fetchesBeforeDuplicate);
assert.equal((await request(port, '/api/state')).revision, revision);
const fetchesBeforeAdd = providerFetchCount;
const added = await mutation('/api/profiles', 'POST', {
label: 'Работа',
url: replacementUrl,
expectedRevision: revision,
});
const workProfileId = added.profileId;
assert.equal(providerFetchCount, fetchesBeforeAdd + 1);
assert.equal(added.state.profiles.length, 2);
assert.equal(added.state.selection.desiredProfileId, primaryProfileId);
assert.equal(added.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, '');
assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'))
.profiles.find(({ id }) => id === workProfileId).subscriptionConfig.outbounds[0].tag,
testServerId,
);
const renamed = await mutation(`/api/profiles/${workProfileId}`, 'PATCH', {
label: 'Офис',
expectedRevision: revision,
});
assert.equal(renamed.state.profiles.find(({ id }) => id === workProfileId).label, 'Офис');
const selected = await mutation(`/api/profiles/${workProfileId}/server`, 'PUT', {
serverId: testServerId,
expectedRevision: revision,
});
assert.equal(
selected.state.profiles.find(({ id }) => id === workProfileId).desiredServerId,
testServerId,
);
assert.equal(selected.state.profiles.find(({ id }) => id === primaryProfileId).desiredServerId, testServerId);
const applied = await mutation('/api/apply', 'POST', {
profileId: workProfileId,
serverId: testServerId,
expectedRevision: revision,
});
assert.equal(applied.state.selection.desiredProfileId, workProfileId);
assert.equal(applied.state.selection.desiredServerId, testServerId);
assert.equal(applied.state.selection.appliedProfileId, workProfileId);
assert.equal(applied.state.selection.appliedServerId, testServerId);
assert.equal(applied.state.selection.appliedServerSnapshot.id, testServerId);
assert.equal(applied.state.connection.process, 'running');
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
const configBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
invalidNextPath = '/subscription/test';
const failedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
invalidNextPath = '/subscription/replacement';
const failedRefresh = await rawRequest(
port,
`/api/profiles/${workProfileId}/refresh`,
'POST',
{ expectedRevision: revision },
);
assert.equal(failedRefresh.response.status, 400);
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).selectedTag, 'test-vpn');
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), cacheBeforeFailedRefresh);
const storedAfterFailedRefresh = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
const staleWorkProfile = storedAfterFailedRefresh.profiles.find(({ id }) => id === workProfileId);
assert.equal(staleWorkProfile.desiredServerId, testServerId);
assert.equal(staleWorkProfile.lastRefreshErrorCode, 'SUBSCRIPTION_INVALID');
assert.equal(staleWorkProfile.servers[0].id, testServerId);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), configBeforeFailedRefresh);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
@@ -549,28 +626,28 @@ if (process.argv[2] === 'check') {
fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
const delayedRequest = new Promise((resolve) => { delayedRequestStarted = resolve; });
delayedPath = '/subscription/test';
const staleRefresh = rawRequest(port, '/api/subscription/refresh', 'POST');
await delayedRequest;
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
await mutation('/api/subscription/fetch', 'POST', { url: replacementUrl });
releaseDelayedRequest();
const staleRefreshResult = await staleRefresh;
assert.equal(staleRefreshResult.response.status, 409);
assert.equal(staleRefreshResult.payload.error.code, 'STATE_CONFLICT');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, replacementUrl);
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')).url, replacementUrl);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing');
assert.equal(forgotten.state.servers.length, 0);
assert.deepEqual(forgotten.state.route.localRules, routed.state.route.localRules);
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
const inactiveDeleted = await mutation(`/api/profiles/${primaryProfileId}`, 'DELETE', {
mode: 'delete',
expectedRevision: revision,
});
assert.deepEqual(inactiveDeleted.state.profiles.map(({ id }) => id), [workProfileId]);
assert.equal(inactiveDeleted.state.selection.appliedProfileId, workProfileId);
const activeDeleted = await mutation(`/api/profiles/${workProfileId}`, 'DELETE', {
mode: 'stop-and-delete',
expectedRevision: revision,
});
assert.equal(activeDeleted.state.subscription.status, 'missing');
assert.deepEqual(activeDeleted.state.profiles, []);
assert.equal(activeDeleted.state.selection.desiredProfileId, '');
assert.equal(activeDeleted.state.selection.appliedProfileId, '');
assert.equal(activeDeleted.state.servers.length, 0);
assert.deepEqual(activeDeleted.state.route.localRules, routed.state.route.localRules);
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
assert.equal(missingConfig.response.status, 422);
assert.equal(missingConfig.payload.error.code, 'CONFIG_INVALID');
assert.equal(missingConfig.response.status, 404);
assert.equal(missingConfig.payload.error.code, 'PROFILE_NOT_FOUND');
assert.equal(missingConfig.payload.error.retryable, false);
});
+53 -5
View File
@@ -63,9 +63,12 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
assert.deepEqual(migrated.routeRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.equal(migrated.appliedTag, 'nl');
assert.equal(migrated.selectedServerId, createServerId(legacy.servers[0]));
assert.equal(migrated.appliedServerId, migrated.selectedServerId);
const primary = migrated.profiles[0];
assert.equal(primary.label, 'Основной');
assert.equal(primary.desiredServerId, createServerId(legacy.servers[0]));
assert.equal(migrated.appliedProfileId, primary.id);
assert.equal(migrated.appliedServerId, primary.desiredServerId);
assert.equal(Object.hasOwn(migrated, 'selectedServerId'), false);
assert.equal(store.migration.fromVersion, 2);
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
@@ -84,9 +87,54 @@ test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
const migrated = createStateStore(filePath).read();
assert.equal(migrated.selectedServerId, '');
assert.equal(migrated.profiles[0].desiredServerId, '');
assert.equal(migrated.appliedServerId, '');
assert.equal(migrated.servers.length, 2);
assert.equal(migrated.profiles[0].servers.length, 2);
});
test('schema v4 migration never combines canonical state with a different cache owner', (t) => {
const filePath = fixture(t);
const stateServer = {
tag: 'State server',
type: 'vless',
server: 'state.example',
server_port: 443,
};
const cacheServer = {
tag: 'Cache server',
type: 'vless',
server: 'cache.example',
server_port: 8443,
};
const stateServerId = createServerId(stateServer);
fs.writeFileSync(filePath, JSON.stringify({
schemaVersion: 4,
revision: 9,
subscriptionUrl: 'https://state.example/subscription-a',
servers: [stateServer],
selectedServerId: stateServerId,
appliedServerId: stateServerId,
connectionDesired: 'running',
}));
const migrated = createStateStore(filePath, {
legacySubscriptionCache: {
url: 'https://cache.example/subscription-b',
config: { outbounds: [cacheServer] },
servers: [cacheServer],
userInfo: { total: 123 },
fetchedAt: '2026-08-11T12:00:00.000Z',
},
}).read();
assert.equal(migrated.profiles.length, 1);
assert.equal(migrated.profiles[0].subscriptionUrl, 'https://state.example/subscription-a');
assert.equal(migrated.profiles[0].subscriptionConfig, null);
assert.deepEqual(migrated.profiles[0].servers.map(({ id }) => id), [stateServerId]);
assert.equal(migrated.profiles[0].desiredServerId, stateServerId);
assert.equal(migrated.appliedServerId, stateServerId);
assert.equal(migrated.appliedServerSnapshot.id, stateServerId);
assert.equal(JSON.stringify(migrated).includes('cache.example'), false);
});
test('data invariant: corrupt JSON preserves original bytes and returns explicit recovery state', (t) => {
+239 -414
View File
@@ -4,144 +4,111 @@ import test from 'node:test';
import { createSubscriptionService } from '../../dist/server/features/subscription/index.js';
import { createSubscriptionMutationRoute } from '../../dist/server/http/routes/subscriptionMutationRoute.js';
import { HarborError } from '../../dist/shared/errors.js';
const oldServer = {
id: 'srv_old',
label: 'Old',
host: 'old.example',
port: 443,
protocol: 'vless',
};
const nextServer = {
id: 'srv_next',
label: 'Next',
host: 'next.example',
port: 443,
protocol: 'vless',
};
const oldServer = { id: 'shared', label: 'Old', host: 'old.example', port: 443, protocol: 'vless' };
const nextServer = { id: 'next', label: 'Next', host: 'next.example', port: 443, protocol: 'vless' };
const routeRules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
function parsed(servers = [nextServer]) {
return {
config: { normalized: true },
sourceConfig: { source: true },
servers,
const profile = (id, label, server = oldServer) => ({
id,
label,
subscriptionUrl: `https://${id}.example/sub`,
subscriptionConfig: { profile: id },
servers: [server],
userInfo: { total: 100 },
fetchedAt: '2026-08-08T10:00:00.000Z',
desiredServerId: server.id,
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
});
const parsed = (id, servers = [nextServer]) => ({
config: { profile: id, refreshed: true },
servers,
userInfo: { total: 200 },
fetchedAt: '2026-08-08T12:00:00.000Z',
});
function defaultState() {
return {
revision: 3,
profiles: [profile('personal', 'Личный'), profile('work', 'Работа')],
desiredProfileId: 'personal',
appliedProfileId: 'personal',
appliedServerId: 'shared',
appliedServerSnapshot: oldServer,
routeRules,
appliedRouteRules: routeRules,
routeRulesRevision: 1,
connectionDesired: 'running',
};
}
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? {
revision: 3,
subscriptionUrl: 'https://old.example/sub',
servers: [oldServer],
selectedServerId: oldServer.id,
appliedServerId: oldServer.id,
routeRules,
gatewayAutoEnabled: false,
connectionDesired: 'running',
});
let cache = structuredClone(overrides.cache ?? { url: 'old', config: { old: true } });
let config = overrides.config ?? 'old-config-bytes';
let gatewayAuto = structuredClone(overrides.gatewayAuto ?? { mode: 'gateway-direct', gatewayId: 'old' });
let state = structuredClone(overrides.state ?? defaultState());
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
let gatewayAuto = { mode: 'gateway-direct', gatewayId: 'gateway' };
let running = overrides.running ?? true;
let serialized = Promise.resolve();
let tail = Promise.resolve();
let updateCount = 0;
const calls = [];
const failures = { ...(overrides.failures || {}) };
const timers = [];
const providerCalls = [];
const failOnce = (name) => {
const failure = failures[name];
if (!failure) return;
delete failures[name];
throw failure;
const serialize = (operation) => {
const result = tail.then(operation, operation);
tail = result.then(() => undefined, () => undefined);
return result;
};
const dependencies = {
const service = createSubscriptionService({
provider: {
fetchSubscription: overrides.fetchSubscription || (async () => parsed()),
selectRefreshedServer: overrides.selectRefreshedServer || ((current, _before, after) => (
fetchSubscription: async (url) => {
providerCalls.push(url);
if (overrides.fetchSubscription) return overrides.fetchSubscription(url);
return parsed(url.includes('work') ? 'work' : 'new');
},
selectRefreshedServer: (current, _before, after) => (
after.some((server) => server.id === current) ? current : ''
)),
),
},
state: {
read: () => structuredClone(state),
update: (mutator) => {
failOnce('stateUpdate');
const revision = state.revision + 1;
state = { ...structuredClone(mutator(structuredClone(state))), revision };
updateCount += 1;
const next = structuredClone(mutator(structuredClone(state)));
state = { ...next, revision: state.revision + 1 };
calls.push('state.update');
failOnce('stateUpdateAfter');
if (overrides.failStateAt === updateCount) throw new Error('state failed');
return structuredClone(state);
},
},
cache: {
read: () => structuredClone(cache),
write: (value) => {
failOnce('cacheWrite');
cache = structuredClone(value);
calls.push('cache.write');
},
remove: () => {
failOnce('cacheRemove');
cache = null;
calls.push('cache.remove');
},
},
config: {
build: (_value, selectedServerId, rules) => ({ selectedServerId, rules }),
read: () => config,
write: (value) => {
failOnce('configWrite');
config = JSON.stringify(value);
calls.push('config.write');
},
restore: (value) => {
failOnce('configRestore');
config = value;
calls.push('config.restore');
},
remove: () => {
failOnce('configRemove');
config = null;
calls.push('config.remove');
},
write: (value) => { config = JSON.stringify(value); calls.push('config.write'); },
restore: (value) => { config = value; calls.push('config.restore'); },
remove: () => { config = null; calls.push('config.remove'); },
},
runtime: {
isRunning: async () => running,
stop: async () => {
calls.push('runtime.stop');
failOnce('runtimeStop');
if (overrides.failStop) throw overrides.failStop;
running = false;
},
start: async () => {
calls.push('runtime.start');
const failure = Array.isArray(failures.runtimeStart)
? failures.runtimeStart.shift()
: failures.runtimeStart;
if (failure) {
if (!Array.isArray(failures.runtimeStart)) delete failures.runtimeStart;
running = false;
throw failure;
}
if (overrides.failStart) throw overrides.failStart;
running = true;
},
},
gatewayAuto: {
read: () => structuredClone(gatewayAuto),
set: (value) => {
gatewayAuto = structuredClone(value);
calls.push('gateway.set');
},
set: (value) => { gatewayAuto = structuredClone(value); calls.push('gateway.set'); },
createInitial: () => ({ mode: 'local-vpn' }),
},
serialize: (operation) => {
const result = serialized.then(operation, operation);
serialized = result.then(() => undefined, () => undefined);
return result;
},
serialize,
scheduler: {
setInterval: (callback, intervalMs) => {
const timer = {
@@ -157,383 +124,241 @@ function createHarness(overrides = {}) {
clearInterval: (timer) => { timer.clearCalls += 1; },
},
onRefreshError: overrides.onRefreshError || (() => {}),
};
now: () => new Date('2026-08-08T12:30:00.000Z'),
});
return {
service: createSubscriptionService(dependencies),
service,
calls,
timers,
snapshot: () => structuredClone({ state, cache, config, gatewayAuto, running }),
providerCalls,
advanceBackgroundRevision: (patch = {}) => {
state = { ...state, ...structuredClone(patch), revision: state.revision + 1 };
},
snapshot: () => structuredClone({ state, config, gatewayAuto, running }),
};
}
function assertRestoredSnapshot(actual, expected, label) {
const actualRevision = actual.state.revision;
const expectedRevision = expected.state.revision;
const actualDomain = structuredClone(actual);
const expectedDomain = structuredClone(expected);
delete actualDomain.state.revision;
delete expectedDomain.state.revision;
assert.deepEqual(actualDomain, expectedDomain, label);
assert.ok(actualRevision >= expectedRevision, `${label}: revision moved backwards`);
}
test('add fetches before commit, keeps the runtime, and only the first profile becomes desired', async () => {
const empty = defaultState();
empty.profiles = [];
empty.desiredProfileId = '';
empty.appliedProfileId = '';
empty.appliedServerId = '';
empty.appliedServerSnapshot = null;
empty.connectionDesired = 'stopped';
const harness = createHarness({ state: empty, running: false });
test('subscription import commits source config, clears selection, and leaves runtime stopped', async () => {
const result = await harness.service.addProfile(' Личный ', 'https://new.example/sub', 3);
const after = harness.snapshot();
assert.equal(result.label, 'Личный');
assert.equal(after.state.profiles.length, 1);
assert.equal(after.state.desiredProfileId, result.profileId);
assert.equal(after.state.profiles[0].subscriptionUrl, 'https://new.example/sub');
assert.equal(after.state.profiles[0].desiredServerId, '');
assert.equal(after.running, false);
assert.equal(after.config, 'old-config');
await harness.service.addProfile('Работа', 'https://work.example/sub');
assert.equal(harness.snapshot().state.desiredProfileId, result.profileId);
});
test('rename and server selection are profile-scoped and reject stale or duplicate writes', async () => {
const harness = createHarness();
await harness.service.renameProfile('work', 'Офис', 3);
assert.equal(harness.snapshot().state.profiles.find(({ id }) => id === 'work').label, 'Офис');
const duplicateRevision = harness.snapshot().state.revision;
await assert.rejects(harness.service.renameProfile('work', 'личный'), (error) => error.code === 'PROFILE_NAME_CONFLICT');
assert.equal(harness.snapshot().state.revision, duplicateRevision);
await assert.rejects(harness.service.selectProfileServer('work', 'missing'), (error) => error.code === 'SERVER_NOT_FOUND');
const result = await harness.service.importSubscription('https://new.example/sub');
const snapshot = harness.snapshot();
assert.equal(result.success, true);
assert.equal(result.selectedServerId, '');
assert.equal(snapshot.state.subscriptionUrl, 'https://new.example/sub');
assert.deepEqual(snapshot.state.routeRules, routeRules);
assert.equal(snapshot.state.gatewayAutoEnabled, false);
assert.equal(snapshot.state.connectionDesired, 'stopped');
assert.deepEqual(snapshot.cache.config, { source: true });
assert.equal(snapshot.config, null);
assert.deepEqual(snapshot.gatewayAuto, { mode: 'local-vpn' });
assert.equal(snapshot.running, false);
assert.equal(harness.calls.filter((call) => call === 'runtime.stop').length, 1);
assert.equal(harness.calls.filter((call) => call === 'runtime.start').length, 0);
const state = harness.snapshot().state;
state.profiles.find(({ id }) => id === 'work').servers = [oldServer, nextServer];
state.profiles.find(({ id }) => id === 'work').desiredServerId = 'shared';
const scoped = createHarness({ state });
await scoped.service.selectProfileServer('work', 'next', state.revision);
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'work').desiredServerId, 'next');
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'personal').desiredServerId, 'shared');
});
test('subscription refresh retains selection and restarts only a previously running runtime', async () => {
const retained = { ...oldServer, label: 'Renamed' };
const harness = createHarness({ fetchSubscription: async () => parsed([retained, nextServer]) });
const result = await harness.service.refreshSavedSubscription();
const snapshot = harness.snapshot();
assert.equal(result.selectedServerId, oldServer.id);
assert.equal(snapshot.state.selectedServerId, oldServer.id);
assert.match(snapshot.config, /srv_old/);
assert.equal(snapshot.running, true);
assert.equal(harness.calls.filter((call) => call === 'runtime.stop').length, 0);
assert.equal(harness.calls.filter((call) => call === 'runtime.start').length, 1);
test('refreshing an inactive profile does not mutate config, runtime, or the applied pair', async () => {
const harness = createHarness({ fetchSubscription: async () => parsed('work', [oldServer, nextServer]) });
await harness.service.refreshProfile('work', 3);
const after = harness.snapshot();
assert.equal(after.state.profiles.find(({ id }) => id === 'work').servers.length, 2);
assert.equal(after.state.appliedProfileId, 'personal');
assert.equal(after.state.appliedServerId, 'shared');
assert.equal(after.config, 'old-config');
assert.equal(harness.calls.includes('runtime.start'), false);
});
test('subscription import restores every snapshot on pre-commit failures', async () => {
for (const failurePoint of ['runtimeStop', 'configRemove', 'cacheWrite', 'stateUpdate', 'stateUpdateAfter']) {
const failure = new Error(failurePoint);
const harness = createHarness({ failures: { [failurePoint]: failure } });
const before = harness.snapshot();
await assert.rejects(
harness.service.importSubscription('https://new.example/sub'),
(error) => error === failure,
failurePoint,
);
assertRestoredSnapshot(harness.snapshot(), before, failurePoint);
}
test('provider failure retains the last successful list and records a scoped stale marker', async () => {
const failure = Object.assign(new Error('provider down'), { code: 'PROVIDER_UNAVAILABLE' });
const harness = createHarness({ fetchSubscription: async () => { throw failure; } });
await assert.rejects(harness.service.refreshProfile('work', 3), (error) => error === failure);
const work = harness.snapshot().state.profiles.find(({ id }) => id === 'work');
assert.deepEqual(work.servers, [oldServer]);
assert.equal(work.lastRefreshErrorCode, 'PROVIDER_UNAVAILABLE');
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
});
test('subscription refresh restores snapshots and reapplies the old config on pre-commit failures', async () => {
for (const failurePoint of ['configWrite', 'cacheWrite', 'stateUpdate', 'stateUpdateAfter']) {
const failure = new Error(failurePoint);
const harness = createHarness({
fetchSubscription: async () => parsed([oldServer]),
failures: { [failurePoint]: failure },
test('provider mutations keep admission CAS while tolerating background-only revision bumps', async () => {
let releaseAdd;
let addStarted;
const addFetchStarted = new Promise((resolve) => { addStarted = resolve; });
const adding = createHarness({
fetchSubscription: async () => {
addStarted();
return new Promise((resolve) => { releaseAdd = resolve; });
},
});
const before = harness.snapshot();
const pendingAdd = adding.service.addProfile('Резерв', 'https://reserve.example/sub', 3);
await addFetchStarted;
adding.advanceBackgroundRevision({ gatewayFreshness: 'verified' });
releaseAdd(parsed('reserve'));
const added = await pendingAdd;
assert.equal(adding.snapshot().state.profiles.some(({ id }) => id === added.profileId), true);
assert.equal(adding.snapshot().state.gatewayFreshness, 'verified');
await assert.rejects(
harness.service.refreshSavedSubscription(),
(error) => error === failure,
failurePoint,
);
assertRestoredSnapshot(harness.snapshot(), before, failurePoint);
let rejectRefresh;
let refreshStarted;
const refreshFetchStarted = new Promise((resolve) => { refreshStarted = resolve; });
const refreshing = createHarness({
fetchSubscription: async () => {
refreshStarted();
return new Promise((_resolve, reject) => { rejectRefresh = reject; });
},
});
const providerFailure = Object.assign(new Error('provider down'), { code: 'PROVIDER_UNAVAILABLE' });
const pendingRefresh = refreshing.service.refreshProfile('work', 3);
await refreshFetchStarted;
refreshing.advanceBackgroundRevision({ gatewayFreshness: 'verified' });
rejectRefresh(providerFailure);
await assert.rejects(pendingRefresh, (error) => error === providerFailure);
const refreshedState = refreshing.snapshot().state;
assert.equal(refreshedState.gatewayFreshness, 'verified');
assert.equal(
harness.calls.filter((call) => call === 'runtime.start').length,
failurePoint === 'stateUpdate' || failurePoint === 'stateUpdateAfter' ? 2 : 0,
failurePoint,
refreshedState.profiles.find(({ id }) => id === 'work').lastRefreshErrorCode,
'PROVIDER_UNAVAILABLE',
);
}
});
test('subscription reset restores cache, config, gateway, state, and runtime on removal or commit failure', async () => {
for (const failurePoint of ['configRemove', 'cacheRemove', 'stateUpdate', 'stateUpdateAfter']) {
const failure = new Error(failurePoint);
const harness = createHarness({ failures: { [failurePoint]: failure } });
const before = harness.snapshot();
test('active refresh applies a retained server and keeps last-applied snapshot if it disappears', async () => {
const retained = createHarness({ fetchSubscription: async () => parsed('personal', [oldServer, nextServer]) });
await retained.service.refreshProfile('personal', 3);
assert.ok(retained.calls.includes('config.write'));
assert.ok(retained.calls.includes('runtime.start'));
assert.equal(retained.snapshot().state.appliedServerId, 'shared');
await assert.rejects(
harness.service.resetSavedSubscription(),
(error) => error === failure,
failurePoint,
);
assertRestoredSnapshot(harness.snapshot(), before, failurePoint);
}
const noRuntime = createHarness({ failures: { stateUpdate: new Error('commit') } });
await assert.rejects(noRuntime.service.resetSavedSubscription({ stopRuntime: false }));
assert.equal(noRuntime.calls.includes('runtime.stop'), false);
assert.equal(noRuntime.calls.includes('runtime.start'), false);
const stoppedCleanup = createHarness({ running: false });
await stoppedCleanup.service.resetSavedSubscription();
assert.equal(stoppedCleanup.calls.filter((call) => call === 'runtime.stop').length, 1);
assert.equal(stoppedCleanup.calls.includes('runtime.start'), false);
const removed = createHarness({ fetchSubscription: async () => parsed('personal', [nextServer]) });
await removed.service.refreshProfile('personal', 3);
const after = removed.snapshot();
assert.equal(after.state.profiles[0].desiredServerId, '');
assert.equal(after.state.appliedServerId, 'shared');
assert.deepEqual(after.state.appliedServerSnapshot, oldServer);
assert.equal(removed.calls.includes('config.write'), false);
assert.equal(after.running, true);
});
test('subscription rollback attempts remaining restores and aggregates restoration failures', async () => {
const commitFailure = new Error('commit failed');
const configRestoreFailure = new Error('config restore failed');
const configBroken = createHarness({
fetchSubscription: async () => parsed([oldServer]),
failures: { stateUpdate: commitFailure, configRestore: configRestoreFailure },
});
const beforeConfigBroken = configBroken.snapshot();
await assert.rejects(configBroken.service.refreshSavedSubscription(), (error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [commitFailure, configRestoreFailure]);
return true;
});
assert.equal(configBroken.snapshot().cache.url, beforeConfigBroken.cache.url);
assert.deepEqual(configBroken.snapshot().gatewayAuto, beforeConfigBroken.gatewayAuto);
assert.equal(configBroken.snapshot().running, true);
assert.equal(configBroken.calls.filter((call) => call === 'runtime.start').length, 2);
test('inactive delete is state-only; applied delete requires one stop-and-delete transaction', async () => {
const inactive = createHarness();
await inactive.service.deleteProfile('work', 'delete', 3);
assert.deepEqual(inactive.snapshot().state.profiles.map(({ id }) => id), ['personal']);
assert.equal(inactive.snapshot().config, 'old-config');
assert.equal(inactive.snapshot().running, true);
const removeFailure = new Error('remove failed');
const cacheRestoreFailure = new Error('cache restore failed');
const cacheBroken = createHarness({
failures: { configRemove: removeFailure, cacheWrite: cacheRestoreFailure },
});
const beforeCacheBroken = cacheBroken.snapshot();
await assert.rejects(cacheBroken.service.importSubscription('https://new.example/sub'), (error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [removeFailure, cacheRestoreFailure]);
return true;
});
assert.equal(cacheBroken.snapshot().config, beforeCacheBroken.config);
assert.deepEqual(cacheBroken.snapshot().gatewayAuto, beforeCacheBroken.gatewayAuto);
assert.equal(cacheBroken.snapshot().running, true);
assert.equal(cacheBroken.calls.filter((call) => call === 'runtime.start').length, 1);
const active = createHarness();
await assert.rejects(active.service.deleteProfile('personal', 'delete', 3), (error) => error.code === 'PROFILE_IN_USE');
await active.service.deleteProfile('personal', 'stop-and-delete', 3);
const after = active.snapshot();
assert.deepEqual(after.state.profiles.map(({ id }) => id), ['work']);
assert.equal(after.state.appliedProfileId, '');
assert.equal(after.state.appliedServerSnapshot, null);
assert.equal(after.state.connectionDesired, 'stopped');
assert.equal(after.running, false);
assert.equal(after.config, null);
});
test('failed refresh start rolls back, and failed rollback start reports both causes', async () => {
const applyFailure = new Error('apply failed');
const recovered = createHarness({
fetchSubscription: async () => parsed([oldServer]),
failures: { runtimeStart: [applyFailure] },
});
const before = recovered.snapshot();
await assert.rejects(
recovered.service.refreshSavedSubscription(),
(error) => error === applyFailure,
);
assert.deepEqual(recovered.snapshot(), before);
assert.equal(recovered.calls.filter((call) => call === 'runtime.start').length, 2);
const rollbackFailure = new Error('rollback failed');
const broken = createHarness({
fetchSubscription: async () => parsed([oldServer]),
failures: { runtimeStart: [applyFailure, rollbackFailure] },
});
await assert.rejects(broken.service.refreshSavedSubscription(), (error) => {
assert.equal(error.code, 'PROCESS_START_FAILED');
assert.ok(error.cause instanceof AggregateError);
assert.deepEqual(error.cause.errors, [applyFailure, rollbackFailure]);
return true;
});
test('deleting a pending desired profile leaves the running applied route untouched', async () => {
const state = defaultState();
state.desiredProfileId = 'work';
const harness = createHarness({ state });
await harness.service.deleteProfile('work', 'delete', 3);
const after = harness.snapshot();
assert.equal(after.config, 'old-config');
assert.equal(after.running, true);
assert.deepEqual(after.gatewayAuto, { mode: 'gateway-direct', gatewayId: 'gateway' });
assert.equal(harness.calls.includes('gateway.set'), false);
});
test('subscription refresh coalesces provider work and rejects a stale late result', async () => {
let releaseOld;
const oldResult = new Promise((resolve) => { releaseOld = resolve; });
let providerCalls = 0;
test('auto refresh iterates profiles once, reports scoped failures, and stops idempotently', async () => {
const errors = [];
const failure = Object.assign(new Error('down'), { code: 'PROVIDER_UNAVAILABLE' });
const harness = createHarness({
fetchSubscription: async (url) => {
providerCalls += 1;
return url.includes('old') ? oldResult : parsed();
},
});
const first = harness.service.refreshSavedSubscription();
const second = harness.service.refreshSavedSubscription();
assert.equal(first, second);
assert.equal(providerCalls, 1);
await harness.service.importSubscription('https://new.example/sub');
releaseOld(parsed([oldServer]));
await assert.rejects(first, (error) => error.code === 'STATE_CONFLICT');
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://new.example/sub');
assert.equal(providerCalls, 2);
});
test('subscription generation rejects late success after a newer import of the same URL', async () => {
let releaseRefresh;
const delayed = new Promise((resolve) => { releaseRefresh = resolve; });
let providerCalls = 0;
const harness = createHarness({
fetchSubscription: async () => {
providerCalls += 1;
return providerCalls === 1 ? delayed : parsed([nextServer]);
},
});
const stale = harness.service.refreshSavedSubscription();
await harness.service.importSubscription('https://old.example/sub');
releaseRefresh(parsed([oldServer]));
await assert.rejects(stale, (error) => error.code === 'STATE_CONFLICT');
assert.deepEqual(harness.snapshot().state.servers, [nextServer]);
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://old.example/sub');
});
test('subscription generation prevents a late terminal refresh from forgetting a newer import', async () => {
let rejectRefresh;
const delayed = new Promise((_resolve, reject) => { rejectRefresh = reject; });
let providerCalls = 0;
const harness = createHarness({
fetchSubscription: async () => {
providerCalls += 1;
return providerCalls === 1 ? delayed : parsed([nextServer]);
},
});
const stale = harness.service.refreshSavedSubscription();
await harness.service.importSubscription('https://old.example/sub');
rejectRefresh(new HarborError('SUBSCRIPTION_EXPIRED'));
await assert.rejects(stale, (error) => error.code === 'STATE_CONFLICT');
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://old.example/sub');
assert.deepEqual(harness.snapshot().state.servers, [nextServer]);
assert.ok(harness.snapshot().cache);
});
test('subscription generation prevents a delayed import from resurrecting a forgotten subscription', async () => {
let releaseImport;
const delayed = new Promise((resolve) => { releaseImport = resolve; });
const harness = createHarness({ fetchSubscription: async () => delayed });
const staleImport = harness.service.importSubscription('https://new.example/sub');
await harness.service.resetSavedSubscription();
releaseImport(parsed());
await assert.rejects(staleImport, (error) => error.code === 'STATE_CONFLICT');
assert.equal(Boolean(harness.snapshot().state.subscriptionUrl), false);
assert.equal(harness.snapshot().cache, null);
assert.equal(harness.snapshot().config, null);
});
test('subscription generation lets only the first completed concurrent import commit', async () => {
let releaseFirst;
let releaseSecond;
const firstResult = new Promise((resolve) => { releaseFirst = resolve; });
const secondResult = new Promise((resolve) => { releaseSecond = resolve; });
const harness = createHarness({
fetchSubscription: async (url) => url.includes('first') ? firstResult : secondResult,
});
const first = harness.service.importSubscription('https://first.example/sub');
const second = harness.service.importSubscription('https://second.example/sub');
releaseSecond(parsed([nextServer]));
await second;
releaseFirst(parsed([oldServer]));
await assert.rejects(first, (error) => error.code === 'STATE_CONFLICT');
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://second.example/sub');
assert.deepEqual(harness.snapshot().state.servers, [nextServer]);
});
test('only terminal subscription refresh failures forget saved data', async () => {
const cases = [
['SUBSCRIPTION_EXPIRED', true],
['SUBSCRIPTION_DISABLED', true],
['SUBSCRIPTION_REJECTED', true],
['SUBSCRIPTION_TRAFFIC_EXHAUSTED', false],
['SUBSCRIPTION_INVALID', false],
['PROVIDER_UNAVAILABLE', false],
];
for (const [code, resets] of cases) {
const harness = createHarness({
running: false,
fetchSubscription: async () => { throw new HarborError(code); },
});
await assert.rejects(harness.service.refreshSavedSubscription(), (error) => error.code === code);
assert.equal(Boolean(harness.snapshot().state.subscriptionUrl), !resets, code);
assert.equal(Boolean(harness.snapshot().cache), !resets, code);
}
});
test('subscription scheduler is unrefed, skips empty state, reports errors, and stops idempotently', async () => {
const errors = [];
let providerCalls = 0;
const harness = createHarness({
state: { revision: 0, servers: [], selectedServerId: '', appliedServerId: '', routeRules: [] },
fetchSubscription: async () => {
providerCalls += 1;
throw new HarborError('PROVIDER_UNAVAILABLE');
if (url.includes('work')) throw failure;
return parsed('personal', [oldServer]);
},
onRefreshError: (error) => errors.push(error.code),
});
harness.service.startAutoRefresh(1234);
harness.service.startAutoRefresh(1234);
assert.equal(harness.timers.length, 1);
assert.equal(harness.timers[0].intervalMs, 1234);
assert.equal(harness.timers[0].unrefCalls, 1);
harness.timers[0].callback();
await new Promise(setImmediate);
assert.equal(providerCalls, 0);
const stateful = createHarness({
fetchSubscription: async () => {
providerCalls += 1;
throw new HarborError('PROVIDER_UNAVAILABLE');
},
onRefreshError: (error) => errors.push(error.code),
});
stateful.service.startAutoRefresh(10);
stateful.timers[0].callback();
await new Promise(setImmediate);
await new Promise(setImmediate);
assert.equal(providerCalls, 1);
assert.equal(harness.providerCalls.length, 2);
assert.deepEqual(errors, ['PROVIDER_UNAVAILABLE']);
stateful.service.stopAutoRefresh();
stateful.service.stopAutoRefresh();
assert.equal(stateful.timers[0].clearCalls, 1);
harness.service.stopAutoRefresh();
harness.service.stopAutoRefresh();
assert.equal(harness.timers[0].clearCalls, 1);
});
test('subscription mutation route preserves methods, operation kinds, and response fields', async () => {
const operations = [];
test('profile mutation route scopes every target and keeps bounded legacy shims', async () => {
const calls = [];
const sent = [];
const service = {
importSubscription: async (url) => ({ success: true, imported: url }),
refreshSavedSubscription: async () => ({ success: true, refreshed: true }),
resetSavedSubscription: async () => { operations.push('reset'); },
preflightAddProfile: () => {},
preflightRenameProfile: () => {},
addProfile: async (...args) => ({ success: true, args }),
renameProfile: async (...args) => ({ success: true, args }),
selectProfileServer: async (...args) => ({ success: true, args }),
refreshProfile: async (...args) => ({ success: true, args }),
deleteProfile: async (...args) => ({ success: true, args }),
importSubscription: async (...args) => ({ success: true, args }),
refreshSavedSubscription: async (...args) => ({ success: true, args }),
resetSavedSubscription: async (...args) => ({ success: true, args }),
};
const route = createSubscriptionMutationRoute({
subscriptionService: service,
readBody: async () => ({ url: ' https://new.example/sub ' }),
withOperation: async (kind, operation) => {
operations.push(kind);
return operation();
connection: { activate: async (...args) => ({ success: true, args }) },
readBody: async () => ({
label: 'Работа', url: 'https://work.example/sub', serverId: 'next', mode: 'delete', expectedRevision: 7,
}),
withOperation: async (kind, operation, options = {}) => {
calls.push([kind, options]);
return operation(8);
},
sendState: async (_res, extra = {}) => { sent.push(extra); },
});
const response = {};
for (const [method, url] of [
['POST', '/api/profiles'],
['PATCH', '/api/profiles/work'],
['PUT', '/api/profiles/work/server'],
['POST', '/api/profiles/work/activate'],
['POST', '/api/profiles/work/refresh'],
['DELETE', '/api/profiles/work'],
]) assert.equal(await route.handle({ method, url }, response), true);
assert.equal(await route.handle({ method: 'GET', url: '/api/subscription/fetch' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/subscription/fetch' }, response), true);
assert.equal(await route.handle({ method: 'POST', url: '/api/subscription/refresh' }, response), true);
assert.equal(await route.handle({ method: 'DELETE', url: '/api/subscription' }, response), true);
assert.deepEqual(operations, ['subscription-import', 'subscription-refresh', 'subscription-forget', 'reset']);
assert.deepEqual(sent, [
{ success: true, imported: 'https://new.example/sub' },
{ refreshed: true },
{},
assert.deepEqual(calls.map(([kind]) => kind), [
'profile-add', 'profile-rename', 'profile-select-server', 'profile-activate', 'profile-refresh', 'profile-delete',
]);
});
assert.equal(sent.length, 6);
assert.deepEqual(calls[2][1], { expectedRevision: 7, profileId: 'work', serverId: 'next' });
test('composition root contains no displaced subscription mutation owner', () => {
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(source, /createSubscriptionMutationRoute\(\{/);
assert.match(source, /subscriptionService\.startAutoRefresh\(SUBSCRIPTION_REFRESH_INTERVAL_MS\)/);
assert.doesNotMatch(source, /subscriptionRefreshPromise|subscriptionRefreshTimer|TERMINAL_SUBSCRIPTION_CODES/);
assert.doesNotMatch(source, /req\.url === ['"]\/api\/subscription\/(?:fetch|refresh)['"]/);
assert.doesNotMatch(source, /req\.method === ['"]DELETE['"] && req\.url === ['"]\/api\/subscription['"]/);
assert.doesNotMatch(source, /req\.url === ['"]\/api\/subscription\/\(?:fetch\|refresh\)['"]/);
});
+15 -3
View File
@@ -75,8 +75,20 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
}],
[() => api.subscription.refresh(), '/api/subscription/refresh', { method: 'POST' }],
[() => api.subscription.forget(), '/api/subscription', { method: 'DELETE' }],
[() => api.apply('server-1'), '/api/apply', {
method: 'POST', body: JSON.stringify({ serverId: 'server-1', selectedTag: 'server-1' }),
[() => api.profiles.add('Личный', 'https://sub', 4), '/api/profiles', {
method: 'POST', body: JSON.stringify({ label: 'Личный', url: 'https://sub', expectedRevision: 4 }),
}],
[() => api.profiles.rename('profile-1', 'Работа', 5), '/api/profiles/profile-1', {
method: 'PATCH', body: JSON.stringify({ label: 'Работа', expectedRevision: 5 }),
}],
[() => api.profiles.selectServer('profile-1', 'server-1', 6), '/api/profiles/profile-1/server', {
method: 'PUT', body: JSON.stringify({ serverId: 'server-1', expectedRevision: 6 }),
}],
[() => api.profiles.activate('profile-1', 7), '/api/profiles/profile-1/activate', {
method: 'POST', body: JSON.stringify({ expectedRevision: 7 }),
}],
[() => api.apply('profile-1', 'server-1', 8), '/api/apply', {
method: 'POST', body: JSON.stringify({ profileId: 'profile-1', serverId: 'server-1', expectedRevision: 8 }),
}],
[() => api.gatewayAuto.setEnabled(true), '/api/gateway-auto', {
method: 'POST', body: JSON.stringify({ enabled: true }),
@@ -101,7 +113,7 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
}],
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
[() => api.servers.ping(['one', 'two']), '/api/servers/ping-all', {
[() => api.servers.ping('profile-1', ['one', 'two']), '/api/profiles/profile-1/servers/ping', {
method: 'POST', body: JSON.stringify({ serverIds: ['one', 'two'] }),
}],
];
+1 -1
View File
@@ -20,7 +20,7 @@ test('connection button chooses the only valid client action', () => {
type: 'apply',
serverId: 'srv_nl',
});
assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' });
assert.equal(connectionAction({ configExists: true }), null);
assert.equal(connectionAction({}), null);
});
+5 -4
View File
@@ -13,7 +13,8 @@ const routing = source('features/routing/RoutingFeature.tsx');
const diagnostics = source('features/diagnostics/ConnectivityDiagnosticsPanel.tsx');
test('App owns one stable mapping from typed transport to component actions', () => {
assert.match(app, /const componentActions = \{[\s\S]*validateSubscription: api\.subscription\.validate[\s\S]*listDevices: api\.devices\.list[\s\S]*refreshDevices: api\.devices\.refresh[\s\S]*updateDevice: api\.devices\.update[\s\S]*setDevicePolicy: api\.devices\.setPolicy[\s\S]*pingServers: api\.servers\.ping[\s\S]*runConnectivityDiagnostics: api\.diagnostics\.connectivity[\s\S]*\};/);
assert.match(app, /const componentActions = \{[\s\S]*listDevices: api\.devices\.list[\s\S]*refreshDevices: api\.devices\.refresh[\s\S]*updateDevice: api\.devices\.update[\s\S]*setDevicePolicy: api\.devices\.setPolicy[\s\S]*pingServers: api\.servers\.ping[\s\S]*runConnectivityDiagnostics: api\.diagnostics\.connectivity[\s\S]*\};/);
assert.doesNotMatch(app, /validateSubscription: api\.subscription\.validate/);
assert.equal((app.match(/actions=\{componentActions\}/g) || []).length, 1);
assert.doesNotMatch(app, /componentActions\s*=\s*useMemo|componentActions\s*=\s*\([^)]*\)\s*=>/);
});
@@ -21,8 +22,8 @@ test('App owns one stable mapping from typed transport to component actions', ()
test('presentational components use only injected narrow actions', () => {
const components = [overview, subscription, devices, servers, routing, diagnostics].join('\n');
assert.doesNotMatch(components, /from ['"][^'"]*\/api\/harborClient\.js['"]|\bapi\./);
assert.match(overview, /validateSubscription: actions\.validateSubscription/);
assert.match(subscription, /await validateSubscription\(normalizedUrl, \{ signal: controller\.signal \}\)/);
assert.match(subscription, /isSubscriptionUrlValid\(normalizedUrl\)/);
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController/);
assert.match(overview, /refreshDevices: actions\.refreshDevices/);
assert.match(deviceFeature, /discover \? refreshDevices\(\) : listDevices\(\)/);
assert.match(overview, /<ServerPicker[\s\S]*pingServers=\{actions\.pingServers\}/);
@@ -31,6 +32,6 @@ test('presentational components use only injected narrow actions', () => {
assert.match(overview, /<ConnectivityDiagnosticsPanel[\s\S]*runConnectivityDiagnostics=\{actions\.runConnectivityDiagnostics\}/);
assert.match(deviceFeature, /requestDeviceUpdate\(device\.id, patch, snapshot\.revision\)/);
assert.match(deviceFeature, /setDevicePolicy\(device\.id, mode, snapshot\.revision\)/);
assert.match(servers, /await pingServers\(ids\)/);
assert.match(servers, /await pingServers\(profileId, ids\)/);
assert.match(diagnostics, /await runConnectivityDiagnostics\(customServices, target\)/);
});
+14 -3
View File
@@ -26,17 +26,28 @@ test('connection feature is the sole always-mounted power panel owner', () => {
test('connection feature preserves actions, local preference and opaque neighbor slots', () => {
assert.doesNotMatch(panel, /from ['"][^'"]*\/api\/|\bapi\./);
assert.doesNotMatch(panel, /setInterval|setTimeout|useState\([^)]*state|useReducer/);
assert.match(panel, /connectionAction\(\{ connected, selectedServerId, configExists: configured \}\)/);
assert.match(panel, /action\?\.type === 'stop'[\s\S]*action\?\.type === 'apply'[\s\S]*action\?\.type === 'restart'/);
assert.match(panel, /connectionAction\(\{ connected, selectedServerId \}\)/);
assert.match(panel, /action\?\.type === 'stop'[\s\S]*action\?\.type === 'apply'/);
assert.doesNotMatch(panel, /action\?\.type === 'restart'/);
assert.match(panel, /if \(!await onStop\(\)\) return;[\s\S]*setConfirmingStop\(false\)/);
assert.match(panel, /localStorage\.getItem\(DURATION_MODE_STORAGE_KEY\) === 'words'[\s\S]*localStorage\.setItem\(DURATION_MODE_STORAGE_KEY, nextMode\)/);
assert.match(panel, /client-state-detail[\s\S]*\{routingSlot\}[\s\S]*client-state-copy[\s\S]*\{serverSlot\}[\s\S]*client-proxies[\s\S]*\{statusSlot\}/);
assert.match(page, /routingSlot=\{<RoutingPendingStatus[\s\S]*blocked=\{connectionBlocked\}[\s\S]*onRestart=\{onRestart\}/);
assert.match(routing, /client-route-rules-pending[\s\S]*Перезапустить VPN/);
assert.match(page, /serverSlot=\{isGateway && <div className="client-gateway-route-summary"/);
assert.match(page, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
assert.match(page, /mainIdentity[\s\S]*appliedProfile[\s\S]*appliedServer/);
assert.match(page, /statusSlot=\{<>[\s\S]*InlineError[\s\S]*InlineProgress/);
});
test('gateway-direct is remote-owned even when the local runtime is stopped', () => {
assert.match(page, /const mainIdentity = gatewayDirect[\s\S]*Gateway · сервер не определён[\s\S]*: connected/);
assert.match(page, /const switchIdentity = gatewayDirect[\s\S]*Данные применённого сервера Gateway недоступны/);
assert.match(panel, /const remoteOwned = !isGateway && gatewayDirect/);
assert.match(panel, /const powerUnavailable = remoteOwned \|\| \(!connected && !canStart\)/);
assert.match(panel, /aria-checked=\{connected \|\| remoteOwned\}[\s\S]*disabled=\{blocked \|\| powerUnavailable\}/);
assert.match(panel, /remoteOwned[\s\S]*Подключением управляет Harbor Gateway[\s\S]*Gateway подключён/);
});
test('shared clock, copy feedback and live announcement stay single-owned by the page', () => {
const pageBody = page.slice(page.indexOf('export function ClientOverviewPage'));
assert.equal((page.match(/setInterval\(\(\) => setNow\(Date\.now\(\)\), 1000\)/g) || []).length, 1);
+10 -8
View File
@@ -201,8 +201,10 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
const connectionPanelStart = overview.indexOf('<ConnectionPanel');
assert.match(overview, /const appliedServerId = state\?\.selection\?\.appliedServerId \|\| ''/);
assert.match(overview, /appliedServer\?\.label \|\| 'VPN-сервер не используется'/);
assert.match(overview, /selectedServerId !== appliedServerId[\s\S]*Переключаем на \{desiredServer\.label\}/);
assert.match(overview, /const appliedServer = appliedProfile\?\.servers\.find[\s\S]*state\.selection\.appliedServerSnapshot/);
assert.match(overview, /const mainIdentity = gatewayDirect[\s\S]*: connected[\s\S]*appliedProfile && appliedServer[\s\S]*`\$\{appliedProfile\.label\} · \$\{appliedServer\.label\}`/);
assert.match(overview, /const switchIdentity = gatewayDirect[\s\S]*switchingServer && operationProfile && operationServer[\s\S]*`Переключаем на \$\{operationProfile\.label\} · \$\{operationServer\.label\}`/);
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
assert.match(feature, /const history = globalTraffic\?\.history \|\| \[\][\s\S]*samples=\{history\}[\s\S]*routeLabel="Gateway"[\s\S]*series="speed"/);
assert.match(connection, /<section className=\{`client-power-section[\s\S]*client-state-detail[\s\S]*client-connection-title[\s\S]*\{serverSlot\}[\s\S]*client-proxies/);
@@ -212,13 +214,13 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
assert.match(feature, /feature\.status === 'error' \? feature\.error : null/);
assert.match(overview, /if \(!isGateway && \(!connected \|\| !state\?\.connection\?\.startedAt\)\) return undefined/);
assert.match(feature, /trafficSourceError[\s\S]*Трафик не обновляется · последние данные/);
assert.match(subscription, /client-subscription-drawer\$\{open \? ' is-open' : ''\}/);
assert.match(subscription, /const confirmingDeleteRef = useRef\(confirmingDelete\)[\s\S]*if \(confirmingDeleteRef\.current\) return/);
assert.match(subscription, /requestDelete: \(\) => setConfirmingDelete\(true\)[\s\S]*open=\{feature\.confirmingDelete\}[\s\S]*onCancel=\{feature\.cancelDelete\}/);
assert.match(connection, /aria-label=\{isGateway[\s\S]*Остановить Harbor Connect[\s\S]*Запустить Harbor Connect/);
assert.match(connection, /className=\{`client-power-control\$\{isGateway \? ' client-tooltip-anchor' : ''\}`\}[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Сначала добавьте подписку и выберите сервер/);
assert.match(subscription, /client-drawer client-subscription-drawer\$\{drawerOpen \? ' is-open' : ''\}/);
assert.match(subscription, /const deleteIdRef = useRef\(deleteId\)[\s\S]*if \(deleteIdRef\.current\) return/);
assert.match(subscription, /requestDelete: \(profileId: string\) => setDeleteId\(profileId\)[\s\S]*open=\{Boolean\(feature\.deleteProfile\)\}[\s\S]*onCancel=\{feature\.cancelDelete\}[\s\S]*onConfirm=\{feature\.confirmDelete\}/);
assert.match(connection, /aria-label=\{remoteOwned[\s\S]*isGateway[\s\S]*Остановить Harbor Connect[\s\S]*Запустить Harbor Connect/);
assert.match(connection, /className=\{`client-power-control\$\{powerUnavailable \? ' client-tooltip-anchor' : ''\}`\}[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Подключением управляет Harbor Gateway[\s\S]*Сначала добавьте подписку и выберите сервер/);
assert.match(connection, /const powerButton = <button[\s\S]*className="client-power"[\s\S]*client-power-control[\s\S]*\{brandSlot\}[\s\S]*\{powerButton\}/);
assert.match(overview, /isGateway && <DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
assert.match(overview, /isGateway && hasSubscription && <DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
});
@@ -35,7 +35,7 @@ test('diagnostics feature is the sole owner while the conditional panel keeps re
assert.equal((page.match(/useDiagnosticsFeature\(\)/g) || []).length, 1);
assert.equal((page.match(/<DiagnosticsToggle/g) || []).length, 1);
assert.equal((page.match(/<ConnectivityDiagnosticsPanel/g) || []).length, 1);
assert.match(page, /const diagnosticsAvailable = isGateway \|\| \(hasSubscription && subscriptionContentReady\)/);
assert.match(page, /const diagnosticsAvailable = hasSubscription/);
assert.match(page, /\{diagnosticsAvailable && <ConnectivityDiagnosticsPanel[\s\S]*feature=\{diagnosticsFeature\}/);
assert.match(page, /if \(!diagnosticsAvailable\) diagnosticsFeature\.close\(\)/);
assert.doesNotMatch(page, /diagnosticsOpen|setDiagnosticsOpen|diagnosticsPanelRef|diagnosticsToggleRef|diagnosticsCloseRef|client-diagnostics-toggle/);
+10 -16
View File
@@ -12,7 +12,12 @@ import { createStateSnapshot } from '../../.test-dist/src/shared/contracts/state
const snapshot = (revision, desiredServerId = '', serverIds = ['one', 'two']) => ({
apiVersion: 1,
revision,
selection: { desiredServerId },
profiles: [{
id: 'primary',
desiredServerId,
servers: serverIds.map((id) => ({ id })),
}],
selection: { desiredProfileId: 'primary', desiredServerId },
servers: serverIds.map((id) => ({ id })),
});
@@ -87,21 +92,10 @@ test('an equal revision keeps the current snapshot identity', () => {
assert.equal(next.snapshot.selection.desiredServerId, 'one');
});
test('pending selection survives polling until canonical state acknowledges it', () => {
let state = receive(initialHarborState, snapshot(1, 'one'));
state = harborReducer(state, { type: 'select-server', serverId: 'two' });
state = receive(state, snapshot(2, 'one'));
assert.equal(state.pendingServerId, 'two');
state = receive(state, snapshot(3, 'two'));
assert.equal(state.pendingServerId, '');
});
test('pending selection is cleared when its server disappears', () => {
let state = receive(initialHarborState, snapshot(1, 'one'));
state = harborReducer(state, { type: 'select-server', serverId: 'two' });
assert.equal(receive(state, snapshot(2, 'one', ['one'])).pendingServerId, '');
test('selection has no client-side shadow and follows canonical profile snapshots', () => {
const state = receive(initialHarborState, snapshot(2, 'two'));
assert.equal(Object.hasOwn(state, 'pendingServerId'), false);
assert.equal(state.snapshot.profiles[0].desiredServerId, 'two');
});
test('data invariant: initial control outage is retryable without a fabricated snapshot', () => {
@@ -25,8 +25,8 @@ test('instructions feature is the sole owner behind one public boundary', () =>
test('unconditional controller and conditional panel preserve lifecycle and reset boundaries', () => {
assert.match(page, /const instructionsFeature = useInstructionsFeature\([\s\S]*const diagnosticsAvailable/);
assert.match(page, /\{\(isGateway \|\| \(hasSubscription && subscriptionContentReady\)\) && <InstructionsPanel/);
assert.match(page, /if \(!hasSubscription\) \{[\s\S]*if \(!isGateway\) \{[\s\S]*instructionsFeature\.close\(\)/);
assert.match(page, /\{hasSubscription && <InstructionsPanel/);
assert.match(page, /if \(!hasSubscription\) \{[\s\S]*instructionsFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)/);
assert.doesNotMatch(page, /if \(!instructionsAvailable\)|instructionsAvailable/);
assert.match(feature, /const \[openInstructionId, setOpenInstructionId\] = useState\(''\)/);
assert.match(feature, /function InstructionBlock[\s\S]*const \[copyFeedback, setCopyFeedback\] = useState/);
+10 -22
View File
@@ -1,5 +1,4 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import test from 'node:test';
import {
@@ -21,38 +20,27 @@ test('operation conflicts block domain controls but leave copy and navigation al
}
}
const refreshing = { subscriptionRefresh: { status: 'running' } };
const refreshing = { profileRefresh: { status: 'running', target: 'primary' } };
assert.equal(operationBlocked(refreshing, 'connection'), true);
assert.equal(operationBlocked(refreshing, 'serverApply'), true);
assert.equal(operationBlocked(refreshing, 'subscriptionDelete'), true);
assert.equal(operationBlocked(refreshing, 'copy'), false);
assert.equal(operationBlocked(refreshing, 'navigation'), false);
assert.equal(operationBlocked(refreshing, 'profileDelete'), true);
const applying = { serverApply: { status: 'running' } };
const applying = { serverApply: { status: 'running', target: 'primary:server' } };
assert.equal(operationBlocked(applying, 'connection'), true);
assert.equal(operationBlocked(applying, 'subscriptionRefresh'), true);
assert.equal(operationBlocked(applying, 'copy'), false);
assert.equal(operationBlocked(applying, 'profileRefresh'), true);
});
test('double click shares one in-flight request end to end', async (t) => {
test('double click shares one in-flight request end to end', async () => {
const request = deferred();
let requests = 0;
const server = http.createServer((request, response) => {
requests += 1;
setTimeout(() => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end('{"success":true}');
}, 20);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const registry = createOperationRegistry();
const action = () => fetch(`http://127.0.0.1:${server.address().port}/apply`).then((response) => response.json());
const first = registry.run('serverApply', action);
const second = registry.run('serverApply', action);
const action = () => { requests += 1; return request.promise; };
const first = registry.run('serverApply', action, 'primary:server');
const second = registry.run('serverApply', action, 'primary:server');
assert.equal(second, first);
assert.equal(registry.getSnapshot().serverApply.status, 'running');
request.resolve({ success: true });
assert.deepEqual(await first, { success: true });
assert.equal(requests, 1);
assert.deepEqual(registry.getSnapshot(), {});
+19 -16
View File
@@ -22,7 +22,7 @@ function rule(selector, source = styles) {
return new RegExp(`^${escaped}\\s*\\{([\\s\\S]*?)\\n\\}`, 'm').exec(source)?.[1] || '';
}
test('desktop layout keeps the power control on a symmetric center axis', () => {
test('desktop layout keeps the power control on a symmetric center axis beside the subscription drawer', () => {
const panel = rule('.client-panel');
const power = rule('.client-power-section');
const form = rule('.client-form');
@@ -33,12 +33,11 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
assert.match(form, /position:\s*static/);
assert.match(form, /overflow:\s*visible/);
assert.doesNotMatch(form, /\bleft\s*:|translateX|transition:[^;]*(?:left|width|transform)/);
assert.match(styles, /\.client-panel\.has-subscription \.client-form \{[\s\S]*height:\s*var\(--client-work-height\)/);
assert.match(rule('.client-form-content'), /align-content:\s*start/);
assert.doesNotMatch(styles, /\.client-panel\.has-subscription \.client-form\b/);
assert.doesNotMatch(styles, /\.client-form-content\b/);
assert.match(styles, /--client-power-top:\s*calc\(\(var\(--client-work-height\) - 96px\) \/ 2\)/);
assert.match(styles, /\.client-panel\.has-subscription \{[\s\S]*transform:\s*translateY\(-9vh\)/);
assert.match(styles, /\.client-panel\.has-subscription \.client-power-section \{[\s\S]*height:\s*var\(--client-work-height\);[\s\S]*padding-top:\s*var\(--client-power-top\)/);
assert.match(styles, /\.client-panel\.has-subscription \.client-form-content \{[\s\S]*padding-top:\s*var\(--client-power-top\)/);
assert.match(rule('.client-panel.is-gateway-home'), /min-height:\s*max\(620px, calc\(100dvh - 80px\)\)[\s\S]*grid-template-rows:\s*minmax\(260px, 1fr\) auto[\s\S]*row-gap:\s*36px/);
assert.match(rule('.client-gateway-summary'), /grid-column:\s*1 \/ -1[\s\S]*grid-row:\s*2[\s\S]*justify-self:\s*center[\s\S]*width:\s*min\(860px, calc\(100% - 48px\)\)/);
assert.match(styles, /\.client-power-section\.is-gateway \.client-power-control,[\s\S]*width:\s*84px;[\s\S]*height:\s*84px/);
@@ -49,9 +48,11 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
assert.match(rule('.client-gateway-traffic-chart'), /min-height:\s*180px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-gateway-traffic-chart,[\s\S]*min-height:\s*130px;[\s\S]*height:\s*130px/);
assert.doesNotMatch(rule('.client-gateway-summary'), /background|border|box-shadow/);
assert.match(rule('.client-gateway-route-slot'), /min-height:\s*16px[\s\S]*overflow:\s*hidden[\s\S]*text-overflow:\s*ellipsis[\s\S]*white-space:\s*nowrap/);
assert.match(rule('.client-applied-operation'), /min-height:\s*15px[\s\S]*overflow:\s*hidden[\s\S]*text-overflow:\s*ellipsis[\s\S]*white-space:\s*nowrap/);
assert.match(component, /client-panel\$\{showPower \? '' : ' is-setup'\}[\s\S]*is-gateway-home/);
assert.match(component, /const showPower = isGateway \|\| \(hasSubscription && Boolean\(selectedServerId\)\)/);
assert.match(component, /const showPower = hasSubscription/);
assert.match(component, /\$\{!hasSubscription \? ' is-first-run' : ''\}/);
assert.match(component, /\$\{isGateway && hasSubscription \? ' is-gateway-home' : ''\}/);
});
test('page reload plays one stable startup sequence', () => {
@@ -66,7 +67,7 @@ test('page reload plays one stable startup sequence', () => {
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-shell\.is-intro \.client-panel,[\s\S]*animation: none/);
});
test('server rows scroll without moving the subscription column or showing a scrollbar', () => {
test('server rows scroll inside the subscription drawer without moving the main layout', () => {
const scroll = rule('.client-server-scroll');
const simpleScroll = rule('.client-server-mode-panel.is-simple .client-server-scroll');
const grid = rule('.client-server-grid');
@@ -77,7 +78,8 @@ test('server rows scroll without moving the subscription column or showing a scr
assert.match(simpleScroll, /max-height:\s*none/);
assert.match(simpleScroll, /overflow:\s*visible/);
assert.match(grid, /width:\s*min\(100%, 220px\)/);
assert.match(rule('.client-form-content'), /gap:\s*24px/);
assert.match(rule('.client-drawer'), /overflow-y:\s*auto/);
assert.match(subscription, /client-profile-list[\s\S]*feature\.profiles\.map\(\(profile\) => <ProfileGroup/);
assert.match(rule('.client-server-toolbar-title'), /grid-column:\s*2/);
assert.match(rule('.client-server-mode-toggle'), /grid-row:\s*2/);
assert.match(rule('.client-server-check'), /grid-column:\s*3/);
@@ -101,7 +103,7 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
assert.match(rule('.client-duration-toggle'), /width:\s*min\(290px, 100%\)/);
assert.match(mobile, /\.client-server-toolbar \{[\s\S]*grid-template-rows:\s*44px 44px/);
assert.match(mobile, /\.client-form-content \{[\s\S]*gap:\s*24px/);
assert.match(mobile, /\.client-secondary-menu \{[\s\S]*right:\s*8px/);
assert.match(mobile, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.35\)/);
assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/);
assert.match(styles, /\.client-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/);
@@ -239,13 +241,14 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
});
test('subscription validation waits for the provider and keeps diagnostics below errors', () => {
assert.match(subscription, /validateSubscription\(normalizedUrl/);
assert.match(subscription, /status: 'checking'/);
assert.match(subscription, /status: 'valid'/);
assert.match(subscription, /message: ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message/);
assert.match(subscription, /validationStatus === 'checking' \? '…' : '×'/);
assert.match(subscription, /if \(error\?\.context === 'subscription'\) onDismissError\(\)/);
test('subscription validation is local and provider errors keep stable detail slots', () => {
assert.match(subscription, /const validationStatus = !normalizedUrl[\s\S]*isSubscriptionUrlValid\(normalizedUrl\) \? 'valid' : 'invalid'/);
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController|status: 'checking'/);
assert.match(subscription, /const message = feature\.duplicateLabel[\s\S]*feature\.validationStatus === 'invalid'[\s\S]*ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message[\s\S]*feature\.addError\?\.message/);
assert.match(subscription, /aria-invalid=\{feature\.validationStatus === 'invalid'\}/);
assert.match(subscription, /disabled=\{feature\.addBlocked \|\| !feature\.label\.trim\(\) \|\| feature\.duplicateLabel \|\| feature\.validationStatus !== 'valid'\}/);
assert.match(subscription, /if \(!await onAdd\(normalizedLabel, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
assert.match(subscription, /setUrl: \(value: string\) => \{[\s\S]*onDismissError\(\)/);
assert.match(component, /error\.retry[\s\S]*error\.correlationId/);
assert.match(rule('.client-inline-error.is-subscription small'), /flex-basis:\s*100%/);
assert.match(rule('.client-inline-error.is-subscription small'), /opacity:\s*0\.45/);
+4 -4
View File
@@ -26,8 +26,8 @@ test('server picker has one public feature owner without legacy shims', () => {
assert.match(overview, /import \{ ServerPicker \} from '\.\.\/features\/servers\/index\.js'/);
assert.equal((overview.match(/<ServerPicker/g) || []).length, 1);
assert.doesNotMatch(picker, /from ['"][^'"]*\/api\/|\bapi\./);
assert.match(overview, /const selectedServerId = pendingServerId \|\| state\?\.selection\?\.desiredServerId \|\| ''/);
assert.match(overview, /setPendingServerId\(serverId\);[\s\S]*if \(connected && serverId\) onApply\(serverId\)/);
assert.match(overview, /const selectedServerId = desiredProfile\?\.desiredServerId \|\| ''/);
assert.match(overview, /function selectServer\(profile: ProfileSnapshot, serverId: string\)[\s\S]*onApply\(profile\.id, serverId\)[\s\S]*onSelectProfileServer\(profile\.id, serverId\)/);
});
const fixtures = (count) => Array.from({ length: count }, (_, index) => ({
@@ -70,7 +70,7 @@ test('server picker validates unknown ping payloads before publishing results',
]) {
assert.throws(() => parseServerPingResults(payload), TypeError);
}
assert.match(picker, /parseServerPingResults\(await pingServers\(ids\)\)/);
assert.match(picker, /parseServerPingResults\(await pingServers\(profileId, ids\)\)/);
});
test('server picker checks health only on manual refresh and bounds the result window', () => {
@@ -78,7 +78,7 @@ test('server picker checks health only on manual refresh and bounds the result w
assert.doesNotMatch(picker, /checkVisible\(\);/);
assert.match(picker, /onClick={checkVisible}/);
assert.match(picker, /\{ \.\.\.current\[id\], checking: true \}/);
assert.match(picker, /900 - \(performance\.now\(\) - startedAt\)/);
assert.match(picker, /Math\.max\(900, Math\.ceil\(elapsed \/ 900\) \* 900\)/);
assert.match(picker, /\{ \.\.\.current\[id\], checking: false \}/);
assert.match(picker, /\.slice\(page \* SERVER_RESULT_WINDOW, \(page \+ 1\) \* SERVER_RESULT_WINDOW\)/);
assert.match(picker, /\.slice\(0, 30\)/);
+33 -19
View File
@@ -36,26 +36,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 821,
cascadeEdges: 780,
customProperties: 31,
declarations: 2941,
declarations: 2912,
important: 0,
keyframes: 51,
media: 10,
rules: 888,
variableReferences: 338,
keyframes: 49,
media: 12,
rules: 889,
variableReferences: 330,
},
hashes: {
cascadeEdges: '1ffc2a2f986970988643129ff3e32f19c31bb8b1e018a3a474a976e23adc9776',
cascadeEdges: '12a578504877454d14c734b8ce608766d061d3f8f809a5abf13513a645d8b6aa',
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
declarations: '77314876411a21c88472dc277a59ca24192ca7d38de1ce8f367e7bd1007c5323',
declarations: '297c4387ebbccec796e80474c4d040eba20941d316a77a29d1e72c31d19c02d6',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
keyframes: '0bed7ec3cd3a86ee091cf9b07c081ab2364ac5a17e55e95bc9c4a6430419b019',
ruleDeclarationSequences: 'ce3831db5966264c17fa3af3e10fc6a41398cc619d061fa155ac43b47f0947ea',
selectors: '828cf6e95b03c890e3a969be20eb05be1f70872d6076e8f9a6a38cf6b415d012',
variableReferences: '9b6214da6e8e02b01deb6d5a98fe4c695a1fdaff4f65c936b805d7d5841db04d',
witnesses: 'c34721ec2cb99104eae77841ae88ddf77cde278d15c21675a020f3cfdd13a28a',
duplicateSelectors: 'bea7ed28b2e90fbaa3ae50aaee2346fc9063996fc9b7c585a3a47693a055b783',
keyframes: 'bde9c628dc86cb8a4299d5397eb7a81c5fbc9481136a073130991d3182d0799d',
ruleDeclarationSequences: '7386a7be7efa9f3527d167b885fee0f208f236f6947ba3878a0f4acabd31e7a8',
selectors: '845bd57c761ad3767b5a908fa004623d61dd4cdb838616ae0320093ffa1718eb',
variableReferences: '6755baede1580eefde410ea7237ede30af629fbbabd5bc13fc83012a7e4132b2',
witnesses: 'e5869e1f591f6a559ad9aa1942edcbb67b84ebe5ca6d22b4f6108dcba584a9f5',
},
};
@@ -110,7 +110,7 @@ test('tokens, shared primitives, and feature styles have one explicit owner', ()
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 716);
assert.equal(witnesses.length, 740);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -127,7 +127,7 @@ test('every live production selector has an expanded DOM witness', () => {
]);
});
test('JSX witness expansion follows cross-file components, ReactNode slots, portals, and imperative classes', () => {
test('JSX witness expansion follows cross-file components, render props, ReactNode slots, portals, and imperative classes', () => {
const fixtureWitnesses = createStyleWitnesses([
{
file: '/fixture/Child.tsx',
@@ -142,6 +142,20 @@ test('JSX witness expansion follows cross-file components, ReactNode slots, port
const slot = fixtureWitnesses.find((witness) => witness.classes.includes('slot'));
assert.deepEqual(title?.ancestorClasses, ['child', 'scope']);
assert.deepEqual(slot?.ancestorClasses, ['child', 'scope']);
const renderPropWitnesses = createStyleWitnesses([
{
file: '/fixture/List.tsx',
source: 'export function List({ renderItem }) { return <section className="list">{renderItem()}</section>; }',
},
{
file: '/fixture/App.tsx',
source: 'export function App() { return <main className="scope"><List renderItem={() => <button className="item" />} /></main>; }',
},
]);
assert.deepEqual(
renderPropWitnesses.find((witness) => witness.classes.includes('item'))?.ancestorClasses,
['list', 'scope'],
);
const localBindingWitnesses = createStyleWitnesses([{
file: '/fixture/App.tsx',
source: `export function App() {
@@ -293,8 +307,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-DQr1ElW4.css']);
assert.deepEqual(assets, ['index-COUqegc1.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 110647);
assert.equal(sha256(built), '91bf07a74af3312f9b048fdebd642e811fafc3bb2ec49b55592a35f085c25f41');
assert.equal(built.byteLength, 108951);
assert.equal(sha256(built), '059653e70cd17fb84953e19b8280dcb9b926760c1781fb19382820434f9d1f01');
});
+7 -11
View File
@@ -235,16 +235,6 @@ function dynamicClassBindings(files) {
add(routingSuffix, 'status', [...routingStatusBody.matchAll(/return \['([a-z-]+)'/g)].map((match) => match[1]));
}
const subscriptionSuffix = '/features/subscription/SubscriptionFeature.tsx';
const subscription = bySuffix(subscriptionSuffix);
if (subscription) {
add(subscriptionSuffix, 'validationStatus', literalUnion(
subscription,
/interface SubscriptionValidation[\s\S]+?status: ([^;]+);/,
'SubscriptionValidation.status',
));
}
const connectionSuffix = '/features/connection/ConnectionPanel.tsx';
const connection = bySuffix(connectionSuffix);
if (connection) {
@@ -281,7 +271,13 @@ function bindComponentProps(definition, element, callerEnvironment, dynamicBindi
const node = attribute.value?.type === 'JSXExpressionContainer'
? attribute.value.expression
: attribute.value || { type: 'BooleanLiteral', value: true };
supplied.set(name, { node, environment: callerEnvironment });
supplied.set(name, node.type === 'Identifier' && callerEnvironment.get(node.name)
? callerEnvironment.get(node.name)
: {
callable: ['ArrowFunctionExpression', 'FunctionExpression'].includes(node.type) && producesJsx(node),
node,
environment: callerEnvironment,
});
}
supplied.set('children', { node: element.children, environment: callerEnvironment });
const environment = new Map(dynamicBindings.get(definition.file));
+44 -33
View File
@@ -21,33 +21,39 @@ test('subscription feature is the sole always-mounted lifecycle and view owner',
assert.equal((page.match(/<SubscriptionDeleteDialog/g) || []).length, 1);
assert.doesNotMatch(page, /client-subscription-summary|client-usage-bar|id="delete-subscription"/);
assert.doesNotMatch(page, /subscriptionValidationAttempt|confirmingDeleteRef|previousHasSubscriptionRef|SUBSCRIPTION_REVEAL_DELAY_MS/);
assert.match(feature, /client-subscription-summary/);
assert.match(feature, /client-usage-bar/);
assert.match(feature, /function ProfileGroup/);
assert.match(feature, /client-profile-list/);
assert.match(feature, /id="delete-subscription"/);
});
test('App mutation sequencing and the controlled URL draft stay unchanged', () => {
assert.match(app, /const \[subscriptionUrl, setSubscriptionUrl\] = useState\(''\)/);
assert.match(app, /async function fetchSubscription\(\)[\s\S]*api\.subscription\.fetch\(subscriptionUrl\)[\s\S]*dispatch\(\{ type: 'clear-pending-server' \}\)/);
assert.match(app, /async function forgetSubscription\(\)[\s\S]*setSubscriptionUrl\(''\)[\s\S]*dispatch\(\{ type: 'clear-pending-server' \}\)/);
assert.match(page, /subscriptionUrl,[\s\S]*setSubscriptionUrl,[\s\S]*validateSubscription: actions\.validateSubscription,[\s\S]*onImport: onFetchSubscription,[\s\S]*onRefresh: onRefreshSubscription,[\s\S]*onForget: onForgetSubscription/);
test('App owns profile mutations and always uses the latest canonical revision', () => {
assert.match(app, /const revisionRef = useRef\(0\)/);
assert.match(app, /const hasAcceptedSnapshotRef = useRef\(false\)/);
assert.equal((app.match(/if \(!hasAcceptedSnapshotRef\.current \|\| snapshot\.revision > revisionRef\.current\)/g) || []).length, 2);
assert.equal((app.match(/revisionRef\.current = snapshot\.revision/g) || []).length, 2);
assert.doesNotMatch(app, /if \(state\) revisionRef\.current = state\.revision/);
assert.match(app, /api\.profiles\.add\(label, url, revisionRef\.current\)/);
assert.match(app, /api\.profiles\.rename\(profileId, label, revisionRef\.current\)/);
assert.match(app, /api\.profiles\.refresh\(profileId, revisionRef\.current\)/);
assert.match(app, /api\.profiles\.forget\(profileId, mode, revisionRef\.current\)/);
assert.match(page, /profiles,[\s\S]*onAdd: onAddProfile,[\s\S]*onRename: onRenameProfile,[\s\S]*onRefresh: onRefreshProfile,[\s\S]*onForget: onForgetProfile/);
assert.doesNotMatch(feature, /from ['"][^'"]*\/api\/|\bapi\./);
assert.doesNotMatch(feature, /ServerPicker|InlineError|InlineProgress|ConnectionPanel|DevicesPanel|DiagnosticsPanel/);
assert.doesNotMatch(feature, /<ServerPicker|<InlineError|<InlineProgress|<ConnectionPanel|<DevicesPanel|<DiagnosticsPanel/);
});
test('validation, reveal, refresh, usage and drawer timing remain feature-owned', () => {
assert.match(feature, /setTimeout\(async \(\) => \{[\s\S]*await validateSubscription\(normalizedUrl, \{ signal: controller\.signal \}\)[\s\S]*\}, 300\)/);
assert.match(feature, /normalizeRequestError\(caught\)[\s\S]*requestError\.name === 'AbortError'[\s\S]*controller\.abort\(\)/);
assert.match(feature, /currentValidation\?\.error[\s\S]*\|\| localError[\s\S]*error\?\.context === 'subscription'/);
assert.match(feature, /retry: requestError\.retryable[\s\S]*setValidationAttempt/);
assert.match(feature, /SUBSCRIPTION_REVEAL_DELAY_MS = 1350/);
assert.match(feature, /previouslyHadSubscription[\s\S]*prefers-reduced-motion: reduce[\s\S]*setTimeout\(\(\) => setContentReady\(true\), SUBSCRIPTION_REVEAL_DELAY_MS\)/);
assert.match(feature, /if \(!hasSubscription\) return undefined;[\s\S]*onRefresh\(\);[\s\S]*\}, \[hasSubscription\]\)/);
assert.match(feature, /setTimeout\(\(\) => setEditing\(false\), 5000\)/);
assert.match(feature, /requestAnimationFrame\(tick\)[\s\S]*cancelAnimationFrame\(frame\)/);
assert.match(feature, /420 \+ Math\.min\(7, Math\.max\(0, serverCount - 1\)\) \* 90/);
test('local validation, scoped refresh and drawer focus remain feature-owned', () => {
assert.match(feature, /isSubscriptionUrlValid\(normalizedUrl\)/);
assert.doesNotMatch(feature, /validateSubscription\(|AbortController|setTimeout\(async/);
assert.match(feature, /async function refresh\(profileId: string\)/);
assert.match(feature, /Math\.max\(900, Math\.ceil\(elapsed \/ 900\) \* 900\)/);
assert.match(feature, /if \(confirmingDeleteRef\.current\) return;[\s\S]*event\.type === 'keydown'[\s\S]*toggleRef\.current\?\.focus\(\)/);
assert.match(feature, /previousProfileCountRef\.current === 0 && profiles\.length > 0[\s\S]*setOpen\(true\)[\s\S]*setExpanded/);
assert.match(feature, /previousProfileCountRef\.current > 0\) setOpen\(false\)/);
assert.match(feature, /if \(!adding \|\| \(profiles\.length > 0 && !open\)\) return undefined/);
assert.match(feature, /if \(deleteIdRef\.current\) return;[\s\S]*toggleRef\.current\?\.focus\(\)/);
assert.match(feature, /const invoker = addInvokerRef\.current[\s\S]*if \(invoker\) requestAnimationFrame\(\(\) => invoker\.focus\(\)\)/);
assert.match(feature, /showAdd: \(\) => \{[\s\S]*addInvokerRef\.current = document\.activeElement instanceof HTMLElement/);
assert.match(feature, /function cancelRename\(\)[\s\S]*client-profile-menu-\$\{profileId\}[\s\S]*\.focus\(\)/);
assert.match(feature, /id=\{`client-profile-menu-\$\{profile\.id\}`\}/);
});
test('validation rejection parser preserves structured errors and normalizes non-objects', () => {
@@ -79,22 +85,27 @@ test('validation rejection parser preserves structured errors and normalizes non
});
test('feature keeps exact slots, truthy closes and subscription DOM order', () => {
assert.match(feature, /if \(!await onImport\(\)\) return;[\s\S]*setSubscriptionUrl\(''\)[\s\S]*setEditing\(false\)/);
assert.match(feature, /if \(!await onForget\(\)\) return;[\s\S]*setConfirmingDelete\(false\)/);
assert.match(feature, /client-subscription-summary[\s\S]*client-subscription-edit[\s\S]*\{statusSlot\}[\s\S]*client-usage[\s\S]*\{serverSlot\}/);
assert.match(page, /<SubscriptionPanel[\s\S]*statusSlot=\{<>[\s\S]*InlineError[\s\S]*InlineProgress[\s\S]*serverSlot=\{hasSubscription[\s\S]*<ServerPicker/);
assert.match(feature, /if \(!await onAdd\(normalizedLabel, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
assert.match(feature, /if \(!await onForget\(deleteProfile\.id, deleteStopsVpn \? 'stop-and-delete' : 'delete'\)\) return;[\s\S]*setDeleteId\(''\)/);
assert.match(feature, /client-profiles-operation[\s\S]*client-profiles-current[\s\S]*\{statusSlot\}[\s\S]*client-profile-list/);
assert.match(feature, /const drawerOpen = feature\.open && feature\.profiles\.length > 0/);
assert.match(feature, /feature\.profiles\.length === 0 && <div className="client-form client-subscription-first-run"/);
assert.match(page, /<SubscriptionPanel[\s\S]*statusSlot=\{<>[\s\S]*InlineError[\s\S]*renderServerPicker=[\s\S]*<ServerPicker/);
assert.doesNotMatch(page, /<InlineProgress[^>]*context="subscription"/);
assert.match(page, /<SubscriptionToggle[\s\S]*subscriptionFeature\.toggle\(\)/);
assert.match(page, /subscriptionFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)/);
assert.doesNotMatch(feature, /setInterval|copyText|client-live-region/);
});
test('saved subscription stays one clear shared presentation', () => {
assert.equal((feature.match(/client-subscription-summary/g) || []).length, 1);
assert.match(feature, /client-subscription-heading[\s\S]*client-subscription-status[\s\S]*Сохранена[\s\S]*client-subscription-actions[\s\S]*client-subscription-refresh[\s\S]*client-subscription-delete/);
assert.match(feature, /client-subscription-domain-button[\s\S]*client-subscription-label">Подписка[\s\S]*subscriptionDomain\(subscription\?\.host\)/);
assert.match(feature, /client-usage-summary[\s\S]*Использовано[\s\S]* из [\s\S]*client-usage-bar[\s\S]*client-usage-details[\s\S]*Действует до[\s\S]*subscriptionDaysLeft/);
assert.doesNotMatch(feature, /role="tab"|client-subscription-card|subscriptions\.map\(/);
assert.match(styles, /\.client-subscription-heading\s*\{[\s\S]*grid-template-columns:\s*64px minmax\(0, 1fr\) 64px/);
assert.match(styles, /\.client-usage\s*\{[\s\S]*min-height:\s*76px/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-usage-summary > strong[\s\S]*transition:\s*none/);
test('profiles render as flat accordion groups with scoped controls', () => {
assert.match(feature, /feature\.profiles\.map\(\(profile\) => <ProfileGroup/);
assert.match(feature, /aria-expanded=\{expanded\}/);
assert.match(feature, /profile\.subscription\.status === 'stale'[\s\S]*profile\.subscription\.fetchedAt/);
assert.match(feature, /feature\.operations\.profileRefresh\?\.target === profile\.id/);
assert.match(feature, /const currentLabel = feature\.gatewayDirect[\s\S]*Gateway · сервер не определён/);
assert.match(feature, /const desired = !feature\.connected[\s\S]*&& !feature\.gatewayDirect/);
assert.match(styles, /\.client-profile-group/);
assert.match(styles, /\.client-profile-body/);
assert.match(styles, /\.client-profile-refresh\.is-refreshing/);
assert.doesNotMatch(feature, /client-subscription-card|role="tab"/);
});