Skip to content

Commit 54e268d

Browse files
committed
feat(sync): align SyncHub production endpoint
1 parent 4eb3deb commit 54e268d

5 files changed

Lines changed: 82 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ LatestNews 是一个以中文资讯聚合为核心的实时阅读应用,提供
2727
- 提供 v1 只读 API、节点清单、JSON Feed/RSS 和诊断导出,便于外部工具接入
2828
- 支持安装为 PWA,并提供离线与更新反馈
2929
- 支持缓存、限频抓取与登录用户强制刷新
30+
- 支持通过 `https://sync.likanug.app` 和 LatestNews API Key 同步阅读历史、收藏与偏好,也可覆盖服务地址接入自建 SyncHub
3031

3132
## 技术栈
3233

shared/synchub-contract.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const defaultSyncHubEndpoint = "https://sync.likanug.app";
2+
3+
export const latestNewsSyncHubCollections = ["reading-history", "favorites", "preferences"] as const;
4+
5+
export type LatestNewsSyncHubCollection = (typeof latestNewsSyncHubCollections)[number];
6+
7+
export function normalizeSyncHubEndpoint(value: string) {
8+
return value.trim().replace(/\/+$/, "") || defaultSyncHubEndpoint;
9+
}
10+
11+
export function latestNewsSyncHubURL(endpoint: string, collection: LatestNewsSyncHubCollection) {
12+
return `${normalizeSyncHubEndpoint(endpoint)}/api/v1/metadata/latestnews/${collection}`;
13+
}
14+
15+
export function syncHubHeaders(apiKey: string) {
16+
return {
17+
"Content-Type": "application/json",
18+
"X-API-Key": apiKey.trim(),
19+
};
20+
}

src/routes/history.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import { useReadingState } from "~/hooks/useReadingState";
1414
import { getReadingStateList } from "@shared/reading-state";
1515
import { formatReadingHistoryExport } from "@shared/history-export";
1616
import { filterReadingHistory, hasReadingHistoryFilters } from "@shared/history-filter";
17-
import { getSyncHubConfig, saveSyncHubConfig, clearSyncHubConfig } from "~/services/synchub.service";
17+
import {
18+
getSyncHubConfig,
19+
saveSyncHubConfig,
20+
clearSyncHubConfig,
21+
defaultSyncHubEndpoint,
22+
} from "~/services/synchub.service";
1823

