feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、 Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
commit
f8740d27f7
240 files changed
+21625
No files matched your search
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@opsxdb/transport",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opsxdb/core": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tauri-apps/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
|
||||
|
||||
function buildUrl(spec: HttpSpec): string {
|
||||
const u = new URL(spec.url);
|
||||
if (spec.query) {
|
||||
for (const [k, v] of Object.entries(spec.query)) {
|
||||
if (v !== undefined && v !== "") u.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function headersToRecord(h: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
h.forEach((v, k) => {
|
||||
out[k] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export const browserTransport: Transport = {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
const started = performance.now();
|
||||
const init: RequestInit = {
|
||||
method: spec.method,
|
||||
headers: { ...spec.headers },
|
||||
signal: spec.timeoutMs
|
||||
? AbortSignal.timeout(spec.timeoutMs)
|
||||
: undefined,
|
||||
};
|
||||
if (spec.body && spec.body.mode !== "none" && spec.method !== "GET") {
|
||||
init.body = spec.body.raw ?? "";
|
||||
if (spec.body.mode === "json" && !("content-type" in Object.fromEntries(
|
||||
Object.entries(spec.headers).map(([k, v]) => [k.toLowerCase(), v]),
|
||||
))) {
|
||||
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
const res = await fetch(buildUrl(spec), init);
|
||||
const body = await res.text();
|
||||
const timeMs = Math.round(performance.now() - started);
|
||||
return {
|
||||
status: res.status,
|
||||
headers: headersToRecord(res.headers),
|
||||
body,
|
||||
timeMs,
|
||||
size: new TextEncoder().encode(body).length,
|
||||
};
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
const socket = new WebSocket(spec.url, spec.protocols);
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
emitStatus("connecting");
|
||||
socket.onopen = () => emitStatus("open");
|
||||
socket.onclose = () => emitStatus("closed");
|
||||
socket.onerror = () => emitStatus("error");
|
||||
socket.onmessage = (ev) => {
|
||||
const data = typeof ev.data === "string" ? ev.data : String(ev.data);
|
||||
messageCbs.forEach((cb) => cb(data));
|
||||
};
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
socket.send(data);
|
||||
},
|
||||
close() {
|
||||
socket.close();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Transport } from "@opsxdb/core";
|
||||
import { browserTransport } from "./browser";
|
||||
import { ginProxyTransport } from "./ginProxy";
|
||||
import { tauriNativeTransport } from "./tauriNative";
|
||||
|
||||
export type TransportKind = "tauri-native" | "gin-proxy" | "browser";
|
||||
|
||||
export type DetectOptions = {
|
||||
ginBaseUrl?: string;
|
||||
/** Force a transport regardless of environment */
|
||||
force?: TransportKind;
|
||||
};
|
||||
|
||||
function isTauri(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const w = window as unknown as { __TAURI_INTERNALS__?: unknown };
|
||||
return Boolean(w.__TAURI_INTERNALS__);
|
||||
}
|
||||
|
||||
export function detectTransport(opts: DetectOptions = {}): {
|
||||
kind: TransportKind;
|
||||
transport: Transport;
|
||||
} {
|
||||
if (opts.force === "browser") {
|
||||
return { kind: "browser", transport: browserTransport };
|
||||
}
|
||||
if (opts.force === "tauri-native") {
|
||||
return { kind: "tauri-native", transport: tauriNativeTransport };
|
||||
}
|
||||
if (opts.force === "gin-proxy") {
|
||||
const base = opts.ginBaseUrl ?? "http://127.0.0.1:8788";
|
||||
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
|
||||
}
|
||||
|
||||
if (isTauri()) {
|
||||
return { kind: "tauri-native", transport: tauriNativeTransport };
|
||||
}
|
||||
|
||||
const base = opts.ginBaseUrl ?? "http://127.0.0.1:8788";
|
||||
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
|
||||
import { browserTransport } from "./browser";
|
||||
|
||||
/**
|
||||
* HTTP goes through Gin POST /v1/proxy/http.
|
||||
* WS (MVP): falls back to browser WebSocket — custom headers require
|
||||
* desktop/Gin tunnel later (Week 2).
|
||||
*/
|
||||
export function ginProxyTransport(baseUrl: string): Transport {
|
||||
const root = baseUrl.replace(/\/$/, "");
|
||||
|
||||
return {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
const res = await fetch(`${root}/v1/proxy/http`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(spec),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`gin-proxy HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
return (await res.json()) as HttpResult;
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
// TODO(week2): custom headers need desktop/Gin WS tunnel.
|
||||
// For MVP, fall back to browser WebSocket (no custom headers).
|
||||
console.warn(
|
||||
"[opsxdb/transport] gin-proxy WS falls back to browser WebSocket; custom headers need desktop/Gin tunnel later",
|
||||
);
|
||||
return browserTransport.ws(spec);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { browserTransport } from "./browser";
|
||||
export { ginProxyTransport } from "./ginProxy";
|
||||
export { tauriNativeTransport } from "./tauriNative";
|
||||
export { detectTransport } from "./detect";
|
||||
export type { TransportKind, DetectOptions } from "./detect";
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
|
||||
|
||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
const { invoke: tauriInvoke } = await import("@tauri-apps/api/core");
|
||||
return await tauriInvoke<T>(cmd, args);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(
|
||||
`tauri-native invoke("${cmd}") failed (are you running inside OpsxDB desktop?): ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const tauriNativeTransport: Transport = {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
return invoke<HttpResult>("http_send", { spec });
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
let sessionId: string | null = null;
|
||||
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
emitStatus("connecting");
|
||||
sessionId = await invoke<string>("ws_connect", { spec });
|
||||
emitStatus("open");
|
||||
// Full event listen pipeline is Week 2; stub keeps UI usable.
|
||||
} catch (e) {
|
||||
emitStatus("error");
|
||||
messageCbs.forEach((cb) =>
|
||||
cb(`[ws_connect error] ${e instanceof Error ? e.message : String(e)}`),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
if (!sessionId) {
|
||||
messageCbs.forEach((cb) => cb("[ws] not connected"));
|
||||
return;
|
||||
}
|
||||
void invoke("ws_send", { sessionId, data }).catch((e) => {
|
||||
messageCbs.forEach((cb) =>
|
||||
cb(`[ws_send error] ${e instanceof Error ? e.message : String(e)}`),
|
||||
);
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (!sessionId) return;
|
||||
void invoke("ws_close", { sessionId }).finally(() => {
|
||||
sessionId = null;
|
||||
emitStatus("closed");
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user