@ysgroup/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +22 -0
- package/src/index.ts +12 -0
- package/src/rest.ts +227 -0
- package/src/store.ts +196 -0
- package/src/ws.ts +123 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ysgroup/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@ysgroup/contracts": "0.1.1"
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"pinia": ">=2.2",
|
|
12
|
+
"vue": ">=3.5"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* @ysgroup/core —— 前端运行时:WS RPC 客户端 / 登录态 / 差量订阅 / REST。 */
|
|
2
|
+
|
|
3
|
+
export { YsWsClient, type Envelope } from "./ws";
|
|
4
|
+
export { useYsStore } from "./store";
|
|
5
|
+
export {
|
|
6
|
+
YsRest,
|
|
7
|
+
YsRestError,
|
|
8
|
+
type DeleteBlocker,
|
|
9
|
+
type DeleteImpact,
|
|
10
|
+
type RevealedCredential,
|
|
11
|
+
type TopologyNode,
|
|
12
|
+
} from "./rest";
|
package/src/rest.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/* @ysgroup/core —— REST 客户端(管理接口,带 JWT)。 */
|
|
2
|
+
|
|
3
|
+
export class YsRestError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
public status: number,
|
|
6
|
+
public detail: unknown,
|
|
7
|
+
) {
|
|
8
|
+
super(typeof detail === "string" ? detail : JSON.stringify(detail));
|
|
9
|
+
this.name = "YsRestError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DeleteBlocker {
|
|
14
|
+
source_table: string;
|
|
15
|
+
field: string;
|
|
16
|
+
title: string;
|
|
17
|
+
count: number;
|
|
18
|
+
sample_ids: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DeleteImpact {
|
|
22
|
+
can_delete: boolean;
|
|
23
|
+
target: { table: string; id: string };
|
|
24
|
+
blockers: DeleteBlocker[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RevealedCredential {
|
|
28
|
+
plain: string;
|
|
29
|
+
base64: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class YsRest {
|
|
33
|
+
constructor(
|
|
34
|
+
private baseUrl: string,
|
|
35
|
+
private getToken: () => string,
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
39
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
40
|
+
method,
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
Authorization: `Bearer ${this.getToken()}`,
|
|
44
|
+
},
|
|
45
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
46
|
+
});
|
|
47
|
+
if (!resp.ok) {
|
|
48
|
+
const text = await resp.text();
|
|
49
|
+
let detail: unknown = text;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(text) as { detail?: unknown };
|
|
52
|
+
detail = parsed.detail ?? parsed;
|
|
53
|
+
} catch {
|
|
54
|
+
// 非 JSON 错误保留原文。
|
|
55
|
+
}
|
|
56
|
+
throw new YsRestError(resp.status, detail);
|
|
57
|
+
}
|
|
58
|
+
return resp.status === 204 ? (undefined as T) : ((await resp.json()) as T);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async upload<T>(path: string, file: File): Promise<T> {
|
|
62
|
+
const form = new FormData();
|
|
63
|
+
form.append("upload", file);
|
|
64
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { Authorization: `Bearer ${this.getToken()}` },
|
|
67
|
+
body: form,
|
|
68
|
+
});
|
|
69
|
+
if (!resp.ok) {
|
|
70
|
+
const parsed = await resp.json().catch(() => ({ detail: resp.statusText }));
|
|
71
|
+
throw new YsRestError(resp.status, parsed.detail ?? parsed);
|
|
72
|
+
}
|
|
73
|
+
return (await resp.json()) as T;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
list<T = Record<string, unknown>>(table: string) {
|
|
77
|
+
return this.request<T[]>("GET", `/api/tables/${table}`);
|
|
78
|
+
}
|
|
79
|
+
upsert<T = Record<string, unknown>>(table: string, doc: Record<string, unknown>) {
|
|
80
|
+
return this.request<T>("PUT", `/api/tables/${table}`, doc);
|
|
81
|
+
}
|
|
82
|
+
remove(table: string, id: string) {
|
|
83
|
+
return this.request<{ deleted: string }>("DELETE", `/api/tables/${table}/${id}`);
|
|
84
|
+
}
|
|
85
|
+
deleteImpact(table: string, id: string) {
|
|
86
|
+
return this.request<DeleteImpact>("GET", `/api/tables/${table}/${id}/delete-impact`);
|
|
87
|
+
}
|
|
88
|
+
revealCredentials(table: string, id: string, body: {
|
|
89
|
+
fields: string[];
|
|
90
|
+
password: string;
|
|
91
|
+
reason: string;
|
|
92
|
+
}) {
|
|
93
|
+
return this.request<{
|
|
94
|
+
credentials: Record<string, RevealedCredential>;
|
|
95
|
+
expires_in: number;
|
|
96
|
+
}>("POST", `/api/tables/${table}/${id}/credentials/reveal`, body);
|
|
97
|
+
}
|
|
98
|
+
updateCredentials(table: string, id: string, body: {
|
|
99
|
+
credentials: Record<string, string>;
|
|
100
|
+
password: string;
|
|
101
|
+
reason: string;
|
|
102
|
+
}) {
|
|
103
|
+
return this.request<{ updated_fields: string[] }>(
|
|
104
|
+
"POST",
|
|
105
|
+
`/api/tables/${table}/${id}/credentials/update`,
|
|
106
|
+
body,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
listApps() {
|
|
110
|
+
return this.request<Record<string, unknown>[]>("GET", "/api/apps");
|
|
111
|
+
}
|
|
112
|
+
listAppReferences() {
|
|
113
|
+
return this.request<Record<string, unknown>[]>("GET", "/api/apps/reference-list");
|
|
114
|
+
}
|
|
115
|
+
createApp(body: Record<string, unknown>) {
|
|
116
|
+
return this.request<Record<string, unknown>>("POST", "/api/apps", body);
|
|
117
|
+
}
|
|
118
|
+
setAppStatus(appId: string, status: string) {
|
|
119
|
+
return this.request("POST", `/api/apps/${appId}/status/${status}`, {});
|
|
120
|
+
}
|
|
121
|
+
setGrants(appId: string, grants: Record<string, unknown>) {
|
|
122
|
+
return this.request("POST", `/api/apps/${appId}/grants`, grants);
|
|
123
|
+
}
|
|
124
|
+
updateApp(appId: string, body: Record<string, unknown>) {
|
|
125
|
+
return this.request<Record<string, unknown>>("PUT", `/api/apps/${appId}`, body);
|
|
126
|
+
}
|
|
127
|
+
rotateSecret(appId: string) {
|
|
128
|
+
return this.request<{ secret: string }>("POST", `/api/apps/${appId}/rotate_secret`, {});
|
|
129
|
+
}
|
|
130
|
+
disableUser(userId: string) {
|
|
131
|
+
return this.request("POST", `/api/users/${userId}/disable`, {});
|
|
132
|
+
}
|
|
133
|
+
issueSso(appName: string) {
|
|
134
|
+
return this.request<{ code: string; expires_in: number }>("POST", "/api/sso/issue", {
|
|
135
|
+
app_name: appName,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
portalApps() {
|
|
139
|
+
return this.request<Record<string, unknown>[]>("GET", "/api/portal/apps");
|
|
140
|
+
}
|
|
141
|
+
createUser(body: Record<string, unknown>) {
|
|
142
|
+
return this.request<Record<string, unknown>>("POST", "/api/users", body);
|
|
143
|
+
}
|
|
144
|
+
updateUser(id: string, body: Record<string, unknown>) {
|
|
145
|
+
return this.request<Record<string, unknown>>("PUT", `/api/users/${id}`, body);
|
|
146
|
+
}
|
|
147
|
+
setUserEnabled(id: string, enabled: boolean) {
|
|
148
|
+
return this.request("POST", `/api/users/${id}/${enabled ? "enable" : "disable"}`, {});
|
|
149
|
+
}
|
|
150
|
+
resetUserPassword(id: string, newPassword: string, reason: string) {
|
|
151
|
+
return this.request("POST", `/api/users/${id}/reset-password`, {
|
|
152
|
+
new_password: newPassword,
|
|
153
|
+
reason,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
changeMyPassword(currentPassword: string, newPassword: string) {
|
|
157
|
+
return this.request("POST", "/api/me/change-password", {
|
|
158
|
+
current_password: currentPassword,
|
|
159
|
+
new_password: newPassword,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
removeUser(id: string) {
|
|
163
|
+
return this.request("DELETE", `/api/users/${id}`);
|
|
164
|
+
}
|
|
165
|
+
getUserApps(id: string) {
|
|
166
|
+
return this.request<string[]>("GET", `/api/users/${id}/apps`);
|
|
167
|
+
}
|
|
168
|
+
setUserApps(id: string, appIds: string[]) {
|
|
169
|
+
return this.request<{ app_ids: string[] }>("PUT", `/api/users/${id}/apps`, {
|
|
170
|
+
app_ids: appIds,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
uploadOpenAccountConfirmation(ownerId: string, file: File) {
|
|
174
|
+
return this.upload<{
|
|
175
|
+
file_id: string;
|
|
176
|
+
parsed: Record<string, string>;
|
|
177
|
+
exchange_account_id: string;
|
|
178
|
+
exchange_account_created: boolean;
|
|
179
|
+
}>(`/api/owners/${ownerId}/open-account-confirmation`, file);
|
|
180
|
+
}
|
|
181
|
+
upsertMountExchangeAccount(body: Record<string, unknown>) {
|
|
182
|
+
return this.request<Record<string, unknown>>("PUT", "/api/mount-exchange-accounts", body);
|
|
183
|
+
}
|
|
184
|
+
setProjectOwners(projectId: string, ownerIds: string[]) {
|
|
185
|
+
return this.request<{ owner_ids: string[] }>("PUT", `/api/projects/${projectId}/owners`, {
|
|
186
|
+
owner_ids: ownerIds,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
getRelations(entity: "owners" | "accounts", id: string,
|
|
190
|
+
relation: "apps" | "strategies") {
|
|
191
|
+
return this.request<{ ids: string[] }>(
|
|
192
|
+
"GET", `/api/${entity}/${id}/relations/${relation}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
setRelations(entity: "owners" | "accounts", id: string,
|
|
196
|
+
relation: "apps" | "strategies", ids: string[]) {
|
|
197
|
+
return this.request<{ ids: string[] }>(
|
|
198
|
+
"PUT", `/api/${entity}/${id}/relations/${relation}`, { ids },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
listOwnerFiles(ownerId: string) {
|
|
202
|
+
return this.request<Record<string, unknown>[]>("GET", `/api/owners/${ownerId}/files`);
|
|
203
|
+
}
|
|
204
|
+
downloadFile(fileId: string) {
|
|
205
|
+
return fetch(`${this.baseUrl}/api/files/${fileId}/download`, {
|
|
206
|
+
headers: { Authorization: `Bearer ${this.getToken()}` },
|
|
207
|
+
}).then(async (resp) => {
|
|
208
|
+
if (!resp.ok) throw new YsRestError(resp.status, await resp.text());
|
|
209
|
+
return resp.blob();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
topology() {
|
|
213
|
+
return this.request<{ nodes: TopologyNode[] }>("GET", "/api/meta/topology");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface TopologyNode {
|
|
218
|
+
name: string;
|
|
219
|
+
title: string;
|
|
220
|
+
kind: string;
|
|
221
|
+
status: string;
|
|
222
|
+
port: number;
|
|
223
|
+
online: boolean;
|
|
224
|
+
last_heartbeat_at: number;
|
|
225
|
+
subscribed_tables: string[];
|
|
226
|
+
heartbeat?: Record<string, unknown>;
|
|
227
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/* @ysgroup/core —— 登录态 + 差量订阅缓存(Pinia store)。 */
|
|
2
|
+
import { defineStore } from "pinia";
|
|
3
|
+
import { ref, computed } from "vue";
|
|
4
|
+
import { YsWsClient } from "./ws";
|
|
5
|
+
|
|
6
|
+
interface LoginResult {
|
|
7
|
+
user?: Record<string, unknown>;
|
|
8
|
+
access_token: string;
|
|
9
|
+
refresh_token: string;
|
|
10
|
+
access_expires_in: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const LS_ACCESS = "ys_access_token";
|
|
14
|
+
const LS_REFRESH = "ys_refresh_token";
|
|
15
|
+
|
|
16
|
+
export const useYsStore = defineStore("ys", () => {
|
|
17
|
+
const user = ref<Record<string, unknown> | null>(null);
|
|
18
|
+
const accessToken = ref<string>(localStorage.getItem(LS_ACCESS) ?? "");
|
|
19
|
+
const refreshToken = ref<string>(localStorage.getItem(LS_REFRESH) ?? "");
|
|
20
|
+
const connected = ref(false);
|
|
21
|
+
const appName = ref("");
|
|
22
|
+
/** 差量订阅的表数据:table -> Map<id, doc> */
|
|
23
|
+
const tables = ref<Record<string, Map<string, Record<string, unknown>>>>({});
|
|
24
|
+
|
|
25
|
+
let ws: YsWsClient | null = null;
|
|
26
|
+
let renewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
|
|
28
|
+
const isLoggedIn = computed(() => !!accessToken.value);
|
|
29
|
+
|
|
30
|
+
function tableList(table: string): Record<string, unknown>[] {
|
|
31
|
+
return Array.from(tables.value[table]?.values() ?? []);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function connect(wsUrl: string, app: string) {
|
|
35
|
+
appName.value = app;
|
|
36
|
+
ws = new YsWsClient(wsUrl);
|
|
37
|
+
ws.on("__connected", () => {
|
|
38
|
+
connected.value = true;
|
|
39
|
+
if (refreshToken.value && !user.value) void renew();
|
|
40
|
+
});
|
|
41
|
+
ws.on("__disconnected", () => (connected.value = false));
|
|
42
|
+
ws.on("snapshot", (d: unknown) => applySnapshot(d as SnapshotData));
|
|
43
|
+
ws.on("delta", (d: unknown) => applyDelta(d as DeltaData));
|
|
44
|
+
ws.on("user_revoked", (d: unknown) => onRevoked(d as { user_id: string }));
|
|
45
|
+
ws.on("resync", () => {}); // 前端订阅本子系统后端,resync 由后端 SDK 处理
|
|
46
|
+
ws.start();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SnapshotData {
|
|
50
|
+
table: string;
|
|
51
|
+
docs: Record<string, unknown>[];
|
|
52
|
+
batch: number;
|
|
53
|
+
total: number;
|
|
54
|
+
}
|
|
55
|
+
interface DeltaData {
|
|
56
|
+
table: string;
|
|
57
|
+
changes: { op: string; doc: Record<string, unknown> }[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const snapshotBuf: Record<string, Record<string, unknown>[]> = {};
|
|
61
|
+
|
|
62
|
+
function applySnapshot(d: SnapshotData) {
|
|
63
|
+
(snapshotBuf[d.table] ??= []).push(...d.docs);
|
|
64
|
+
if (d.batch < d.total) return;
|
|
65
|
+
const map = new Map<string, Record<string, unknown>>();
|
|
66
|
+
for (const doc of snapshotBuf[d.table]) map.set(String(doc._id), doc);
|
|
67
|
+
tables.value = { ...tables.value, [d.table]: map };
|
|
68
|
+
delete snapshotBuf[d.table];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function applyDelta(d: DeltaData) {
|
|
72
|
+
const map = tables.value[d.table] ?? new Map();
|
|
73
|
+
for (const c of d.changes) {
|
|
74
|
+
const id = String(c.doc._id);
|
|
75
|
+
if (c.op === "delete") map.delete(id);
|
|
76
|
+
else map.set(id, c.doc);
|
|
77
|
+
}
|
|
78
|
+
tables.value = { ...tables.value, [d.table]: new Map(map) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onRevoked(d: { user_id: string }) {
|
|
82
|
+
if (user.value && String(user.value._id) === d.user_id) void logout();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function login(username: string, password: string, otp = ""): Promise<void> {
|
|
86
|
+
if (!ws) throw new Error("未连接");
|
|
87
|
+
const result = (await ws.call("login", {
|
|
88
|
+
username,
|
|
89
|
+
password,
|
|
90
|
+
otp,
|
|
91
|
+
app_name: appName.value,
|
|
92
|
+
user_agent: navigator.userAgent,
|
|
93
|
+
})) as LoginResult;
|
|
94
|
+
setSession(result);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 门户一次性 code 单点登录(子系统前端启动时检测 URL 上的 ?ys_code=)。 */
|
|
98
|
+
async function loginByCode(code: string): Promise<void> {
|
|
99
|
+
await waitUntilConnected();
|
|
100
|
+
if (!ws) throw new Error("未连接");
|
|
101
|
+
const result = (await ws.call("exchange_sso_code", { code })) as LoginResult;
|
|
102
|
+
setSession(result);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function waitUntilConnected(timeoutMs = 10000): Promise<void> {
|
|
106
|
+
if (connected.value) return;
|
|
107
|
+
if (!ws) throw new Error("未连接");
|
|
108
|
+
await new Promise<void>((resolve, reject) => {
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
off();
|
|
111
|
+
reject(new Error("连接服务超时"));
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
const off = ws!.on("__connected", () => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
off();
|
|
116
|
+
resolve();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
|
|
122
|
+
if (!ws || !accessToken.value) throw new Error("未登录");
|
|
123
|
+
if (
|
|
124
|
+
String(user.value?.role ?? "") === "Root" ||
|
|
125
|
+
String(user.value?.username ?? "").trim().toLowerCase() === "root"
|
|
126
|
+
) {
|
|
127
|
+
throw new Error("Root 不允许修改登录密码");
|
|
128
|
+
}
|
|
129
|
+
await ws.call("change_password", {
|
|
130
|
+
access_token: accessToken.value,
|
|
131
|
+
current_password: currentPassword,
|
|
132
|
+
new_password: newPassword,
|
|
133
|
+
});
|
|
134
|
+
clearSession();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function setSession(result: LoginResult) {
|
|
138
|
+
if (result.user) user.value = result.user;
|
|
139
|
+
accessToken.value = result.access_token;
|
|
140
|
+
refreshToken.value = result.refresh_token;
|
|
141
|
+
localStorage.setItem(LS_ACCESS, result.access_token);
|
|
142
|
+
localStorage.setItem(LS_REFRESH, result.refresh_token);
|
|
143
|
+
scheduleRenew(result.access_expires_in);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function scheduleRenew(ttl: number) {
|
|
147
|
+
if (renewTimer) clearTimeout(renewTimer);
|
|
148
|
+
// 提前 60s 静默续期
|
|
149
|
+
renewTimer = setTimeout(() => void renew(), Math.max(10, ttl - 60) * 1000);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function renew() {
|
|
153
|
+
if (!ws || !refreshToken.value) return;
|
|
154
|
+
try {
|
|
155
|
+
const result = (await ws.call("refresh", {
|
|
156
|
+
refresh_token: refreshToken.value,
|
|
157
|
+
})) as LoginResult;
|
|
158
|
+
if (result.user) user.value = result.user;
|
|
159
|
+
accessToken.value = result.access_token;
|
|
160
|
+
refreshToken.value = result.refresh_token;
|
|
161
|
+
localStorage.setItem(LS_ACCESS, result.access_token);
|
|
162
|
+
localStorage.setItem(LS_REFRESH, result.refresh_token);
|
|
163
|
+
scheduleRenew(result.access_expires_in);
|
|
164
|
+
} catch {
|
|
165
|
+
void logout();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function logout() {
|
|
170
|
+
const token = refreshToken.value;
|
|
171
|
+
if (ws && token) {
|
|
172
|
+
try {
|
|
173
|
+
await ws.call("logout", { refresh_token: token });
|
|
174
|
+
} catch {
|
|
175
|
+
// 本地退出必须可用;服务端不可达时仍清除浏览器状态。
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
clearSession();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function clearSession() {
|
|
182
|
+
user.value = null;
|
|
183
|
+
accessToken.value = "";
|
|
184
|
+
refreshToken.value = "";
|
|
185
|
+
localStorage.removeItem(LS_ACCESS);
|
|
186
|
+
localStorage.removeItem(LS_REFRESH);
|
|
187
|
+
if (renewTimer) clearTimeout(renewTimer);
|
|
188
|
+
renewTimer = null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
user, accessToken, refreshToken, connected, isLoggedIn, tables,
|
|
193
|
+
tableList, connect, login, loginByCode, waitUntilConnected, changeMyPassword, renew, logout,
|
|
194
|
+
client: () => ws,
|
|
195
|
+
};
|
|
196
|
+
});
|
package/src/ws.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/* @ysgroup/core —— 浏览器端 WS RPC 客户端(与 ys_base.ws.WsRpcClient 信封协议对齐)。 */
|
|
2
|
+
|
|
3
|
+
export interface Envelope {
|
|
4
|
+
event: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
reply_to?: string;
|
|
7
|
+
code?: number;
|
|
8
|
+
message?: string;
|
|
9
|
+
ts?: number;
|
|
10
|
+
data?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type EventHandler = (data: unknown) => void;
|
|
14
|
+
|
|
15
|
+
export class YsWsClient {
|
|
16
|
+
private ws: WebSocket | null = null;
|
|
17
|
+
private seq = 0;
|
|
18
|
+
private connectionId = "";
|
|
19
|
+
private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
20
|
+
private handlers = new Map<string, EventHandler[]>();
|
|
21
|
+
private reconnectDelay = 1000;
|
|
22
|
+
private stopped = false;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private url: string,
|
|
26
|
+
private callTimeout = 10_000,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
start(): void {
|
|
30
|
+
this.stopped = false;
|
|
31
|
+
this.connect();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
stop(): void {
|
|
35
|
+
this.stopped = true;
|
|
36
|
+
this.ws?.close();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
on(event: string, handler: EventHandler): () => void {
|
|
40
|
+
const list = this.handlers.get(event) ?? [];
|
|
41
|
+
list.push(handler);
|
|
42
|
+
this.handlers.set(event, list);
|
|
43
|
+
return () => {
|
|
44
|
+
const current = this.handlers.get(event) ?? [];
|
|
45
|
+
const next = current.filter((item) => item !== handler);
|
|
46
|
+
if (next.length) this.handlers.set(event, next);
|
|
47
|
+
else this.handlers.delete(event);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
call(event: string, data: unknown = {}): Promise<unknown> {
|
|
52
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
53
|
+
return Promise.reject(new Error("not connected"));
|
|
54
|
+
}
|
|
55
|
+
const id = `${this.connectionId || "c0"}:${++this.seq}`;
|
|
56
|
+
this.ws.send(JSON.stringify({ event, id, ts: Date.now() / 1000, data }));
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
this.pending.delete(id);
|
|
60
|
+
reject(new Error(`call ${event} timed out`));
|
|
61
|
+
}, this.callTimeout);
|
|
62
|
+
this.pending.set(id, {
|
|
63
|
+
resolve: (v) => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
resolve(v);
|
|
66
|
+
},
|
|
67
|
+
reject: (e) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
reject(e);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
send(event: string, data: unknown = {}): void {
|
|
76
|
+
this.ws?.send(JSON.stringify({ event, ts: Date.now() / 1000, data }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private connect(): void {
|
|
80
|
+
const ws = new WebSocket(this.url);
|
|
81
|
+
this.ws = ws;
|
|
82
|
+
ws.onopen = () => {
|
|
83
|
+
this.reconnectDelay = 1000;
|
|
84
|
+
};
|
|
85
|
+
ws.onmessage = (e) => this.onMessage(e);
|
|
86
|
+
ws.onclose = () => {
|
|
87
|
+
for (const p of this.pending.values()) p.reject(new Error("connection lost"));
|
|
88
|
+
this.pending.clear();
|
|
89
|
+
this.emit("__disconnected", null);
|
|
90
|
+
if (!this.stopped) {
|
|
91
|
+
setTimeout(() => this.connect(), this.reconnectDelay + Math.random() * 1000);
|
|
92
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private onMessage(e: MessageEvent): void {
|
|
98
|
+
let msg: Envelope;
|
|
99
|
+
try {
|
|
100
|
+
msg = JSON.parse(e.data as string);
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (msg.reply_to) {
|
|
105
|
+
const p = this.pending.get(msg.reply_to);
|
|
106
|
+
if (p) {
|
|
107
|
+
this.pending.delete(msg.reply_to);
|
|
108
|
+
if ((msg.code ?? 0) === 0) p.resolve(msg.data);
|
|
109
|
+
else p.reject(new Error(`[${msg.code}] ${msg.message ?? ""}`));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (msg.event === "hello") {
|
|
114
|
+
this.connectionId = String((msg.data as { connection_id?: string })?.connection_id ?? "");
|
|
115
|
+
this.emit("__connected", msg.data);
|
|
116
|
+
}
|
|
117
|
+
this.emit(msg.event, msg.data);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private emit(event: string, data: unknown): void {
|
|
121
|
+
for (const h of this.handlers.get(event) ?? []) h(data);
|
|
122
|
+
}
|
|
123
|
+
}
|