初始化web服务功能
This commit is contained in:
11
src/lib/transport/detect.ts
Normal file
11
src/lib/transport/detect.ts
Normal 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"
|
||||
}
|
||||
33
src/lib/transport/index.ts
Normal file
33
src/lib/transport/index.ts
Normal 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()
|
||||
}
|
||||
23
src/lib/transport/tauri-transport.ts
Normal file
23
src/lib/transport/tauri-transport.ts
Normal 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
|
||||
}
|
||||
}
|
||||
22
src/lib/transport/types.ts
Normal file
22
src/lib/transport/types.ts
Normal 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
|
||||
}
|
||||
130
src/lib/transport/web-transport.ts
Normal file
130
src/lib/transport/web-transport.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user