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:
logan committed 2026-09-18 00:16:59 +08:00
commit dec66db5aa
182 files changed
+23377

No files matched your search

+27
View File
@@ -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
}
}
}
+81
View File
@@ -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();
},
};
},
};
+41
View File
@@ -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) };
}
+35
View File
@@ -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);
},
};
}
+29
View File
@@ -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";
+120
View File
@@ -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");
},
};
}
+92
View File
@@ -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");
},
};
}
+351
View File
@@ -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");
},
};
}
+68
View File
@@ -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");
});
},
};
},
};
+190
View File
@@ -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;
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src"
]
}