初始化web服务功能

This commit is contained in:
xintaofei
2026-03-25 14:26:26 +08:00
parent ae70f17d2e
commit ac09d3db9e
99 changed files with 3253 additions and 304 deletions

View File

@@ -0,0 +1,11 @@
export type TransportEnvironment = "tauri" | "web"
export function detectEnvironment(): TransportEnvironment {
if (
typeof window !== "undefined" &&
"__TAURI_INTERNALS__" in window
) {
return "tauri"
}
return "web"
}

View File

@@ -0,0 +1,33 @@
import { detectEnvironment } from "./detect"
import type { Transport } from "./types"
export type { Transport, UnsubscribeFn } from "./types"
let _transport: Transport | null = null
export function getTransport(): Transport {
if (!_transport) {
const env = detectEnvironment()
if (env === "tauri") {
// Use dynamic require to avoid bundling tauri deps in web mode.
// TauriTransport uses dynamic imports internally.
const { TauriTransport } = require("./tauri-transport") as {
TauriTransport: new () => Transport
}
_transport = new TauriTransport()
} else {
const { WebTransport } = require("./web-transport") as {
WebTransport: new (baseUrl: string) => Transport
}
// In web mode, the API is served from the same origin.
// Token is read from localStorage on each request.
const baseUrl = window.location.origin
_transport = new WebTransport(baseUrl)
}
}
return _transport
}
export function isDesktop(): boolean {
return getTransport().isDesktop()
}

View File

@@ -0,0 +1,23 @@
import type { Transport, UnsubscribeFn } from "./types"
export class TauriTransport implements Transport {
async call<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
const { invoke } = await import("@tauri-apps/api/core")
return invoke(command, args)
}
async subscribe<T>(
event: string,
handler: (payload: T) => void
): Promise<UnsubscribeFn> {
const { listen } = await import("@tauri-apps/api/event")
return listen<T>(event, (e) => handler(e.payload))
}
isDesktop(): boolean {
return true
}
}

View File

@@ -0,0 +1,22 @@
export type UnsubscribeFn = () => void
export interface Transport {
/**
* Invoke a backend command (replaces Tauri's invoke()).
*/
call<T>(command: string, args?: Record<string, unknown>): Promise<T>
/**
* Subscribe to a backend event stream (replaces Tauri's listen()).
* Returns an unsubscribe function.
*/
subscribe<T>(
event: string,
handler: (payload: T) => void
): Promise<UnsubscribeFn>
/**
* Whether the app is running in a desktop Tauri environment.
*/
isDesktop(): boolean
}

View File

@@ -0,0 +1,130 @@
import type { Transport, UnsubscribeFn } from "./types"
interface WebEvent {
channel: string
payload: unknown
}
function getToken(): string {
return localStorage.getItem("codeg_token") ?? ""
}
export class WebTransport implements Transport {
private ws: WebSocket | null = null
private handlers = new Map<string, Set<(payload: unknown) => void>>()
private baseUrl: string
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private wsFailCount = 0
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
async call<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
const token = getToken()
const res = await fetch(`${this.baseUrl}/api/${command}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(args ?? {}),
})
if (res.status === 401) {
WebTransport.redirectToLogin()
throw new Error("Unauthorized")
}
if (!res.ok) {
const error = await res.json().catch(() => ({
code: "network_error",
message: `HTTP ${res.status}`,
}))
throw error
}
return res.json()
}
async subscribe<T>(
event: string,
handler: (payload: T) => void
): Promise<UnsubscribeFn> {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set())
}
const wrappedHandler = handler as (payload: unknown) => void
this.handlers.get(event)!.add(wrappedHandler)
// If WS is not connected but we now have a token, connect
if (!this.ws && getToken()) {
this.connectWs()
}
return () => {
this.handlers.get(event)?.delete(wrappedHandler)
}
}
isDesktop(): boolean {
return false
}
private static redirectToLogin() {
if (window.location.pathname.startsWith("/login")) return
localStorage.removeItem("codeg_token")
window.location.href = "/login"
}
private connectWs() {
const token = getToken()
if (!token) return
const wsUrl =
this.baseUrl.replace(/^http/, "ws") +
`/ws/events?token=${encodeURIComponent(token)}`
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
this.wsFailCount = 0
}
this.ws.onmessage = (msg) => {
try {
const event = JSON.parse(msg.data) as WebEvent
const handlers = this.handlers.get(event.channel)
if (handlers) {
for (const h of handlers) {
h(event.payload)
}
}
} catch {
// ignore malformed messages
}
}
this.ws.onclose = () => {
this.ws = null
this.wsFailCount++
if (this.wsFailCount >= 3) {
WebTransport.redirectToLogin()
return
}
this.reconnectTimer = setTimeout(() => this.connectWs(), 3000)
}
this.ws.onerror = () => {
this.ws?.close()
}
}
destroy() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.ws?.close()
this.ws = null
this.handlers.clear()
}
}