feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 files changed
+1322534

No files matched your search

+104
View File
@@ -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)
}
}
+233
View File
@@ -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')
}
}
+176
View File
@@ -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,
)
}
}