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

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,504 @@
import type { InferredClientModules } from '../modules'
import { buildModuleStructure } from '../modules'
import type { BaseUrlConfig, ClientConfig } from '../types/client'
import type { RequestContext, RequestOptions } from '../types/request'
import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload'
import type { AbstractFeature } from './abstract-feature'
import type { AbstractModule } from './abstract-module'
import type { AbstractSyncClient } from './abstract-sync'
import { AbstractUploadClient } from './abstract-upload-client'
import type { AbstractWebSocketClient } from './abstract-websocket'
import { ModrinthApiError, ModrinthServerError } from './errors'
type ArchonClientModules = Omit<InferredClientModules['archon'], 'backups_v1'> & {
/** @deprecated Use `backups_queue_v1` for the Backups Queue API. */
backups_v1: InferredClientModules['archon']['backups_v1']
}
/**
* Abstract base client for Modrinth APIs
*/
export abstract class AbstractModrinthClient extends AbstractUploadClient {
protected config: ClientConfig
protected features: AbstractFeature[]
/**
* Maps full module ID (e.g., 'labrinth_projects_v2') to instantiated module
*/
private _moduleInstances: Map<string, AbstractModule> = new Map()
/**
* Maps API name (e.g., 'labrinth') to namespace object
*/
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
public readonly labrinth!: InferredClientModules['labrinth']
public readonly archon!: ArchonClientModules & {
sockets: AbstractWebSocketClient
sync: AbstractSyncClient
}
public readonly kyros!: InferredClientModules['kyros']
public readonly iso3166!: InferredClientModules['iso3166']
public readonly mclogs!: InferredClientModules['mclogs']
public readonly launchermeta!: InferredClientModules['launchermeta']
public readonly paper!: InferredClientModules['paper']
public readonly purpur!: InferredClientModules['purpur']
constructor(config: ClientConfig) {
super()
this.config = {
timeout: 10000,
labrinthBaseUrl: 'https://api.modrinth.com',
archonBaseUrl: 'https://archon.modrinth.com',
...config,
}
this.features = config.features ?? []
this.initializeModules()
}
/**
* This creates the nested API structure (e.g., client.labrinth.projects_v2)
* but doesn't instantiate modules until first access
*
* Module IDs in the registry are validated at runtime to ensure they match
* what the module declares via getModuleID().
*/
private initializeModules(): void {
const structure = buildModuleStructure()
for (const [api, modules] of Object.entries(structure)) {
const namespaceObj: Record<string, AbstractModule> = {}
// Define lazy getters for each module
for (const [moduleName, ModuleConstructor] of Object.entries(modules)) {
const fullModuleId = `${api}_${moduleName}`
Object.defineProperty(namespaceObj, moduleName, {
get: () => {
// Lazy instantiation
if (!this._moduleInstances.has(fullModuleId)) {
const instance = new ModuleConstructor(this)
// Validate the module ID matches what we expect
const declaredId = instance.getModuleID()
if (declaredId !== fullModuleId) {
throw new Error(
`Module ID mismatch: registry expects "${fullModuleId}" but module declares "${declaredId}"`,
)
}
this._moduleInstances.set(fullModuleId, instance)
}
return this._moduleInstances.get(fullModuleId)!
},
enumerable: true,
configurable: false,
})
}
// Assign namespace to client (e.g., this.labrinth = namespaceObj)
// defineProperty bypasses readonly restriction
Object.defineProperty(this, api, {
value: namespaceObj,
writable: false,
enumerable: true,
configurable: false,
})
this._moduleNamespaces.set(api, namespaceObj)
}
}
/**
* Make a request to the API
*
* @param path - API path (e.g., '/project/sodium')
* @param options - Request options
* @returns Promise resolving to the response data
* @throws {ModrinthApiError} When the request fails or features throw errors
*/
async request<T>(path: string, options: RequestOptions): Promise<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 defaultHeaders = await this.buildDefaultHeaders()
// Merge options with defaults
const mergedOptions: RequestOptions = {
method: 'GET',
timeout: this.config.timeout,
...options,
headers: {
...defaultHeaders,
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const headers = mergedOptions.headers
if (headers && 'Content-Type' in headers && headers['Content-Type'] === '') {
delete headers['Content-Type']
}
const context = this.buildContext(url, path, mergedOptions)
try {
const result = await this.executeFeatureChain<T>(context)
await this.config.hooks?.onResponse?.(result, context)
return result
} catch (error) {
const apiError = this.normalizeError(error, context)
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}
}
async stream(path: string, options: RequestOptions): Promise<ReadableStream<Uint8Array>> {
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 defaultHeaders = await this.buildDefaultHeaders()
const mergedOptions: RequestOptions = {
method: 'GET',
retry: false,
circuitBreaker: false,
...options,
headers: {
...defaultHeaders,
Accept: 'text/event-stream',
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const context = this.buildContext(url, path, mergedOptions)
try {
return await this.executeFeatureChain<ReadableStream<Uint8Array>>(context, () =>
this.executeStreamRequest(context.url, context.options),
)
} catch (error) {
const apiError = this.normalizeError(error, context)
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}
}
/**
* Execute the feature chain and the actual request
*
* Features are executed in order, with each feature calling next() to continue.
* The last "feature" in the chain is the actual request execution.
*/
protected async executeFeatureChain<T>(
context: RequestContext,
executeTerminal: () => Promise<T> = () => this.executeRequest<T>(context.url, context.options),
): Promise<T> {
// Filter to only features that should apply
const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context))
// Build the feature chain
// We work backwards from the actual request, wrapping each feature around the previous
let index = applicableFeatures.length
const next = async (): Promise<T> => {
index--
if (index >= 0) {
// Execute the next feature in the chain
const feature = applicableFeatures[index]
return feature.execute(next, context)
} else {
// We've reached the end of the chain, execute the actual request
await this.config.hooks?.onRequest?.(context)
return executeTerminal()
}
}
return next()
}
/**
* Execute the feature chain for an upload
*
* Similar to executeFeatureChain but calls executeXHRUpload at the end.
* This allows features (auth, retry, etc.) to wrap the upload execution.
*/
protected async executeUploadFeatureChain<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T> {
const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context))
let index = applicableFeatures.length
const next = async (): Promise<T> => {
index--
if (index >= 0) {
return applicableFeatures[index].execute(next, context)
} else {
await this.config.hooks?.onRequest?.(context)
return this.executeXHRUpload<T>(context, progressCallbacks, abortController)
}
}
return next()
}
/**
* Build the full URL for a request
*/
protected buildUrl(path: string, baseUrl: string, version: number | 'internal' | string): string {
// Remove trailing slash from base URL
const base = baseUrl.replace(/\/$/, '')
// Build version path
let versionPath = ''
if (version === 'internal') {
versionPath = '/_internal'
} else if (typeof version === 'number') {
versionPath = `/v${version}`
} else if (typeof version === 'string') {
// Custom version string (e.g., 'v0', 'modrinth/v0')
versionPath = `/${version}`
}
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${base}${versionPath}${cleanPath}`
}
protected resolveBaseUrl(baseUrl: BaseUrlConfig): string {
return typeof baseUrl === 'function' ? baseUrl() : baseUrl
}
/**
* Build the request context
*/
protected buildContext(url: string, path: string, options: RequestOptions): RequestContext {
return {
url,
path,
options,
attempt: 1,
startTime: Date.now(),
}
}
/**
* Build context for an upload request
*
* Sets metadata.isUpload = true so features can detect uploads.
* Supports both single file uploads and FormData uploads.
*/
protected buildUploadContext(
url: string,
path: string,
options: UploadRequestOptions,
): RequestContext {
let metadata: UploadMetadata
let body: File | Blob | FormData
if ('formData' in options && options.formData) {
metadata = {
isUpload: true,
formData: options.formData,
onProgress: options.onProgress,
}
body = options.formData
} else if ('file' in options && options.file) {
metadata = {
isUpload: true,
file: options.file,
onProgress: options.onProgress,
}
body = options.file
} else {
throw new Error('Upload options must include either file or formData')
}
return {
url,
path,
options: {
...options,
method: 'POST',
body,
},
attempt: 1,
startTime: Date.now(),
metadata,
}
}
/**
* Build default headers for all requests
*
* Subclasses can override this to add platform-specific headers
* (e.g., Nuxt rate limit key)
*/
protected async buildDefaultHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...this.config.headers,
}
const userAgent = await this.resolveUserAgent()
if (userAgent) {
headers['User-Agent'] = userAgent
}
return headers
}
private async resolveUserAgent(): Promise<string | undefined> {
const userAgent = this.config.userAgent
return typeof userAgent === 'function' ? await userAgent() : userAgent
}
protected attachArchonSentryCaptureHeader(options: RequestOptions): void {
if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) {
return
}
options.headers['modrinth-sentry-capture'] = '1'
}
private shouldCaptureArchonRequests(): boolean {
const archonSentryCapture = this.config.archonSentryCapture
return typeof archonSentryCapture === 'function'
? archonSentryCapture()
: archonSentryCapture === true
}
/**
* Execute the actual HTTP request
*
* This must be implemented by platform-specific clients.
*
* @param url - Full URL to request
* @param options - Request options
* @returns Promise resolving to the response data
* @throws {Error} Platform-specific errors that will be normalized by normalizeError()
*/
protected abstract executeRequest<T>(url: string, options: RequestOptions): Promise<T>
protected abstract executeStreamRequest(
url: string,
options: RequestOptions,
): Promise<ReadableStream<Uint8Array>>
/**
* Execute the actual XHR upload
*
* This must be implemented by platform clients that support uploads.
* Called at the end of the upload feature chain.
*
* @param context - Request context with upload metadata
* @param progressCallbacks - Callbacks to invoke on progress events
* @param abortController - Controller for cancellation
* @returns Promise resolving to the response data
*/
protected abstract executeXHRUpload<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T>
/**
* Normalize an error into a ModrinthApiError
*
* Platform implementations should override this to handle platform-specific errors
* (e.g., FetchError from ofetch, Tauri HTTP errors)
*/
protected normalizeError(error: unknown, context?: RequestContext): ModrinthApiError {
if (error instanceof ModrinthApiError) {
return error
}
return ModrinthApiError.fromUnknown(error, context?.path)
}
/**
* Helper to create a normalized error from extracted status code and response data
*/
protected createNormalizedError(
error: Error,
statusCode: number | undefined,
responseData: unknown,
): ModrinthApiError {
if (statusCode && responseData) {
return ModrinthServerError.fromResponse(statusCode, responseData)
}
return new ModrinthApiError(error.message, {
statusCode,
originalError: error,
responseData,
})
}
/**
* Add a feature to this client
*
* Features are executed in the order they are added.
*
* @example
* ```typescript
* const client = new GenericModrinthClient()
* client.addFeature(new AuthFeature({ token: async () => getOAuthToken() }))
* client.addFeature(new RetryFeature({ maxAttempts: 3 }))
* ```
*/
addFeature(feature: AbstractFeature): this {
this.features.push(feature)
return this
}
/**
* Remove a feature from this client
*
* @example
* ```typescript
* const retryFeature = new RetryFeature({ maxAttempts: 3 })
* client.addFeature(retryFeature)
* // Later, remove it
* client.removeFeature(retryFeature)
* ```
*/
removeFeature(feature: AbstractFeature): this {
const index = this.features.indexOf(feature)
if (index !== -1) {
this.features.splice(index, 1)
}
return this
}
/**
* Get all features on this client
*/
getFeatures(): AbstractFeature[] {
return [...this.features]
}
}

View File

@ -0,0 +1,91 @@
import type { RequestContext } from '../types/request'
/**
* Base configuration for features
*/
export interface FeatureConfig {
/**
* Optional name for this feature (for debugging)
*/
name?: string
/**
* Whether this feature is enabled
* @default true
*/
enabled?: boolean
}
/**
* Abstract base class for request features
*
* Features are composable middleware that can intercept and modify requests.
* They are executed in a chain, with each feature calling next() to continue the chain.
*/
export abstract class AbstractFeature {
protected config: FeatureConfig
constructor(config?: FeatureConfig) {
this.config = {
enabled: true,
...config,
}
}
/**
* Execute the feature logic
*
* @param next - Function to call the next feature in the chain (or the actual request)
* @param context - Full request context
* @returns Promise resolving to the response data
*
* @example
* ```typescript
* async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
* // Do something before request
* console.log('Before request:', context.url)
*
* try {
* const result = await next()
*
* // Do something after successful request
* console.log('Request succeeded')
*
* return result
* } catch (error) {
* // Handle errors
* console.error('Request failed:', error)
* throw error
* }
* }
* ```
*/
abstract execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T>
/**
* Determine if this feature should apply to the given request
*
* By default, features apply if they are enabled.
* Override this to add custom logic (e.g., only apply to GET requests).
*
* @param context - Request context
* @returns true if the feature should execute, false to skip
*/
shouldApply(_context: RequestContext): boolean {
return this.config.enabled !== false
}
/**
* Get the name of this feature (for debugging)
*/
get name(): string {
return this.config.name ?? this.constructor.name
}
/**
* Check if this feature is enabled
*/
get enabled(): boolean {
return this.config.enabled !== false
}
}

View File

@ -0,0 +1,15 @@
import type { AbstractModrinthClient } from './abstract-client'
export abstract class AbstractModule {
protected client: AbstractModrinthClient
public constructor(client: AbstractModrinthClient) {
this.client = client
}
/**
* Get the module's name, used for error reporting & for module field generation.
* @returns Module name
*/
public abstract getModuleID(): string
}

View File

@ -0,0 +1,167 @@
import type mitt from 'mitt'
import type { Archon } from '../modules/archon/types'
import type { RequestOptions } from '../types/request'
export type SyncEventType = Archon.Sync.v1.SyncEvent['type']
export type SyncEventOfType<E extends SyncEventType> = Extract<
Archon.Sync.v1.SyncEvent,
{ type: E }
>
export type SyncEventHandler<E extends Archon.Sync.v1.SyncEvent = Archon.Sync.v1.SyncEvent> = (
event: E,
) => void
export type SyncStatusState =
| 'idle'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'disconnected'
| 'error'
export type SyncStatus = {
state: SyncStatusState
connected: boolean
reconnecting: boolean
reconnectAttempts: number
retryDelay: number
lastEventId?: string
error?: unknown
}
export type SyncStatusHandler = (status: SyncStatus) => void
export type SyncConnectOptions = {
intent?: Archon.Sync.v1.SyncIntent
force?: boolean
}
export type SyncConnection = {
serverId: string
intent: Archon.Sync.v1.SyncIntent
controller?: AbortController
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
reconnectResolve?: () => void
retryDelay: number
lastEventId?: string
stopped: boolean
status: SyncStatusState
error?: unknown
}
export type SyncEmitterEvents = Record<string, unknown>
export abstract class AbstractSyncClient {
protected connections = new Map<string, SyncConnection>()
protected abstract emitter: ReturnType<typeof mitt<SyncEmitterEvents>>
constructor(
protected client: {
stream: (path: string, options: RequestOptions) => Promise<ReadableStream<Uint8Array>>
},
) {}
abstract safeConnectServer(serverId: string, options?: SyncConnectOptions): Promise<void>
abstract disconnect(serverId: string): void
abstract disconnectAll(): void
on<E extends SyncEventType>(
serverId: string,
eventType: E,
handler: SyncEventHandler<SyncEventOfType<E>>,
): () => void {
const eventKey = this.getEventKey(serverId, eventType)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
onAny(serverId: string, handler: SyncEventHandler): () => void {
const eventKey = this.getAnyEventKey(serverId)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
onStatus(serverId: string, handler: SyncStatusHandler): () => void {
const eventKey = this.getStatusEventKey(serverId)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
getStatus(serverId: string): SyncStatus | null {
const connection = this.connections.get(serverId)
if (!connection) return null
return this.connectionToStatus(connection)
}
protected emitSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent): void {
this.emitter.emit(this.getEventKey(serverId, event.type), event)
this.emitter.emit(this.getAnyEventKey(serverId), event)
}
protected updateStatus(
connection: SyncConnection,
status: SyncStatusState,
error?: unknown,
): void {
connection.status = status
connection.error = error
this.emitter.emit(
this.getStatusEventKey(connection.serverId),
this.connectionToStatus(connection),
)
}
protected clearListeners(serverId: string): void {
this.emitter.all.forEach((_handlers, type) => {
if (type.toString().startsWith(`${serverId}:`)) {
this.emitter.all.delete(type)
}
})
}
protected connectionToStatus(connection: SyncConnection): SyncStatus {
return {
state: connection.status,
connected: connection.status === 'connected',
reconnecting: connection.status === 'reconnecting',
reconnectAttempts: connection.reconnectAttempts,
retryDelay: connection.retryDelay,
lastEventId: connection.lastEventId,
error: connection.error,
}
}
private getEventKey(serverId: string, eventType: string): string {
return `${serverId}:${eventType}`
}
private getAnyEventKey(serverId: string): string {
return `${serverId}:*`
}
private getStatusEventKey(serverId: string): string {
return `${serverId}:__status`
}
}

View File

@ -0,0 +1,21 @@
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
/**
* Abstract base class defining upload capability
*
* All clients that support file uploads must extend this class.
* Platform-specific implementations should provide the actual upload mechanism
* (e.g., XHR for browser environments).
*
* Upload goes through the feature chain (auth, retry, circuit-breaker, etc.)
* just like regular requests.
*/
export abstract class AbstractUploadClient {
/**
* Upload a file or FormData with progress tracking
* @param path - API path (e.g., '/fs/create')
* @param options - Upload options including file or formData, api, version
* @returns UploadHandle with promise, onProgress chain, and cancel method
*/
abstract upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T>
}

View File

@ -0,0 +1,104 @@
import type mitt from 'mitt'
import type { Archon } from '../modules/archon/types'
export type WebSocketEventHandler<
E extends Archon.Websocket.v0.WSEvent = Archon.Websocket.v0.WSEvent,
> = (event: E) => void
export interface WebSocketConnection {
serverId: string
socket: WebSocket
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
isReconnecting: boolean
}
export interface WebSocketStatus {
connected: boolean
reconnecting: boolean
reconnectAttempts: number
}
type WSEventMap = {
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
}
export abstract class AbstractWebSocketClient {
protected connections = new Map<string, WebSocketConnection>()
protected abstract emitter: ReturnType<typeof mitt<WSEventMap>>
protected readonly MAX_RECONNECT_ATTEMPTS = 10
protected readonly RECONNECT_BASE_DELAY = 1000
protected readonly RECONNECT_MAX_DELAY = 30000
constructor(
protected client: {
archon: {
servers_v0: {
getWebSocketAuth: (serverId: string) => Promise<Archon.Websocket.v0.WSAuth>
}
}
},
) {}
abstract connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void>
abstract disconnect(serverId: string): void
abstract disconnectAll(): void
abstract send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void
async safeConnect(serverId: string, options?: { force?: boolean }): Promise<void> {
const status = this.getStatus(serverId)
if (status?.connected && !options?.force) {
return
}
if (status && !status.connected && !options?.force) {
return
}
if (options?.force && status) {
this.disconnect(serverId)
}
const auth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
await this.connect(serverId, auth)
}
on<E extends Archon.Websocket.v0.WSEventType>(
serverId: string,
eventType: E,
handler: WebSocketEventHandler<Extract<Archon.Websocket.v0.WSEvent, { event: E }>>,
): () => void {
const eventKey = `${serverId}:${eventType}` as keyof WSEventMap
this.emitter.on(eventKey, handler as () => void)
return () => {
this.emitter.off(eventKey, handler as () => void)
}
}
getStatus(serverId: string): WebSocketStatus | null {
const connection = this.connections.get(serverId)
if (!connection) return null
return {
connected: connection.socket.readyState === WebSocket.OPEN,
reconnecting: connection.isReconnecting,
reconnectAttempts: connection.reconnectAttempts,
}
}
protected getReconnectDelay(attempt: number): number {
const delay = Math.min(
this.RECONNECT_BASE_DELAY * Math.pow(2, attempt),
this.RECONNECT_MAX_DELAY,
)
return delay + Math.random() * 1000
}
}

View File

@ -0,0 +1,142 @@
import type { ApiErrorData, ModrinthErrorResponse } from '../types/errors'
import { isModrinthErrorResponse } from '../types/errors'
/**
* Base error class for all Modrinth API errors
*/
export class ModrinthApiError extends Error {
/**
* HTTP status code (if available)
*/
readonly statusCode?: number
/**
* Original error that was caught
*/
readonly originalError?: Error
/**
* Response data from the API (if available)
*/
readonly responseData?: unknown
/**
* Error context (e.g., module name, operation being performed)
*/
readonly context?: string
constructor(message: string, data?: ApiErrorData) {
super(message)
this.name = 'ModrinthApiError'
this.statusCode = data?.statusCode
this.originalError = data?.originalError
this.responseData = data?.responseData
this.context = data?.context
// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ModrinthApiError)
}
}
/**
* Create a ModrinthApiError from an unknown error
*/
static fromUnknown(error: unknown, context?: string): ModrinthApiError {
if (error instanceof ModrinthApiError) {
return error
}
if (error instanceof Error) {
return new ModrinthApiError(error.message, {
originalError: error,
context,
})
}
return new ModrinthApiError(String(error), { context })
}
}
/**
* Error class for Modrinth server errors (kyros/archon)
* Extends ModrinthApiError with V1 error response parsing
*/
export class ModrinthServerError extends ModrinthApiError {
/**
* V1 error information (if available)
*/
readonly v1Error?: ModrinthErrorResponse
constructor(message: string, data?: ApiErrorData & { v1Error?: ModrinthErrorResponse }) {
// If we have a V1 error, format the message nicely
let errorMessage = message
if (data?.v1Error) {
errorMessage = `[${data.v1Error.error}] ${data.v1Error.description}`
if (data.v1Error.context) {
errorMessage = `${data.v1Error.context}: ${errorMessage}`
}
}
super(errorMessage, data)
this.name = 'ModrinthServerError'
this.v1Error = data?.v1Error
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ModrinthServerError)
}
}
/**
* Create a ModrinthServerError from response data
*/
static fromResponse(
statusCode: number,
responseData: unknown,
context?: string,
): ModrinthServerError {
const v1Error = isModrinthErrorResponse(responseData) ? responseData : undefined
let message = `HTTP ${statusCode}`
if (v1Error) {
message = v1Error.description
} else if (typeof responseData === 'string') {
message = responseData
}
return new ModrinthServerError(message, {
statusCode,
responseData,
context,
v1Error,
})
}
/**
* Create a ModrinthServerError from an unknown error
*/
static fromUnknown(error: unknown, context?: string): ModrinthServerError {
if (error instanceof ModrinthServerError) {
return error
}
if (error instanceof ModrinthApiError) {
return new ModrinthServerError(error.message, {
statusCode: error.statusCode,
originalError: error.originalError,
responseData: error.responseData,
context: context ?? error.context,
})
}
if (error instanceof Error) {
return new ModrinthServerError(error.message, {
originalError: error,
context,
})
}
return new ModrinthServerError(String(error), { context })
}
}