feat: OpsxAPI v1.0 全协议接口工作台
- 六协议调试:HTTP/GraphQL/WebSocket/SSE/Socket.IO/MQTT
- 集合树/多标签/多环境变量/历史记录,localStorage 持久化
- 导入:OpenAPI 2/3/3.1、Postman、Apifox、Apipost、cURL、HAR、
Insomnia、Hoppscotch、Markdown(自动识别,21 个单测覆盖)
- 导出:OpenAPI 3.0 JSON/YAML、Postman v2.1、Markdown 文档、
全量备份;单请求生成 cURL/fetch/Python/Go 代码
- Gin 服务:HTTP 代理、NDJSON 流式代理(SSE 透传)、
WebSocket 隧道(浏览器自定义握手 Header)
- 内置产品官网 /home 与用户文档站 /docs(SSR,随二进制交付)
- lucide 图标体系 + 品牌标识(Logo/favicon),明暗双主题
- 环境变量:彩色徽标切换器、管理弹窗、{{ 自动补全
This commit is contained in:
commit
dec66db5aa
182 files changed
+23377
No files matched your search
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@opsxapi/transport",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opsxapi/core": "*",
|
||||
"mqtt": "^5.10.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"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 "@opsxapi/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 "@opsxapi/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 ?? "";
|
||||
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
|
||||
}
|
||||
|
||||
if (isTauri()) {
|
||||
return { kind: "tauri-native", transport: tauriNativeTransport };
|
||||
}
|
||||
|
||||
const base = opts.ginBaseUrl ?? "";
|
||||
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/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(
|
||||
"[opsxapi/transport] gin-proxy WS falls back to browser WebSocket; custom headers need desktop/Gin tunnel later",
|
||||
);
|
||||
return browserTransport.ws(spec);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { browserTransport } from "./browser";
|
||||
export { ginProxyTransport } from "./ginProxy";
|
||||
export { tauriNativeTransport } from "./tauriNative";
|
||||
export { detectTransport } from "./detect";
|
||||
export type { TransportKind, DetectOptions } from "./detect";
|
||||
|
||||
export {
|
||||
SseParser,
|
||||
httpStreamViaGin,
|
||||
httpStreamDirect,
|
||||
openSse,
|
||||
} from "./streaming";
|
||||
export type {
|
||||
StreamStatus,
|
||||
StreamMeta,
|
||||
StreamSession,
|
||||
StreamCallbacks,
|
||||
SseEvent,
|
||||
SseOptions,
|
||||
} from "./streaming";
|
||||
|
||||
export { wsSmart, probeGin } from "./wsSmart";
|
||||
export type { WsSmartOptions, WsTunnelInfo } from "./wsSmart";
|
||||
|
||||
export { connectSocketIo } from "./socketio";
|
||||
export type { SocketIoSession, SocketIoStatus, SocketIoOptions } from "./socketio";
|
||||
|
||||
export { connectMqtt } from "./mqtt";
|
||||
export type { MqttSession, MqttStatus, MqttMessage, MqttOptions } from "./mqtt";
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* MQTT 客户端封装(动态加载 mqtt,浏览器走 ws/wss)。
|
||||
*/
|
||||
export type MqttStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
|
||||
|
||||
export type MqttMessage = {
|
||||
topic: string;
|
||||
payload: string;
|
||||
at: number;
|
||||
qos: number;
|
||||
};
|
||||
|
||||
export type MqttSession = {
|
||||
publish(topic: string, payload: string, qos?: 0 | 1 | 2): void;
|
||||
subscribe(topics: string[]): void;
|
||||
unsubscribe(topics: string[]): void;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export type MqttOptions = {
|
||||
brokerUrl: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
clientId?: string;
|
||||
/** 初始订阅 */
|
||||
topics: string[];
|
||||
qos?: 0 | 1 | 2;
|
||||
onMessage(msg: MqttMessage): void;
|
||||
onStatus?(s: MqttStatus, detail?: string): void;
|
||||
onError?(msg: string): void;
|
||||
};
|
||||
|
||||
export function connectMqtt(opts: MqttOptions): MqttSession {
|
||||
type MqttClient = {
|
||||
on(ev: string, cb: (...args: unknown[]) => void): void;
|
||||
subscribe(topic: string | string[], opts?: { qos?: number }, cb?: (err: unknown) => void): void;
|
||||
unsubscribe(topic: string | string[], cb?: () => void): void;
|
||||
publish(topic: string, message: string | Uint8Array, opts?: { qos?: number }, cb?: (err: unknown) => void): void;
|
||||
end(force?: boolean): void;
|
||||
};
|
||||
let client: MqttClient | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const topics = opts.topics.map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
opts.onStatus?.("connecting");
|
||||
const mqttMod = await import("mqtt");
|
||||
const connect = (mqttMod as unknown as { connect: typeof mqttMod.default.connect }).connect ?? mqttMod.default.connect;
|
||||
const url = opts.brokerUrl.trim();
|
||||
if (!/^(wss?|mqtts?):\/\//i.test(url)) {
|
||||
throw new Error("浏览器端 MQTT 需 ws:// 或 wss:// 地址(例如 wss://broker.emqx.io:8084/mqtt)");
|
||||
}
|
||||
client = connect(url, {
|
||||
clientId: opts.clientId || `opsxapi-${Math.random().toString(16).slice(2, 10)}`,
|
||||
username: opts.username || undefined,
|
||||
password: opts.password || undefined,
|
||||
clean: true,
|
||||
reconnectPeriod: 3000,
|
||||
connectTimeout: 10_000,
|
||||
}) as MqttClient;
|
||||
|
||||
client.on("connect", () => {
|
||||
opts.onStatus?.("open");
|
||||
if (topics.length) client?.subscribe(topics, { qos: opts.qos ?? 0 });
|
||||
});
|
||||
client.on("reconnect", () => opts.onStatus?.("reconnecting"));
|
||||
client.on("close", () => opts.onStatus?.("closed"));
|
||||
client.on("error", (...args: unknown[]) => {
|
||||
const err = args[0];
|
||||
opts.onError?.(err instanceof Error ? err.message : String(err));
|
||||
opts.onStatus?.("error");
|
||||
});
|
||||
client.on("message", (...args: unknown[]) => {
|
||||
const topic = String(args[0]);
|
||||
const payload = args[1] as Uint8Array;
|
||||
const packet = args[2] as { qos?: number } | undefined;
|
||||
const qos = packet?.qos ?? 0;
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder().decode(payload);
|
||||
} catch {
|
||||
text = Array.from(payload ?? []).join(",");
|
||||
}
|
||||
opts.onMessage({ topic, payload: text, at: Date.now(), qos });
|
||||
});
|
||||
|
||||
if (disposed) client.end(true);
|
||||
} catch (e) {
|
||||
opts.onError?.(`mqtt.js 加载/连接失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
opts.onStatus?.("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
publish(topic, payload, qos) {
|
||||
if (!client) {
|
||||
opts.onError?.("MQTT 尚未连接");
|
||||
return;
|
||||
}
|
||||
client.publish(topic.trim(), payload, { qos: qos ?? opts.qos ?? 0 }, (err) => {
|
||||
if (err) opts.onError?.(`发布失败:${String(err)}`);
|
||||
});
|
||||
},
|
||||
subscribe(list) {
|
||||
const t = list.map((x) => x.trim()).filter(Boolean);
|
||||
if (t.length) client?.subscribe(t, { qos: opts.qos ?? 0 });
|
||||
},
|
||||
unsubscribe(list) {
|
||||
const t = list.map((x) => x.trim()).filter(Boolean);
|
||||
if (t.length) client?.unsubscribe(t);
|
||||
},
|
||||
close() {
|
||||
disposed = true;
|
||||
client?.end(true);
|
||||
opts.onStatus?.("closed");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Socket.IO 客户端封装(动态加载 socket.io-client)。
|
||||
*/
|
||||
export type SocketIoStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
export type SocketIoSession = {
|
||||
emit(event: string, args: unknown[]): void;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export type SocketIoOptions = {
|
||||
url: string;
|
||||
/** 附加选项 JSON:{ path?, transports?, auth?, ... } */
|
||||
optionsJson?: string;
|
||||
/** 监听事件列表;空数组 = onAny 全收 */
|
||||
listenEvents: string[];
|
||||
onEvent(event: string, args: unknown[]): void;
|
||||
onStatus?(s: SocketIoStatus, detail?: string): void;
|
||||
onError?(msg: string): void;
|
||||
};
|
||||
|
||||
type IoSocket = {
|
||||
emit(ev: string, ...args: unknown[]): void;
|
||||
on(ev: string, cb: (...args: unknown[]) => void): void;
|
||||
onAny(cb: (ev: string, ...args: unknown[]) => void): void;
|
||||
disconnect(): void;
|
||||
};
|
||||
|
||||
export function connectSocketIo(opts: SocketIoOptions): SocketIoSession {
|
||||
let socket: IoSocket | null = null;
|
||||
let disposed = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
opts.onStatus?.("connecting");
|
||||
const { io } = await import("socket.io-client");
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (opts.optionsJson?.trim()) {
|
||||
try {
|
||||
Object.assign(extra, JSON.parse(opts.optionsJson));
|
||||
} catch {
|
||||
opts.onError?.("连接选项不是合法 JSON,已忽略");
|
||||
}
|
||||
}
|
||||
const s: IoSocket = io(opts.url, {
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: false,
|
||||
...extra,
|
||||
}) as unknown as IoSocket;
|
||||
socket = s;
|
||||
|
||||
s.on("connect", () => opts.onStatus?.("open"));
|
||||
s.on("disconnect", (...args: unknown[]) =>
|
||||
opts.onStatus?.("closed", String(args[0] ?? "")),
|
||||
);
|
||||
s.on("connect_error", (...args: unknown[]) => {
|
||||
const err = args[0];
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
opts.onError?.(msg);
|
||||
opts.onStatus?.("error", msg);
|
||||
});
|
||||
|
||||
const events = opts.listenEvents.map((e) => e.trim()).filter(Boolean);
|
||||
if (events.length === 0 || events.includes("*")) {
|
||||
s.onAny((ev, ...args) => opts.onEvent(ev, args));
|
||||
} else {
|
||||
for (const ev of events) {
|
||||
s.on(ev, (...args: unknown[]) => opts.onEvent(ev, args));
|
||||
}
|
||||
}
|
||||
if (disposed) s.disconnect();
|
||||
} catch (e) {
|
||||
opts.onError?.(`socket.io-client 加载/连接失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
opts.onStatus?.("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
emit(event, args) {
|
||||
if (!socket) {
|
||||
opts.onError?.("Socket.IO 尚未连接");
|
||||
return;
|
||||
}
|
||||
socket.emit(event, ...args);
|
||||
},
|
||||
close() {
|
||||
disposed = true;
|
||||
socket?.disconnect();
|
||||
opts.onStatus?.("closed");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 流式 HTTP / SSE 客户端。
|
||||
* - 直连模式:fetch + ReadableStream(目标站需允许 CORS)
|
||||
* - 代理模式:Gin POST /v1/proxy/http/stream(NDJSON,穿透 SSE/chunked)
|
||||
*/
|
||||
import type { HttpSpec } from "@opsxapi/core";
|
||||
|
||||
export type StreamStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
export type StreamMeta = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export type StreamSession = {
|
||||
abort(): void;
|
||||
};
|
||||
|
||||
export type StreamCallbacks = {
|
||||
onMeta?(meta: StreamMeta): void;
|
||||
onChunk?(text: string): void;
|
||||
onEnd?(info: { timeMs: number; size: number }): void;
|
||||
onStatus?(status: StreamStatus): void;
|
||||
onError?(message: string): void;
|
||||
};
|
||||
|
||||
/** SSE 帧 */
|
||||
export type SseEvent = {
|
||||
event: string;
|
||||
data: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/** 增量 SSE 解析器:喂任意分片,吐完整事件 */
|
||||
export class SseParser {
|
||||
private buffer = "";
|
||||
private event = "message";
|
||||
private dataLines: string[] = [];
|
||||
private lastId: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly emit: (ev: SseEvent) => void,
|
||||
private readonly onId?: (id: string) => void,
|
||||
) {}
|
||||
|
||||
feed(text: string): void {
|
||||
this.buffer += text;
|
||||
let idx: number;
|
||||
while ((idx = this.buffer.indexOf("\n")) >= 0) {
|
||||
const line = this.buffer.slice(0, idx).replace(/\r$/, "");
|
||||
this.buffer = this.buffer.slice(idx + 1);
|
||||
this.handleLine(line);
|
||||
}
|
||||
// 兼容无结尾换行的流结束
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (this.buffer) {
|
||||
this.handleLine(this.buffer.replace(/\r$/, ""));
|
||||
this.buffer = "";
|
||||
}
|
||||
this.dispatch();
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
if (line === "") {
|
||||
this.dispatch();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) return; // 注释/心跳
|
||||
const colon = line.indexOf(":");
|
||||
const field = colon < 0 ? line : line.slice(0, colon);
|
||||
let value = colon < 0 ? "" : line.slice(colon + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
switch (field) {
|
||||
case "event":
|
||||
this.event = value || "message";
|
||||
break;
|
||||
case "data":
|
||||
this.dataLines.push(value);
|
||||
break;
|
||||
case "id":
|
||||
this.lastId = value;
|
||||
this.onId?.(value);
|
||||
break;
|
||||
default:
|
||||
break; // retry 等忽略
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(): void {
|
||||
if (this.dataLines.length === 0) return;
|
||||
const ev: SseEvent = {
|
||||
event: this.event,
|
||||
data: this.dataLines.join("\n"),
|
||||
id: this.lastId,
|
||||
};
|
||||
this.event = "message";
|
||||
this.dataLines = [];
|
||||
this.emit(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/** 通过 Gin 流式代理发送(NDJSON 协议) */
|
||||
export async function httpStreamViaGin(
|
||||
ginBase: string,
|
||||
spec: HttpSpec,
|
||||
cb: StreamCallbacks,
|
||||
): Promise<StreamSession> {
|
||||
const controller = new AbortController();
|
||||
cb.onStatus?.("connecting");
|
||||
const started = performance.now();
|
||||
try {
|
||||
const res = await fetch(`${ginBase}/v1/proxy/http/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(spec),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`代理流式请求失败 ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
await pumpNdjson(res.body, cb, started);
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
cb.onStatus?.("closed");
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
cb.onError?.(e instanceof Error ? e.message : String(e));
|
||||
cb.onStatus?.("error");
|
||||
}
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
|
||||
async function pumpNdjson(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
cb: StreamCallbacks,
|
||||
started: number,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let size = 0;
|
||||
let metaSeen = false;
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let ev: {
|
||||
type: string;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
data?: string;
|
||||
message?: string;
|
||||
timeMs?: number;
|
||||
size?: number;
|
||||
};
|
||||
try {
|
||||
ev = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (ev.type) {
|
||||
case "meta":
|
||||
metaSeen = true;
|
||||
cb.onMeta?.({ status: ev.status ?? 0, headers: ev.headers ?? {} });
|
||||
cb.onStatus?.("open");
|
||||
break;
|
||||
case "chunk": {
|
||||
size += ev.size ?? 0;
|
||||
if (ev.data) {
|
||||
const binary = atob(ev.data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
cb.onChunk?.(decoder.decode(bytes, { stream: true }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "end":
|
||||
cb.onEnd?.({ timeMs: ev.timeMs ?? Math.round(performance.now() - started), size: ev.size ?? size });
|
||||
cb.onStatus?.("closed");
|
||||
break;
|
||||
case "error":
|
||||
cb.onError?.(ev.message ?? "代理流错误");
|
||||
cb.onStatus?.("error");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buffer.indexOf("\n")) >= 0) {
|
||||
handleLine(buffer.slice(0, idx));
|
||||
buffer = buffer.slice(idx + 1);
|
||||
}
|
||||
}
|
||||
if (buffer) handleLine(buffer);
|
||||
if (!metaSeen) {
|
||||
cb.onEnd?.({ timeMs: Math.round(performance.now() - started), size });
|
||||
cb.onStatus?.("closed");
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 直连流式 fetch(CORS 允许时) */
|
||||
export async function httpStreamDirect(spec: HttpSpec, cb: StreamCallbacks): Promise<StreamSession> {
|
||||
const controller = new AbortController();
|
||||
cb.onStatus?.("connecting");
|
||||
const started = performance.now();
|
||||
const url = new URL(spec.url);
|
||||
if (spec.query) {
|
||||
for (const [k, v] of Object.entries(spec.query)) {
|
||||
if (v !== "") url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const init: RequestInit = {
|
||||
method: spec.method,
|
||||
headers: { ...spec.headers },
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (spec.body && spec.body.mode !== "none" && spec.method !== "GET") {
|
||||
init.body = spec.body.raw ?? "";
|
||||
}
|
||||
const res = await fetch(url.toString(), init);
|
||||
const headers: Record<string, string> = {};
|
||||
res.headers.forEach((v, k) => {
|
||||
headers[k] = v;
|
||||
});
|
||||
cb.onMeta?.({ status: res.status, headers });
|
||||
cb.onStatus?.("open");
|
||||
|
||||
if (!res.body) {
|
||||
const text = await res.text();
|
||||
cb.onChunk?.(text);
|
||||
cb.onEnd?.({ timeMs: Math.round(performance.now() - started), size: text.length });
|
||||
cb.onStatus?.("closed");
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let size = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value?.length ?? 0;
|
||||
cb.onChunk?.(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
cb.onEnd?.({ timeMs: Math.round(performance.now() - started), size });
|
||||
cb.onStatus?.("closed");
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
cb.onStatus?.("closed");
|
||||
} else {
|
||||
cb.onError?.(e instanceof Error ? e.message : String(e));
|
||||
cb.onStatus?.("error");
|
||||
}
|
||||
}
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
|
||||
export type SseOptions = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
lastEventId?: string;
|
||||
/** 强制走 Gin 代理(目标站 CORS 不允许时) */
|
||||
forceProxy?: boolean;
|
||||
ginBase?: string;
|
||||
onEvent(ev: SseEvent): void;
|
||||
onStatus?(s: StreamStatus, meta?: StreamMeta): void;
|
||||
onError?(msg: string, willFallback: boolean): void;
|
||||
};
|
||||
|
||||
/** SSE 会话:先试直连,失败自动切代理 */
|
||||
export function openSse(opts: SseOptions): StreamSession {
|
||||
const controller = { aborted: false, abortFns: [] as Array<() => void> };
|
||||
const run = async () => {
|
||||
const parser = new SseParser((ev) => opts.onEvent(ev));
|
||||
|
||||
const callbacks: StreamCallbacks = {
|
||||
onMeta: (meta) => opts.onStatus?.("open", meta),
|
||||
onChunk: (text) => parser.feed(text),
|
||||
onEnd: () => {
|
||||
parser.flush();
|
||||
opts.onStatus?.("closed");
|
||||
},
|
||||
onError: (msg) => {
|
||||
opts.onError?.(msg, false);
|
||||
opts.onStatus?.("error");
|
||||
},
|
||||
};
|
||||
|
||||
const specHeaders = { ...opts.headers };
|
||||
if (opts.lastEventId) specHeaders["Last-Event-ID"] = opts.lastEventId;
|
||||
|
||||
if (!opts.forceProxy) {
|
||||
let direct: StreamSession | null = null;
|
||||
let directFailed = false;
|
||||
const directCb: StreamCallbacks = {
|
||||
...callbacks,
|
||||
onError: (msg) => {
|
||||
directFailed = true;
|
||||
opts.onError?.(msg, true);
|
||||
},
|
||||
};
|
||||
direct = await httpStreamDirect(
|
||||
{
|
||||
method: "GET",
|
||||
url: opts.url,
|
||||
headers: specHeaders,
|
||||
query: opts.query,
|
||||
body: { mode: "none" },
|
||||
},
|
||||
directCb,
|
||||
);
|
||||
controller.abortFns.push(() => direct?.abort());
|
||||
// 直连迅速失败(连接层错误)→ 代理兜底
|
||||
if (!directFailed) return;
|
||||
}
|
||||
if (controller.aborted) return;
|
||||
const proxy = await httpStreamViaGin(
|
||||
opts.ginBase ?? "",
|
||||
{
|
||||
method: "GET",
|
||||
url: opts.url,
|
||||
headers: specHeaders,
|
||||
query: opts.query,
|
||||
body: { mode: "none" },
|
||||
},
|
||||
callbacks,
|
||||
);
|
||||
controller.abortFns.push(() => proxy.abort());
|
||||
};
|
||||
void run();
|
||||
return {
|
||||
abort() {
|
||||
controller.aborted = true;
|
||||
for (const fn of controller.abortFns) fn();
|
||||
opts.onStatus?.("closed");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/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 OpsxAPI 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,190 @@
|
||||
/**
|
||||
* 智能 WebSocket:无自定义 Header → 浏览器直连;有 Header → 借道 Gin WS 隧道。
|
||||
*/
|
||||
import type { WsSession, WsSpec } from "@opsxapi/core";
|
||||
|
||||
export type WsTunnelInfo = {
|
||||
mode: "direct" | "tunnel";
|
||||
/** tunnel 模式的会话 id */
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type WsSmartOptions = {
|
||||
/** Gin 基地址(默认同源 /v1 反代) */
|
||||
ginBase?: string;
|
||||
/** 有自定义 header 时强制走隧道(默认自动) */
|
||||
forceTunnel?: boolean;
|
||||
onInfo?(info: WsTunnelInfo): void;
|
||||
};
|
||||
|
||||
type Spec = WsSpec & { headers?: Record<string, string> };
|
||||
|
||||
export function wsSmart(spec: Spec, opts: WsSmartOptions = {}): WsSession {
|
||||
const headers = spec.headers ?? {};
|
||||
const hasCustomHeaders = Object.keys(headers).length > 0;
|
||||
if (hasCustomHeaders || opts.forceTunnel) {
|
||||
return tunnelSession(spec, headers, opts);
|
||||
}
|
||||
return directSession(spec);
|
||||
}
|
||||
|
||||
function directSession(spec: Spec): WsSession {
|
||||
const protocols = spec.protocols?.filter(Boolean);
|
||||
const socket = new WebSocket(spec.url, protocols?.length ? protocols : undefined);
|
||||
return wrapSocket(socket);
|
||||
}
|
||||
|
||||
function wrapSocket(socket: WebSocket): WsSession {
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
socket.onopen = () => emitStatus("open");
|
||||
socket.onclose = () => emitStatus("closed");
|
||||
socket.onerror = () => emitStatus("error");
|
||||
socket.onmessage = (ev) => {
|
||||
let data: string;
|
||||
if (typeof ev.data === "string") {
|
||||
data = ev.data;
|
||||
} else if (ev.data instanceof ArrayBuffer) {
|
||||
data = new TextDecoder().decode(ev.data);
|
||||
} else if (ev.data instanceof Blob) {
|
||||
// Blob 异步读,转同步回调
|
||||
void ev.data.text().then((t) => messageCbs.forEach((cb) => cb(t)));
|
||||
return;
|
||||
} else {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function tunnelSession(spec: Spec, headers: Record<string, string>, opts: WsSmartOptions): WsSession {
|
||||
const ginBase = opts.ginBase ?? "";
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const wsBase = ginBase
|
||||
.replace(/^http:\/\//i, "ws://")
|
||||
.replace(/^https:\/\//i, "wss://");
|
||||
|
||||
emitStatus("connecting");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`${ginBase}/v1/proxy/ws/connect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: spec.url,
|
||||
headers,
|
||||
protocols: spec.protocols ?? [],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`WS 隧道连接失败 ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await res.json()) as { sessionId: string };
|
||||
sessionId = data.sessionId;
|
||||
if (disposed) {
|
||||
void closeTunnel(ginBase, sessionId);
|
||||
return;
|
||||
}
|
||||
opts.onInfo?.({ mode: "tunnel", sessionId });
|
||||
|
||||
socket = new WebSocket(`${wsBase}/v1/proxy/ws/attach?sessionId=${encodeURIComponent(sessionId)}`);
|
||||
socket.onmessage = (ev) => {
|
||||
if (typeof ev.data !== "string") return;
|
||||
// 隧道控制帧(json)与透传数据区分
|
||||
if (ev.data.startsWith('{"type":"open"}')) {
|
||||
emitStatus("open");
|
||||
return;
|
||||
}
|
||||
if (ev.data.startsWith('{"type":"closed"')) {
|
||||
emitStatus("closed");
|
||||
return;
|
||||
}
|
||||
messageCbs.forEach((cb) => cb(ev.data as string));
|
||||
};
|
||||
socket.onclose = () => emitStatus("closed");
|
||||
socket.onerror = () => emitStatus("error");
|
||||
} catch (e) {
|
||||
messageCbs.forEach((cb) => cb(`[ws tunnel] ${e instanceof Error ? e.message : String(e)}`));
|
||||
emitStatus("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data);
|
||||
} else {
|
||||
messageCbs.forEach((cb) => cb("[ws tunnel] 未连接,无法发送"));
|
||||
}
|
||||
},
|
||||
close() {
|
||||
disposed = true;
|
||||
socket?.close();
|
||||
if (sessionId) void closeTunnel(ginBase, sessionId);
|
||||
emitStatus("closed");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function closeTunnel(ginBase: string, sessionId: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${ginBase}/v1/proxy/ws/close`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
} catch {
|
||||
/* 后台会话回收兜底 */
|
||||
}
|
||||
}
|
||||
|
||||
/** Gin 是否可达(用于 UI 提示) */
|
||||
let ginProbeCache: { base: string; ok: boolean; at: number } | null = null;
|
||||
|
||||
export async function probeGin(base = "", maxAgeMs = 15_000): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
if (ginProbeCache && now - ginProbeCache.at < maxAgeMs && ginProbeCache.base === base) {
|
||||
return ginProbeCache.ok;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) });
|
||||
const ok = res.ok;
|
||||
ginProbeCache = { base, ok, at: now };
|
||||
return ok;
|
||||
} catch {
|
||||
ginProbeCache = { base, ok: false, at: now };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user