feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import { $fetch, FetchError } from 'ofetch'
|
||||
|
||||
import { ModrinthApiError } from '../core/errors'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
|
||||
import { GenericSyncClient } from './sync-generic'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
import { XHRUploadClient } from './xhr-upload-client'
|
||||
|
||||
/**
|
||||
* Generic platform client using ofetch
|
||||
*
|
||||
* This client works in any JavaScript environment (Node.js, browser, workers, etc).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const client = new GenericModrinthClient({
|
||||
* userAgent: 'my-app/1.0.0',
|
||||
* features: [
|
||||
* new AuthFeature({ token: async () => getOAuthToken() }),
|
||||
* new RetryFeature({ maxAttempts: 3 })
|
||||
* ]
|
||||
* })
|
||||
*
|
||||
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
|
||||
* ```
|
||||
*/
|
||||
export class GenericModrinthClient extends XHRUploadClient {
|
||||
constructor(config: ClientConfig) {
|
||||
super(config)
|
||||
|
||||
Object.defineProperty(this.archon, 'sockets', {
|
||||
value: new GenericWebSocketClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
Object.defineProperty(this.archon, 'sync', {
|
||||
value: new GenericSyncClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
try {
|
||||
const response = await $fetch<T>(url, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers,
|
||||
body: options.body as BodyInit,
|
||||
params: options.params as Record<string, string>,
|
||||
timeout: options.timeout,
|
||||
signal: options.signal,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
// ofetch throws FetchError for HTTP errors
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeStreamRequest(
|
||||
url: string,
|
||||
options: RequestOptions,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
try {
|
||||
const response = await fetch(appendRequestParams(url, options.params), {
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers,
|
||||
body: toFetchBody(options.body),
|
||||
signal: options.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw this.createNormalizedError(
|
||||
new Error(`HTTP ${response.status}: ${response.statusText}`),
|
||||
response.status,
|
||||
await parseResponseErrorData(response),
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new ModrinthApiError('Streaming response has no readable body', {
|
||||
statusCode: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
return response.body
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
protected normalizeError(error: unknown): ModrinthApiError {
|
||||
if (error instanceof FetchError) {
|
||||
return this.createNormalizedError(error, error.response?.status, error.data)
|
||||
}
|
||||
|
||||
return super.normalizeError(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { FetchError } from 'ofetch'
|
||||
|
||||
import { ModrinthApiError } from '../core/errors'
|
||||
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
|
||||
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
|
||||
import { GenericSyncClient } from './sync-generic'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
import { XHRUploadClient } from './xhr-upload-client'
|
||||
|
||||
/**
|
||||
* Circuit breaker storage using Nuxt's useState
|
||||
*
|
||||
* This provides cross-request persistence in SSR while also working in client-side.
|
||||
* State is shared between requests in the same Nuxt context.
|
||||
*
|
||||
* Note: useState must be called during initialization (in setup context) and cached,
|
||||
* as it won't work during async operations when the Nuxt context may be lost.
|
||||
*/
|
||||
export class NuxtCircuitBreakerStorage implements CircuitBreakerStorage {
|
||||
private state: Map<string, CircuitBreakerState>
|
||||
|
||||
constructor() {
|
||||
// @ts-expect-error - useState is provided by Nuxt runtime
|
||||
const stateRef = useState<Map<string, CircuitBreakerState>>(
|
||||
'circuit-breaker-state',
|
||||
() => new Map(),
|
||||
)
|
||||
this.state = stateRef.value
|
||||
}
|
||||
|
||||
get(key: string): CircuitBreakerState | undefined {
|
||||
return this.state.get(key)
|
||||
}
|
||||
|
||||
set(key: string, state: CircuitBreakerState): void {
|
||||
this.state.set(key, state)
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
this.state.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuxt-specific configuration
|
||||
*/
|
||||
export interface NuxtClientConfig extends ClientConfig {
|
||||
// TODO: do we want to provide this for tauri+base as well? its not used on app
|
||||
/**
|
||||
* Rate limit key for server-side requests.
|
||||
* This is injected as x-ratelimit-key header on server-side.
|
||||
* Can be a string (for env var) or async function (for CF Secrets Store).
|
||||
*/
|
||||
rateLimitKey?: string | (() => Promise<string | undefined>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuxt platform client using Nuxt's $fetch
|
||||
*
|
||||
* This client is optimized for Nuxt applications and handles SSR/CSR automatically.
|
||||
*
|
||||
* Note: upload() is only available in browser context (CSR). It will throw during SSR.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a Nuxt composable
|
||||
* const config = useRuntimeConfig()
|
||||
*
|
||||
* const client = new NuxtModrinthClient({
|
||||
* userAgent: 'my-nuxt-app/1.0.0',
|
||||
* rateLimitKey: import.meta.server ? config.rateLimitKey : undefined,
|
||||
* features: [
|
||||
* new AuthFeature({
|
||||
* token: async () => getOAuthToken()
|
||||
* }),
|
||||
* new CircuitBreakerFeature({
|
||||
* storage: new NuxtCircuitBreakerStorage()
|
||||
* })
|
||||
* ]
|
||||
* })
|
||||
*
|
||||
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
|
||||
* ```
|
||||
*/
|
||||
export class NuxtModrinthClient extends XHRUploadClient {
|
||||
declare protected config: NuxtClientConfig
|
||||
private rateLimitKeyResolved: string | undefined
|
||||
private rateLimitKeyPromise: Promise<string | undefined> | undefined
|
||||
|
||||
constructor(config: NuxtClientConfig) {
|
||||
super(config)
|
||||
|
||||
Object.defineProperty(this.archon, 'sockets', {
|
||||
value: new GenericWebSocketClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
Object.defineProperty(this.archon, 'sync', {
|
||||
value: new GenericSyncClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the rate limit key, handling both string and async function values.
|
||||
* Results are cached for subsequent calls.
|
||||
*/
|
||||
private async resolveRateLimitKey(): Promise<string | undefined> {
|
||||
if (this.rateLimitKeyResolved !== undefined) {
|
||||
return this.rateLimitKeyResolved
|
||||
}
|
||||
|
||||
const key = this.config.rateLimitKey
|
||||
if (typeof key === 'string') {
|
||||
this.rateLimitKeyResolved = key
|
||||
} else if (typeof key === 'function') {
|
||||
if (!this.rateLimitKeyPromise) {
|
||||
this.rateLimitKeyPromise = key()
|
||||
}
|
||||
this.rateLimitKeyResolved = await this.rateLimitKeyPromise
|
||||
}
|
||||
|
||||
return this.rateLimitKeyResolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Override request to resolve rate limit key before calling super.
|
||||
* This allows async fetching of the key from CF Secrets Store.
|
||||
*/
|
||||
async request<T>(path: string, options: RequestOptions): Promise<T> {
|
||||
// @ts-expect-error - import.meta is provided by Nuxt
|
||||
if (import.meta.server) {
|
||||
await this.resolveRateLimitKey()
|
||||
}
|
||||
return super.request(path, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file with progress tracking
|
||||
*
|
||||
* Note: This method is only available in browser context (CSR).
|
||||
* Calling during SSR will throw an error.
|
||||
*/
|
||||
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
|
||||
// @ts-expect-error - import.meta is provided by Nuxt
|
||||
if (import.meta.server) {
|
||||
throw new ModrinthApiError('upload() is not supported during SSR')
|
||||
}
|
||||
return super.upload(path, options)
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
try {
|
||||
// @ts-expect-error - $fetch is provided by Nuxt
|
||||
const response = await $fetch<T>(url, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers,
|
||||
body: options.body,
|
||||
params: options.params,
|
||||
timeout: options.timeout,
|
||||
signal: options.signal,
|
||||
// @ts-expect-error - import.meta is provided by Nuxt
|
||||
cache: import.meta.server ? undefined : 'no-store',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeStreamRequest(
|
||||
url: string,
|
||||
options: RequestOptions,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
try {
|
||||
const response = await fetch(appendRequestParams(url, options.params), {
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers,
|
||||
body: toFetchBody(options.body),
|
||||
signal: options.signal,
|
||||
// @ts-expect-error - import.meta is provided by Nuxt
|
||||
cache: import.meta.server ? undefined : 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw this.createNormalizedError(
|
||||
new Error(`HTTP ${response.status}: ${response.statusText}`),
|
||||
response.status,
|
||||
await parseResponseErrorData(response),
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new ModrinthApiError('Streaming response has no readable body', {
|
||||
statusCode: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
return response.body
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
protected normalizeError(error: unknown): ModrinthApiError {
|
||||
if (error instanceof FetchError) {
|
||||
return this.createNormalizedError(error, error.response?.status, error.data)
|
||||
}
|
||||
|
||||
return super.normalizeError(error)
|
||||
}
|
||||
|
||||
protected async buildDefaultHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
...(await super.buildDefaultHeaders()),
|
||||
}
|
||||
|
||||
// Use the resolved key (populated by resolveRateLimitKey in request())
|
||||
// @ts-expect-error - import.meta is provided by Nuxt
|
||||
if (import.meta.server && this.rateLimitKeyResolved) {
|
||||
headers['x-ratelimit-key'] = this.rateLimitKeyResolved
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import mitt from 'mitt'
|
||||
|
||||
import {
|
||||
AbstractSyncClient,
|
||||
type SyncConnection,
|
||||
type SyncConnectOptions,
|
||||
type SyncEmitterEvents,
|
||||
} from '../core/abstract-sync'
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
import { type ParsedSseItem, parseSyncEventData, SseParser } from '../utils/sse'
|
||||
|
||||
type StreamReadResult = 'closed' | 'protocol-reconnect'
|
||||
|
||||
const DEFAULT_RETRY_DELAY = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const JITTER_MS = 1000
|
||||
|
||||
export class GenericSyncClient extends AbstractSyncClient {
|
||||
protected emitter = mitt<SyncEmitterEvents>()
|
||||
|
||||
async safeConnectServer(serverId: string, options: SyncConnectOptions = {}): Promise<void> {
|
||||
const existing = this.connections.get(serverId)
|
||||
if (existing && !options.force && !existing.stopped && existing.status !== 'disconnected') {
|
||||
return
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
this.closeConnection(serverId)
|
||||
}
|
||||
|
||||
const connection: SyncConnection = {
|
||||
serverId,
|
||||
intent: options.intent ?? 'all',
|
||||
reconnectAttempts: 0,
|
||||
retryDelay: DEFAULT_RETRY_DELAY,
|
||||
stopped: false,
|
||||
status: 'idle',
|
||||
}
|
||||
|
||||
this.connections.set(serverId, connection)
|
||||
void this.runConnection(connection)
|
||||
}
|
||||
|
||||
disconnect(serverId: string): void {
|
||||
this.closeConnection(serverId)
|
||||
this.clearListeners(serverId)
|
||||
}
|
||||
|
||||
disconnectAll(): void {
|
||||
for (const serverId of this.connections.keys()) {
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
private async runConnection(connection: SyncConnection): Promise<void> {
|
||||
while (!connection.stopped) {
|
||||
const hadConnected = connection.status === 'connected'
|
||||
this.updateStatus(connection, hadConnected ? 'reconnecting' : 'connecting')
|
||||
|
||||
const controller = new AbortController()
|
||||
connection.controller = controller
|
||||
|
||||
try {
|
||||
const stream = await this.client.stream('/sync', {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'GET',
|
||||
params: {
|
||||
scope: `server:${connection.serverId}`,
|
||||
intent: this.intentToParam(connection.intent),
|
||||
},
|
||||
headers: connection.lastEventId
|
||||
? {
|
||||
'Last-Event-Id': connection.lastEventId,
|
||||
}
|
||||
: undefined,
|
||||
signal: controller.signal,
|
||||
retry: false,
|
||||
circuitBreaker: false,
|
||||
})
|
||||
|
||||
if (connection.stopped) return
|
||||
|
||||
connection.reconnectAttempts = 0
|
||||
this.updateStatus(connection, 'connected')
|
||||
|
||||
const result = await this.consumeStream(connection, stream)
|
||||
connection.controller = undefined
|
||||
if (connection.stopped) return
|
||||
|
||||
if (result === 'protocol-reconnect') {
|
||||
connection.reconnectAttempts = 0
|
||||
continue
|
||||
}
|
||||
|
||||
await this.waitForReconnect(connection)
|
||||
} catch (error) {
|
||||
connection.controller = undefined
|
||||
if (connection.stopped || this.isAbortError(error)) return
|
||||
|
||||
connection.reconnectAttempts++
|
||||
this.updateStatus(connection, 'error', error)
|
||||
console.warn(`[Sync] Connection failed for server ${connection.serverId}:`, error)
|
||||
await this.waitForReconnect(connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async consumeStream(
|
||||
connection: SyncConnection,
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
): Promise<StreamReadResult> {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const parser = new SseParser()
|
||||
|
||||
try {
|
||||
while (!connection.stopped) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
const result = this.processParsedItems(connection, parser.feed(chunk))
|
||||
if (result === 'protocol-reconnect') {
|
||||
await reader.cancel()
|
||||
connection.controller?.abort()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
const finalChunk = decoder.decode()
|
||||
const finalItems = finalChunk ? parser.feed(finalChunk) : []
|
||||
const result = this.processParsedItems(connection, [...finalItems, ...parser.end()])
|
||||
if (result === 'protocol-reconnect') {
|
||||
await reader.cancel()
|
||||
connection.controller?.abort()
|
||||
return result
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
private processParsedItems(connection: SyncConnection, items: ParsedSseItem[]): StreamReadResult {
|
||||
for (const item of items) {
|
||||
if (item.kind === 'retry') {
|
||||
connection.retryDelay = Math.min(item.retry, MAX_RECONNECT_DELAY)
|
||||
continue
|
||||
}
|
||||
|
||||
this.updateLastEventId(connection, item.id)
|
||||
|
||||
const event = parseSyncEventData(item.data)
|
||||
if (!event) {
|
||||
console.warn('[Sync] Dropping malformed SSE payload:', {
|
||||
serverId: connection.serverId,
|
||||
event: item.event,
|
||||
data: item.data,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
this.emitSyncEvent(connection.serverId, event)
|
||||
|
||||
if (event.type === 'protocol.reset' || event.type === 'protocol.invalid') {
|
||||
connection.lastEventId = undefined
|
||||
return 'protocol-reconnect'
|
||||
}
|
||||
}
|
||||
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
private async waitForReconnect(connection: SyncConnection): Promise<void> {
|
||||
if (connection.stopped) return
|
||||
|
||||
this.updateStatus(connection, 'reconnecting')
|
||||
const delay = this.getReconnectDelay(connection)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
connection.reconnectResolve = resolve
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
connection.reconnectTimer = undefined
|
||||
connection.reconnectResolve = undefined
|
||||
resolve()
|
||||
}, delay)
|
||||
})
|
||||
}
|
||||
|
||||
private closeConnection(serverId: string): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return
|
||||
|
||||
connection.stopped = true
|
||||
connection.controller?.abort()
|
||||
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer)
|
||||
connection.reconnectTimer = undefined
|
||||
}
|
||||
connection.reconnectResolve?.()
|
||||
connection.reconnectResolve = undefined
|
||||
|
||||
this.updateStatus(connection, 'disconnected')
|
||||
this.connections.delete(serverId)
|
||||
}
|
||||
|
||||
private getReconnectDelay(connection: SyncConnection): number {
|
||||
const exponentialDelay =
|
||||
connection.retryDelay * Math.pow(2, Math.max(connection.reconnectAttempts - 1, 0))
|
||||
return Math.min(exponentialDelay, MAX_RECONNECT_DELAY) + Math.random() * JITTER_MS
|
||||
}
|
||||
|
||||
private updateLastEventId(connection: SyncConnection, id: string | undefined): void {
|
||||
if (id === undefined) return
|
||||
connection.lastEventId = id || undefined
|
||||
}
|
||||
|
||||
private intentToParam(intent: Archon.Sync.v1.SyncIntent): string {
|
||||
return Array.isArray(intent) ? intent.join(',') : intent
|
||||
}
|
||||
|
||||
private isAbortError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
return error.name === 'AbortError' || error.message.toLowerCase().includes('abort')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { ModrinthApiError } from '../core/errors'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
|
||||
import { GenericSyncClient } from './sync-generic'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
import { XHRUploadClient } from './xhr-upload-client'
|
||||
|
||||
/**
|
||||
* Tauri-specific configuration
|
||||
* TODO: extend into interface if needed.
|
||||
*/
|
||||
export type TauriClientConfig = ClientConfig
|
||||
|
||||
/**
|
||||
* Extended error type with HTTP response metadata
|
||||
*/
|
||||
interface HttpError extends Error {
|
||||
statusCode?: number
|
||||
responseData?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri platform client using Tauri v2 HTTP plugin
|
||||
*
|
||||
* Extends XHRUploadClient to provide upload with progress tracking.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { getVersion } from '@tauri-apps/api/app'
|
||||
*
|
||||
* const client = new TauriModrinthClient({
|
||||
* userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`,
|
||||
* features: [
|
||||
* new AuthFeature({ token: async () => getOAuthToken() })
|
||||
* ]
|
||||
* })
|
||||
*
|
||||
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
|
||||
* ```
|
||||
*/
|
||||
export class TauriModrinthClient extends XHRUploadClient {
|
||||
declare protected config: TauriClientConfig
|
||||
|
||||
constructor(config: TauriClientConfig) {
|
||||
super(config)
|
||||
|
||||
Object.defineProperty(this.archon, 'sockets', {
|
||||
value: new GenericWebSocketClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
Object.defineProperty(this.archon, 'sync', {
|
||||
value: new GenericSyncClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
try {
|
||||
// Dynamically import Tauri HTTP plugin
|
||||
// This allows the package to be used in non-Tauri environments
|
||||
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
|
||||
|
||||
const body = toFetchBody(options.body)
|
||||
const fullUrl = appendRequestParams(url, options.params)
|
||||
|
||||
const response = await tauriFetch(fullUrl, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers,
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let responseData: unknown
|
||||
try {
|
||||
responseData = await response.json()
|
||||
} catch {
|
||||
responseData = undefined
|
||||
}
|
||||
|
||||
const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as HttpError
|
||||
|
||||
error.statusCode = response.status
|
||||
error.responseData = responseData
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
// Handle binary downloads (e.g. kyros fs files) before JSON parsing.
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''
|
||||
if (fullUrl.includes('/fs/download')) {
|
||||
return (await response.blob()) as T
|
||||
}
|
||||
if (
|
||||
contentType.startsWith('image/') ||
|
||||
contentType.startsWith('audio/') ||
|
||||
contentType.startsWith('video/') ||
|
||||
contentType.includes('application/octet-stream')
|
||||
) {
|
||||
return (await response.blob()) as T
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.status === 205) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json') || contentType.includes('+json')) {
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
if (!text) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
return text as T
|
||||
}
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeStreamRequest(
|
||||
url: string,
|
||||
options: RequestOptions,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
try {
|
||||
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
|
||||
const response = await tauriFetch(appendRequestParams(url, options.params), {
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers,
|
||||
body: toFetchBody(options.body),
|
||||
signal: options.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw this.createNormalizedError(
|
||||
new Error(`HTTP ${response.status}: ${response.statusText}`),
|
||||
response.status,
|
||||
await parseResponseErrorData(response),
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw this.createNormalizedError(
|
||||
new Error('Streaming response has no readable body'),
|
||||
response.status,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
return response.body
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
protected normalizeError(error: unknown): ModrinthApiError {
|
||||
if (error instanceof Error) {
|
||||
const httpError = error as HttpError
|
||||
const statusCode = httpError.statusCode
|
||||
const responseData = httpError.responseData
|
||||
|
||||
return this.createNormalizedError(error, statusCode, responseData)
|
||||
}
|
||||
|
||||
return super.normalizeError(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import mitt from 'mitt'
|
||||
|
||||
import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket'
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
import { getNodeWebSocketUrl } from '../utils/node-url'
|
||||
|
||||
type WSEventMap = {
|
||||
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
|
||||
}
|
||||
|
||||
const NORMAL_CLOSURE = 1000
|
||||
|
||||
export class GenericWebSocketClient extends AbstractWebSocketClient {
|
||||
protected emitter = mitt<WSEventMap>()
|
||||
|
||||
async connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void> {
|
||||
if (this.connections.has(serverId)) {
|
||||
this.closeConnection(serverId)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(getNodeWebSocketUrl(auth.url))
|
||||
|
||||
const connection: WebSocketConnection = {
|
||||
serverId,
|
||||
socket: ws,
|
||||
reconnectAttempts: 0,
|
||||
reconnectTimer: undefined,
|
||||
isReconnecting: false,
|
||||
}
|
||||
|
||||
this.connections.set(serverId, connection)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ event: 'auth', jwt: auth.token }))
|
||||
|
||||
connection.reconnectAttempts = 0
|
||||
connection.isReconnecting = false
|
||||
|
||||
resolve()
|
||||
}
|
||||
|
||||
ws.onmessage = (messageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
|
||||
|
||||
const eventKey = `${serverId}:${data.event}` as keyof WSEventMap
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.emitter.emit(eventKey, data as any)
|
||||
|
||||
if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') {
|
||||
this.handleAuthExpiring(serverId).catch(console.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to parse message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.debug(`[WebSocket] Closed for server ${serverId}:`, {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean,
|
||||
})
|
||||
if (event.code !== NORMAL_CLOSURE) {
|
||||
this.scheduleReconnect(serverId, auth)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (event) => {
|
||||
const url = ws.url
|
||||
const readyState = ws.readyState
|
||||
console.error(`[WebSocket] Error for server ${serverId}:`, {
|
||||
url,
|
||||
readyState,
|
||||
readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState],
|
||||
type: (event as Event).type,
|
||||
})
|
||||
reject(
|
||||
new Error(
|
||||
`WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(serverId: string): void {
|
||||
this.closeConnection(serverId)
|
||||
|
||||
this.emitter.all.forEach((_handlers, type) => {
|
||||
if (type.toString().startsWith(`${serverId}:`)) {
|
||||
this.emitter.all.delete(type)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private closeConnection(serverId: string): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer)
|
||||
connection.reconnectTimer = undefined
|
||||
}
|
||||
|
||||
if (
|
||||
connection.socket.readyState === WebSocket.OPEN ||
|
||||
connection.socket.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
connection.socket.close(NORMAL_CLOSURE, 'Client disconnecting')
|
||||
}
|
||||
|
||||
this.connections.delete(serverId)
|
||||
}
|
||||
|
||||
disconnectAll(): void {
|
||||
for (const serverId of this.connections.keys()) {
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection || connection.socket.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`Cannot send message: WebSocket not connected for server ${serverId}`)
|
||||
return
|
||||
}
|
||||
connection.socket.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
private scheduleReconnect(serverId: string, auth: Archon.Websocket.v0.WSAuth): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
||||
this.disconnect(serverId)
|
||||
return
|
||||
}
|
||||
|
||||
connection.isReconnecting = true
|
||||
connection.reconnectAttempts++
|
||||
|
||||
const delay = this.getReconnectDelay(connection.reconnectAttempts)
|
||||
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
this.connect(serverId, auth).catch((error) => {
|
||||
console.error(`[WebSocket] Reconnection failed for server ${serverId}:`, error)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private async handleAuthExpiring(serverId: string): Promise<void> {
|
||||
try {
|
||||
const newAuth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
|
||||
|
||||
const connection = this.connections.get(serverId)
|
||||
if (connection && connection.socket.readyState === WebSocket.OPEN) {
|
||||
connection.socket.send(JSON.stringify({ event: 'auth', jwt: newAuth.token }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[WebSocket] Failed to refresh auth for server ${serverId}:`, error)
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { AbstractModrinthClient } from '../core/abstract-client'
|
||||
import { ModrinthApiError } from '../core/errors'
|
||||
import type { RequestContext } from '../types/request'
|
||||
import type {
|
||||
UploadHandle,
|
||||
UploadMetadata,
|
||||
UploadProgress,
|
||||
UploadRequestOptions,
|
||||
} from '../types/upload'
|
||||
|
||||
/**
|
||||
* Abstract client with XHR-based upload implementation
|
||||
*
|
||||
* Platform-specific clients should extend this instead of AbstractModrinthClient
|
||||
* to inherit the XHR upload implementation.
|
||||
*/
|
||||
export abstract class XHRUploadClient extends AbstractModrinthClient {
|
||||
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
|
||||
let baseUrl: string
|
||||
if (options.api === 'labrinth') {
|
||||
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
|
||||
} else if (options.api === 'archon') {
|
||||
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
|
||||
} else {
|
||||
baseUrl = options.api
|
||||
}
|
||||
|
||||
const url = this.buildUrl(path, baseUrl, options.version)
|
||||
|
||||
const progressCallbacks: Array<(p: UploadProgress) => void> = []
|
||||
if (options.onProgress) {
|
||||
progressCallbacks.push(options.onProgress)
|
||||
}
|
||||
const abortController = new AbortController()
|
||||
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener('abort', () => abortController.abort())
|
||||
}
|
||||
|
||||
let context: RequestContext | undefined
|
||||
const handle: UploadHandle<T> = {
|
||||
promise: (async () => {
|
||||
const isFormData = 'formData' in options && options.formData instanceof FormData
|
||||
const baseHeaders = await this.buildDefaultHeaders()
|
||||
if (isFormData) {
|
||||
delete baseHeaders['Content-Type']
|
||||
} else {
|
||||
baseHeaders['Content-Type'] = 'application/octet-stream'
|
||||
}
|
||||
|
||||
const mergedOptions: UploadRequestOptions = {
|
||||
retry: false,
|
||||
...options,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
}
|
||||
this.attachArchonSentryCaptureHeader(mergedOptions)
|
||||
|
||||
const uploadContext = this.buildUploadContext(url, path, mergedOptions)
|
||||
context = uploadContext
|
||||
if (abortController.signal.aborted) {
|
||||
throw new ModrinthApiError('Upload cancelled')
|
||||
}
|
||||
|
||||
const result = await this.executeUploadFeatureChain<T>(
|
||||
uploadContext,
|
||||
progressCallbacks,
|
||||
abortController,
|
||||
)
|
||||
await this.config.hooks?.onResponse?.(result, uploadContext)
|
||||
return result
|
||||
})().catch(async (error) => {
|
||||
const apiError = this.normalizeError(error, context)
|
||||
if (context) {
|
||||
await this.config.hooks?.onError?.(apiError, context)
|
||||
}
|
||||
throw apiError
|
||||
}),
|
||||
onProgress: (callback) => {
|
||||
progressCallbacks.push(callback)
|
||||
return handle
|
||||
},
|
||||
cancel: () => abortController.abort(),
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
protected executeXHRUpload<T>(
|
||||
context: RequestContext,
|
||||
progressCallbacks: Array<(p: UploadProgress) => void>,
|
||||
abortController: AbortController,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const metadata = context.metadata as UploadMetadata
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const progress: UploadProgress = {
|
||||
loaded: e.loaded,
|
||||
total: e.total,
|
||||
progress: e.loaded / e.total,
|
||||
}
|
||||
progressCallbacks.forEach((cb) => cb(progress))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(xhr.response ? JSON.parse(xhr.response) : (undefined as T))
|
||||
} catch {
|
||||
resolve(undefined as T)
|
||||
}
|
||||
} else {
|
||||
reject(this.createUploadError(xhr))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new ModrinthApiError('Upload failed')))
|
||||
xhr.addEventListener('abort', () => reject(new ModrinthApiError('Upload cancelled')))
|
||||
xhr.addEventListener('timeout', () => reject(new ModrinthApiError('Upload timed out')))
|
||||
|
||||
if (context.options.timeout !== undefined) {
|
||||
xhr.timeout = context.options.timeout
|
||||
}
|
||||
|
||||
// build URL with params (unlike $fetch, XHR doesn't handle params automatically)
|
||||
let url = context.url
|
||||
if (context.options.params) {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(context.options.params).map(([k, v]) => [k, String(v)]),
|
||||
).toString()
|
||||
url += (url.includes('?') ? '&' : '?') + queryString
|
||||
}
|
||||
|
||||
xhr.open('POST', url)
|
||||
|
||||
// apply headers from context (features may have modified them)
|
||||
for (const [key, value] of Object.entries(context.options.headers ?? {})) {
|
||||
xhr.setRequestHeader(key, value)
|
||||
}
|
||||
|
||||
// Send either FormData or file depending on what was provided
|
||||
const data = 'formData' in metadata ? metadata.formData : metadata.file
|
||||
xhr.send(data)
|
||||
abortController.signal.addEventListener('abort', () => xhr.abort())
|
||||
})
|
||||
}
|
||||
|
||||
protected createUploadError(xhr: XMLHttpRequest): ModrinthApiError {
|
||||
let responseData: unknown
|
||||
try {
|
||||
responseData = xhr.response ? JSON.parse(xhr.response) : undefined
|
||||
} catch {
|
||||
responseData = xhr.responseText
|
||||
}
|
||||
return this.createNormalizedError(
|
||||
new Error(`Upload failed with status ${xhr.status}`),
|
||||
xhr.status,
|
||||
responseData,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user