1924
export const Route = createFileRoute("/history")({
2025
component: HistoryPage,
@@ -173,7 +178,7 @@ function HistoryPage() {
173178
<input
174179
value={syncHubEndpoint}
175180
onChange={(event) => setSyncHubEndpoint(event.target.value)}
176-
placeholder="SyncHub 服务地址"
181+
placeholder={defaultSyncHubEndpoint}
177182
className="min-w-0 rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm outline-none focus:border-cyan-500 dark:border-zinc-700 dark:bg-zinc-800"
178183
/>
179184
<input
@@ -192,11 +197,13 @@ function HistoryPage() {
192197
>
193198
保存同步
194199
</button>
195-
{(syncHubEndpoint || syncHubApiKey) && (
200+
{(syncHubEndpoint !== defaultSyncHubEndpoint || syncHubApiKey) && (
196201
<button
197202
type="button"
198203
onClick={() => {
199204
clearSyncHubConfig();
205+
setSyncHubEndpoint(defaultSyncHubEndpoint);
206+
setSyncHubApiKey("");
200207
window.location.reload();
201208
}}
202209
className="rounded-xl border border-zinc-200 px-3 py-2 text-sm dark:border-zinc-700"

src/services/synchub.service.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import type { ColorScheme } from "~/hooks/useDark";
22
import type { HistoryItem } from "~/atoms/historyAtom";
33
import type { PrimitiveMetadata } from "@shared/types";
44
import type { ReadingState } from "@shared/reading-state";
5+
import type { LatestNewsSyncHubCollection } from "@shared/synchub-contract";
6+
7+
import {
8+
syncHubHeaders,
9+
latestNewsSyncHubURL,
10+
defaultSyncHubEndpoint,
11+
normalizeSyncHubEndpoint,
12+
} from "@shared/synchub-contract";
13+
14+
export { defaultSyncHubEndpoint } from "@shared/synchub-contract";
515

616
export interface UserPreferences {
717
colorScheme: ColorScheme;
@@ -12,29 +22,32 @@ export interface SyncHubConfig {
1222
endpoint: string;
1323
apiKey: string;
1424
}
15-
const storageKey = "latestnews-synchub-sync";
1625

17-
function endpoint(value: string) {
18-
return value.trim().replace(/\/+$/, "");
19-
}
26+
const storageKey = "latestnews-synchub-sync";
2027

2128
export function getSyncHubConfig(): SyncHubConfig {
2229
try {
2330
const raw = localStorage.getItem(storageKey);
2431
const value = raw ? (JSON.parse(raw) as Partial<SyncHubConfig>) : {};
2532
return {
26-
endpoint: typeof value.endpoint === "string" ? endpoint(value.endpoint) : "",
33+
endpoint:
34+
typeof value.endpoint === "string" && value.endpoint.trim()
35+
? normalizeSyncHubEndpoint(value.endpoint)
36+
: defaultSyncHubEndpoint,
2737
apiKey: typeof value.apiKey === "string" ? value.apiKey.trim() : "",
2838
};
2939
} catch {
30-
return { endpoint: "", apiKey: "" };
40+
return { endpoint: defaultSyncHubEndpoint, apiKey: "" };
3141
}
3242
}
3343

3444
export function saveSyncHubConfig(config: SyncHubConfig) {
3545
localStorage.setItem(
3646
storageKey,
37-
JSON.stringify({ endpoint: endpoint(config.endpoint), apiKey: config.apiKey.trim() })
47+
JSON.stringify({
48+
endpoint: normalizeSyncHubEndpoint(config.endpoint),
49+
apiKey: config.apiKey.trim(),
50+
})
3851
);
3952
}
4053
export function clearSyncHubConfig() {
@@ -44,14 +57,12 @@ export function isSyncHubConfigured(config = getSyncHubConfig()) {
4457
return /^https?:\/\//i.test(config.endpoint) && config.apiKey.startsWith("shk_");
4558
}
4659

47-
type Collection = "reading-history" | "favorites" | "preferences";
48-
49-
async function request<T>(collection: Collection, init?: RequestInit): Promise<T | null> {
60+
async function request<T>(collection: LatestNewsSyncHubCollection, init?: RequestInit): Promise<T | null> {
5061
const config = getSyncHubConfig();
5162
if (!isSyncHubConfigured(config)) return null;
52-
const response = await fetch(`${config.endpoint}/api/v1/metadata/latestnews/${collection}`, {
63+
const response = await fetch(latestNewsSyncHubURL(config.endpoint, collection), {
5364
...init,
54-
headers: { "Content-Type": "application/json", "X-API-Key": config.apiKey, ...init?.headers },
65+
headers: { ...syncHubHeaders(config.apiKey), ...init?.headers },
5566
});
5667
const result = (await response.json()) as { code: number; message?: string; data?: { payload: T | null } };
5768
if (!response.ok || result.code !== 0) throw new Error(result.message || "SyncHub request failed");

test/synchub-service.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { it, expect, describe } from "vitest";
2+
3+
import {
4+
syncHubHeaders,
5+
latestNewsSyncHubURL,
6+
defaultSyncHubEndpoint,
7+
normalizeSyncHubEndpoint,
8+
latestNewsSyncHubCollections,
9+
} from "../shared/synchub-contract";
10+
11+
describe("SyncHub service contract", () => {
12+
it("defaults to the production SyncHub endpoint", async () => {
13+
expect(defaultSyncHubEndpoint).toBe("https://sync.likanug.app");
14+
expect(normalizeSyncHubEndpoint("")).toBe(defaultSyncHubEndpoint);
15+
expect(normalizeSyncHubEndpoint(" https://self-hosted.example/ ")).toBe("https://self-hosted.example");
16+
});
17+
18+
it("uses the LatestNews application route and API key header", async () => {
19+
expect(latestNewsSyncHubCollections).toEqual(["reading-history", "favorites", "preferences"]);
20+
expect(latestNewsSyncHubURL("https://sync.likanug.app/", "favorites")).toBe(
21+
"https://sync.likanug.app/api/v1/metadata/latestnews/favorites"
22+
);
23+
expect(syncHubHeaders(" shk_latestnews ")).toEqual({
24+
"Content-Type": "application/json",
25+
"X-API-Key": "shk_latestnews",
26+
});
27+
});
28+
});

0 commit comments

Comments
 (0)