Skip to content

Commit e8880f3

Browse files
committed
fix: preserve node relationships across refresh
1 parent 4a69b49 commit e8880f3

14 files changed

Lines changed: 472 additions & 42 deletions

local/src/lib/auto-update-service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ describe("local subscription auto update service", () => {
104104
});
105105
mocks.prepareRefreshCacheResult.mockReturnValue({
106106
ok: true,
107+
refreshedConfig: { rules: [], sources: [{ url: "https://airport.example/sub" }] },
107108
cacheEntry: { nodes: [{ name: "A" }], subscriptionInfo: { upload: 1 } },
108109
nodeCount: 1,
109110
});

local/src/lib/auto-update-service.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,13 @@ async function completeSuccess(params: {
187187
maxNodesPerSubscription: MAX_NODES_PER_SUBSCRIPTION,
188188
});
189189
if (decision.kind !== "success") throw new Error(`Unexpected refresh completion decision: ${decision.kind}`);
190-
const config = { ...params.prepared.config, sources: params.prepared.snapshot.savedSources };
191-
192190
const persisted = await writeAutoUpdateState(
193191
params.subscription.id,
194192
params.subscription.updatedAt,
195193
decision.nextAutoUpdateState.state,
196194
{
197195
encryptedNodes: encryptJson(refreshResult.cacheEntry.nodes),
198-
encryptedConfig: encryptJson(config),
196+
encryptedConfig: encryptJson(refreshResult.refreshedConfig),
199197
encryptedSubscriptionInfo: encryptJson(refreshResult.cacheEntry.subscriptionInfo),
200198
lastUpdatedAt: cachedAt,
201199
cacheExpiresAt: buildSubscriptionCacheExpiry(cachedAt),

local/src/lib/subscription-service.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,11 @@ describe("local subscription service", () => {
153153
beforeEach(() => {
154154
vi.clearAllMocks();
155155
mocks.getAppUrl.mockReturnValue("http://127.0.0.1:3001");
156-
mocks.prepareRefreshCacheResult.mockReturnValue({ ok: true, nodeCount: 1 });
156+
mocks.prepareRefreshCacheResult.mockReturnValue({
157+
ok: true,
158+
nodeCount: 1,
159+
refreshedConfig: { sources: [{ id: "source-1", type: "url", content: "https://example.com/sub" }] },
160+
});
157161
mocks.refreshNodeSnapshot.mockResolvedValue({
158162
nodes: [node("Fresh")],
159163
savedSources: [{ id: "source-1", type: "url", content: "https://example.com/sub" }],

local/src/lib/subscription-service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ async function persistRefreshSuccess(params: {
358358
where: { id: params.subscriptionId, updatedAt: params.expectedUpdatedAt },
359359
data: {
360360
encryptedNodes: encryptJson(params.snapshot.nodes),
361-
encryptedConfig: encryptJson({ ...params.config, sources: params.snapshot.savedSources }),
361+
encryptedConfig: encryptJson(params.config),
362362
encryptedSubscriptionInfo: encryptJson(params.snapshot.subscriptionInfo),
363363
lastUpdatedAt: params.cachedAt,
364364
cacheExpiresAt: buildSubscriptionCacheExpiry(params.cachedAt),
@@ -407,7 +407,7 @@ export async function refreshSubscription(ownerId: string, id: string) {
407407
subscriptionId: row.id,
408408
expectedUpdatedAt: row.updatedAt,
409409
snapshot,
410-
config: secrets.config,
410+
config: refreshResult.refreshedConfig,
411411
cachedAt,
412412
});
413413
if (!persisted) {
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { ParsedNode } from "../types/node";
3+
import {
4+
composeNodeNameRenameMaps,
5+
reconcileNodeNameReferences,
6+
} from "./node-name-references";
7+
8+
function node(name: string): ParsedNode {
9+
return { name, type: "ss", server: "example.com", port: 443, cipher: "aes-128-gcm", password: "x" };
10+
}
11+
12+
describe("node name references", () => {
13+
it("composes sequential renames transitively", () => {
14+
expect(
15+
Array.from(
16+
composeNodeNameRenameMaps(
17+
new Map([["Old", "Middle"]]),
18+
new Map([["Middle", "New"]])
19+
)
20+
)
21+
).toEqual([
22+
["Old", "New"],
23+
["Middle", "New"],
24+
]);
25+
});
26+
27+
it("reconciles listener, dialer, and advanced node references", () => {
28+
const config = {
29+
untouched: true,
30+
listenerPorts: { Old: 12000, Missing: 12001 },
31+
dialerProxyGroups: [
32+
{
33+
id: "chain",
34+
name: "Chain",
35+
relayNodes: ["DIRECT", "Old", "Old", "Missing"],
36+
targetNodes: ["Old", "Missing"],
37+
},
38+
],
39+
proxyGroupAdvanced: {
40+
auto: {
41+
extraMembers: [{ kind: "node", name: "Old" }, { kind: "direct" }],
42+
excludedMembers: [{ kind: "node", name: "Missing" }],
43+
memberOrder: [{ kind: "node", name: "Old" }, { kind: "node", name: "Old" }],
44+
},
45+
},
46+
};
47+
48+
expect(
49+
reconcileNodeNameReferences(config, {
50+
nodes: [node("New")],
51+
renameMap: new Map([["Old", "New"]]),
52+
})
53+
).toEqual({
54+
untouched: true,
55+
listenerPorts: { New: 12000 },
56+
dialerProxyGroups: [
57+
{
58+
id: "chain",
59+
name: "Chain",
60+
relayNodes: ["DIRECT", "New"],
61+
targetNodes: ["New"],
62+
},
63+
],
64+
proxyGroupAdvanced: {
65+
auto: {
66+
extraMembers: [{ kind: "node", name: "New" }, { kind: "direct" }],
67+
excludedMembers: [],
68+
memberOrder: [{ kind: "node", name: "New" }],
69+
},
70+
},
71+
});
72+
});
73+
74+
it("keeps references for nodes hidden only by the name filter", () => {
75+
const config = {
76+
listenerPorts: { Hidden: 12000 },
77+
dialerProxyGroups: [{ relayNodes: ["Hidden"], targetNodes: ["Hidden"] }],
78+
};
79+
expect(reconcileNodeNameReferences(config, { nodes: [node("Hidden")] })).toEqual(config);
80+
});
81+
82+
it("normalizes object rename maps and ignores blank or self mappings", () => {
83+
expect(
84+
Array.from(
85+
composeNodeNameRenameMaps(
86+
{ " Old ": " Middle ", " ": "Ignored", Same: "Same" },
87+
{ Middle: "New", New: "New", Empty: " " }
88+
)
89+
)
90+
).toEqual([
91+
["Old", "New"],
92+
["Middle", "New"],
93+
]);
94+
expect(Array.from(composeNodeNameRenameMaps())).toEqual([]);
95+
expect(Array.from(composeNodeNameRenameMaps({ A: "B" }, { B: "A" }))).toEqual([["B", "A"]]);
96+
expect(
97+
Array.from(
98+
composeNodeNameRenameMaps(
99+
{ Old: "Middle", Middle: "Final", Shared: "Existing" },
100+
{ Shared: "Replacement" }
101+
)
102+
)
103+
).toEqual([
104+
["Old", "Final"],
105+
["Middle", "Final"],
106+
["Shared", "Existing"],
107+
]);
108+
});
109+
110+
it("preserves unknown shapes while pruning invalid listener values", () => {
111+
const unknownGroup = null;
112+
expect(
113+
reconcileNodeNameReferences(
114+
{
115+
listenerPorts: { Valid: 1, Float: 1.5, Low: 0, High: 65536, Text: "12000" },
116+
dialerProxyGroups: [
117+
unknownGroup,
118+
{
119+
relayNodes: ["", 7, "DIRECT", "DIRECT", "Valid"],
120+
targetNodes: "legacy",
121+
},
122+
],
123+
proxyGroupAdvanced: {
124+
invalid: null,
125+
valid: {
126+
extraMembers: "legacy",
127+
excludedMembers: [null, { kind: "node" }, { kind: "module", id: "auto" }],
128+
memberOrder: [{ kind: "node", name: "Valid" }],
129+
},
130+
},
131+
},
132+
{ nodes: [node("Valid"), node(" ")] }
133+
)
134+
).toEqual({
135+
listenerPorts: { Valid: 1 },
136+
dialerProxyGroups: [
137+
unknownGroup,
138+
{
139+
relayNodes: [7, "DIRECT", "Valid"],
140+
targetNodes: "legacy",
141+
},
142+
],
143+
proxyGroupAdvanced: {
144+
invalid: null,
145+
valid: {
146+
extraMembers: "legacy",
147+
excludedMembers: [null, { kind: "node" }, { kind: "module", id: "auto" }],
148+
memberOrder: [{ kind: "node", name: "Valid" }],
149+
},
150+
},
151+
});
152+
});
153+
154+
it("leaves absent or non-record relationship sections untouched", () => {
155+
expect(reconcileNodeNameReferences({ unrelated: true }, { nodes: [] })).toEqual({ unrelated: true });
156+
expect(
157+
reconcileNodeNameReferences(
158+
{ listenerPorts: null, dialerProxyGroups: "legacy", proxyGroupAdvanced: [] },
159+
{ nodes: [] }
160+
)
161+
).toEqual({ listenerPorts: null, dialerProxyGroups: "legacy", proxyGroupAdvanced: [] });
162+
});
163+
});
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import type { ParsedNode } from "../types/node";
2+
3+
export type NodeNameRenameMap = ReadonlyMap<string, string> | Readonly<Record<string, string>>;
4+
5+
type ReconcileNodeNameReferencesOptions = {
6+
nodes: ParsedNode[];
7+
renameMap?: NodeNameRenameMap;
8+
};
9+
10+
function isRecord(value: unknown): value is Record<string, unknown> {
11+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
12+
}
13+
14+
function toRenameMap(value?: NodeNameRenameMap): Map<string, string> {
15+
const entries = value instanceof Map ? value.entries() : Object.entries(value ?? {});
16+
const out = new Map<string, string>();
17+
for (const [rawFrom, rawTo] of entries) {
18+
const from = rawFrom.trim();
19+
const to = rawTo.trim();
20+
if (!from || !to || from === to) continue;
21+
out.set(from, to);
22+
}
23+
return out;
24+
}
25+
26+
function resolveRenamedNodeName(name: string, renameMap: ReadonlyMap<string, string>): string {
27+
let current = name;
28+
const visited = new Set<string>();
29+
while (!visited.has(current)) {
30+
visited.add(current);
31+
const next = renameMap.get(current);
32+
if (!next) break;
33+
current = next;
34+
}
35+
return current;
36+
}
37+
38+
export function composeNodeNameRenameMaps(
39+
existing?: NodeNameRenameMap,
40+
next?: NodeNameRenameMap
41+
): Map<string, string> {
42+
const existingMap = toRenameMap(existing);
43+
const nextMap = toRenameMap(next);
44+
const out = new Map<string, string>();
45+
46+
for (const [from, to] of existingMap) {
47+
const resolved = resolveRenamedNodeName(resolveRenamedNodeName(to, existingMap), nextMap);
48+
if (from !== resolved) out.set(from, resolved);
49+
}
50+
for (const [from, to] of nextMap) {
51+
if (out.has(from)) continue;
52+
const resolved = resolveRenamedNodeName(to, nextMap);
53+
out.set(from, resolved);
54+
}
55+
56+
return out;
57+
}
58+
59+
function remapNameList(
60+
value: unknown,
61+
renameMap: ReadonlyMap<string, string>,
62+
availableNames: ReadonlySet<string>,
63+
options: { keepDirect?: boolean } = {}
64+
): unknown {
65+
if (!Array.isArray(value)) return value;
66+
const out: unknown[] = [];
67+
const seenNames = new Set<string>();
68+
for (const item of value) {
69+
if (typeof item !== "string") {
70+
out.push(item);
71+
continue;
72+
}
73+
const name = item.trim();
74+
if (!name) continue;
75+
if (options.keepDirect && name === "DIRECT") {
76+
if (!seenNames.has(name)) out.push(name);
77+
seenNames.add(name);
78+
continue;
79+
}
80+
const nextName = resolveRenamedNodeName(name, renameMap);
81+
if (!availableNames.has(nextName) || seenNames.has(nextName)) continue;
82+
seenNames.add(nextName);
83+
out.push(nextName);
84+
}
85+
return out;
86+
}
87+
88+
function remapAdvancedMemberList(
89+
value: unknown,
90+
renameMap: ReadonlyMap<string, string>,
91+
availableNames: ReadonlySet<string>
92+
): unknown {
93+
if (!Array.isArray(value)) return value;
94+
const out: unknown[] = [];
95+
const seenNodeNames = new Set<string>();
96+
for (const item of value) {
97+
if (!isRecord(item) || item.kind !== "node" || typeof item.name !== "string") {
98+
out.push(item);
99+
continue;
100+
}
101+
const nextName = resolveRenamedNodeName(item.name.trim(), renameMap);
102+
if (!nextName || !availableNames.has(nextName) || seenNodeNames.has(nextName)) continue;
103+
seenNodeNames.add(nextName);
104+
out.push(nextName === item.name ? item : { ...item, name: nextName });
105+
}
106+
return out;
107+
}
108+
109+
function remapProxyGroupAdvanced(
110+
value: unknown,
111+
renameMap: ReadonlyMap<string, string>,
112+
availableNames: ReadonlySet<string>
113+
): unknown {
114+
if (!isRecord(value)) return value;
115+
return Object.fromEntries(
116+
Object.entries(value).map(([groupId, rawAdvanced]) => {
117+
if (!isRecord(rawAdvanced)) return [groupId, rawAdvanced];
118+
return [
119+
groupId,
120+
{
121+
...rawAdvanced,
122+
...(Object.hasOwn(rawAdvanced, "extraMembers")
123+
? { extraMembers: remapAdvancedMemberList(rawAdvanced.extraMembers, renameMap, availableNames) }
124+
: {}),
125+
...(Object.hasOwn(rawAdvanced, "excludedMembers")
126+
? { excludedMembers: remapAdvancedMemberList(rawAdvanced.excludedMembers, renameMap, availableNames) }
127+
: {}),
128+
...(Object.hasOwn(rawAdvanced, "memberOrder")
129+
? { memberOrder: remapAdvancedMemberList(rawAdvanced.memberOrder, renameMap, availableNames) }
130+
: {}),
131+
},
132+
];
133+
})
134+
);
135+
}
136+
137+
export function reconcileNodeNameReferences<T extends object>(
138+
config: T,
139+
options: ReconcileNodeNameReferencesOptions
140+
): T {
141+
const rawConfig = config as Record<string, unknown>;
142+
const renameMap = toRenameMap(options.renameMap);
143+
const availableNames = new Set(options.nodes.map((node) => node.name.trim()).filter(Boolean));
144+
145+
const listenerPorts = isRecord(rawConfig.listenerPorts)
146+
? Object.fromEntries(
147+
Object.entries(rawConfig.listenerPorts)
148+
.map(([name, port]) => [resolveRenamedNodeName(name, renameMap), port] as const)
149+
.filter(
150+
([name, port]) =>
151+
availableNames.has(name) &&
152+
typeof port === "number" &&
153+
Number.isInteger(port) &&
154+
port >= 1 &&
155+
port <= 65535
156+
)
157+
)
158+
: rawConfig.listenerPorts;
159+
160+
const dialerProxyGroups = Array.isArray(rawConfig.dialerProxyGroups)
161+
? rawConfig.dialerProxyGroups.map((rawGroup) => {
162+
if (!isRecord(rawGroup)) return rawGroup;
163+
return {
164+
...rawGroup,
165+
relayNodes: remapNameList(rawGroup.relayNodes, renameMap, availableNames, { keepDirect: true }),
166+
targetNodes: remapNameList(rawGroup.targetNodes, renameMap, availableNames),
167+
};
168+
})
169+
: rawConfig.dialerProxyGroups;
170+
171+
return {
172+
...rawConfig,
173+
...(Object.hasOwn(rawConfig, "listenerPorts") ? { listenerPorts } : {}),
174+
...(Object.hasOwn(rawConfig, "dialerProxyGroups") ? { dialerProxyGroups } : {}),
175+
...(Object.hasOwn(rawConfig, "proxyGroupAdvanced")
176+
? { proxyGroupAdvanced: remapProxyGroupAdvanced(rawConfig.proxyGroupAdvanced, renameMap, availableNames) }
177+
: {}),
178+
} as T;
179+
}

0 commit comments

Comments
 (0)