feat: modrinth hosting - files tab refactor (#4912)

* feat: api-client module for content v0

* feat: delete unused components + modules + setting

* feat: xhr uploading

* feat: fs module -> api-client

* feat: migrate files.vue to use tanstack

* fix: mem leak + other issues

* fix: build

* feat: switch to monaco

* fix: go back to using ace, but improve preloading + theme

* fix: styling + dead attrs

* feat: match figma

* fix: padding

* feat: files-new for ui page structure

* feat: finalize files.vue

* fix: lint

* fix: qa

* fix: dep

* fix: lint

* fix: lockfile merge

* feat: icons on navtab

* fix: surface alternating on table

* fix: hover surface color

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-01-06 00:35:51 +00:00
committed by GitHub
parent 61d4a34f0f
commit 099011a177
89 changed files with 5863 additions and 2091 deletions

View File

@@ -2,15 +2,17 @@ import type { InferredClientModules } from '../modules'
import { buildModuleStructure } from '../modules'
import type { 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 { AbstractUploadClient } from './abstract-upload-client'
import type { AbstractWebSocketClient } from './abstract-websocket'
import { ModrinthApiError, ModrinthServerError } from './errors'
/**
* Abstract base client for Modrinth APIs
*/
export abstract class AbstractModrinthClient {
export abstract class AbstractModrinthClient extends AbstractUploadClient {
protected config: ClientConfig
protected features: AbstractFeature[]
@@ -30,6 +32,7 @@ export abstract class AbstractModrinthClient {
public readonly iso3166!: InferredClientModules['iso3166']
constructor(config: ClientConfig) {
super()
this.config = {
timeout: 10000,
labrinthBaseUrl: 'https://api.modrinth.com',
@@ -176,6 +179,35 @@ export abstract class AbstractModrinthClient {
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
*/
@@ -212,6 +244,36 @@ export abstract class AbstractModrinthClient {
}
}
/**
* Build context for an upload request
*
* Sets metadata.isUpload = true so features can detect uploads.
*/
protected buildUploadContext(
url: string,
path: string,
options: UploadRequestOptions,
): RequestContext {
const metadata: UploadMetadata = {
isUpload: true,
file: options.file,
onProgress: options.onProgress,
}
return {
url,
path,
options: {
...options,
method: 'POST',
body: options.file,
},
attempt: 1,
startTime: Date.now(),
metadata,
}
}
/**
* Build default headers for all requests
*
@@ -243,6 +305,23 @@ export abstract class AbstractModrinthClient {
*/
protected abstract executeRequest<T>(url: string, options: RequestOptions): Promise<T>
/**
* 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
*

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 with progress tracking
* @param path - API path (e.g., '/fs/create')
* @param options - Upload options including file, 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,149 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
/**
* Node authentication credentials
*/
export interface NodeAuth {
/** Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs") */
url: string
/** JWT token */
token: string
}
export interface NodeAuthConfig extends FeatureConfig {
/**
* Get current node auth. Returns null if not authenticated.
*/
getAuth: () => NodeAuth | null
/**
* Refresh the node authentication token.
*/
refreshAuth: () => Promise<void>
}
/**
* Handles authentication for Kyros node fs requests:
* - Automatically injects Authorization header
* - Builds the correct URL from node instance
* - Handles 401 errors by refreshing and retrying (max 3 times)
*
* Only applies to requests with `useNodeAuth: true` in options.
*
* @example
* ```typescript
* const nodeAuth = new NodeAuthFeature({
* getAuth: () => nodeAuthState.getAuth?.() ?? null,
* refreshAuth: async () => {
* if (nodeAuthState.refreshAuth) {
* await nodeAuthState.refreshAuth()
* }
* },
* })
* client.addFeature(nodeAuth)
* ```
*/
export class NodeAuthFeature extends AbstractFeature {
declare protected config: NodeAuthConfig
private refreshPromise: Promise<void> | null = null
shouldApply(context: RequestContext): boolean {
return context.options.useNodeAuth === true && this.config.enabled !== false
}
private async refreshAuthWithLock(): Promise<void> {
if (this.refreshPromise) {
return this.refreshPromise
}
this.refreshPromise = this.config.refreshAuth().finally(() => {
this.refreshPromise = null
})
return this.refreshPromise
}
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const maxRetries = 3
let retryCount = 0
let auth = this.config.getAuth()
if (!auth || this.isTokenExpired(auth.token)) {
await this.refreshAuthWithLock()
auth = this.config.getAuth()
}
if (!auth) {
throw new Error('Failed to obtain node authentication')
}
this.applyAuth(context, auth)
while (true) {
try {
return await next()
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 401) {
retryCount++
if (retryCount >= maxRetries) {
throw new Error(
`Node authentication failed after ${maxRetries} retries. Please re-authenticate.`,
)
}
await this.refreshAuthWithLock()
auth = this.config.getAuth()
if (!auth) {
throw new Error('Failed to refresh node authentication')
}
this.applyAuth(context, auth)
continue
}
throw error
}
}
}
private applyAuth(context: RequestContext, auth: NodeAuth): void {
const baseUrl = `https://${auth.url.replace('v0/fs', '')}`
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
context.options.headers = {
...context.options.headers,
Authorization: `Bearer ${auth.token}`,
}
context.options.skipAuth = true
}
private buildUrl(path: string, baseUrl: string, version: number | 'internal' | string): string {
const base = baseUrl.replace(/\/$/, '')
let versionPath = ''
if (version === 'internal') {
versionPath = '/_internal'
} else if (typeof version === 'number') {
versionPath = `/v${version}`
} else if (typeof version === 'string') {
versionPath = `/${version}`
}
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${base}${versionPath}${cleanPath}`
}
/**
* Check if a JWT token is expired or about to expire
* Refreshes proactively if expiring within next 10 seconds
*/
private isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
if (!payload.exp) return false
// refresh if expiring within 10 seconds
const expiresAt = payload.exp * 1000
return Date.now() >= expiresAt - 10000
} catch {
// cant decode, assume valid and let server decide
return false
}
}
}

View File

@@ -1,5 +1,6 @@
export { AbstractModrinthClient } from './core/abstract-client'
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
export { AbstractUploadClient } from './core/abstract-upload-client'
export {
AbstractWebSocketClient,
type WebSocketConnection,
@@ -15,6 +16,7 @@ export {
type CircuitBreakerStorage,
InMemoryCircuitBreakerStorage,
} from './features/circuit-breaker'
export { type NodeAuth, type NodeAuthConfig, NodeAuthFeature } from './features/node-auth'
export { PANEL_VERSION, PanelVersionFeature } from './features/panel-version'
export { type BackoffStrategy, type RetryConfig, RetryFeature } from './features/retry'
export { type VerboseLoggingConfig, VerboseLoggingFeature } from './features/verbose-logging'
@@ -25,4 +27,7 @@ export type { NuxtClientConfig } from './platform/nuxt'
export { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt'
export type { TauriClientConfig } from './platform/tauri'
export { TauriModrinthClient } from './platform/tauri'
export { XHRUploadClient } from './platform/xhr-upload-client'
export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/node-auth'
export * from './types'
export { withJWTRetry } from './utils/jwt-retry'

View File

@@ -0,0 +1,56 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonContentV0Module extends AbstractModule {
public getModuleID(): string {
return 'archon_content_v0'
}
/** GET /modrinth/v0/servers/:server_id/mods */
public async list(serverId: string): Promise<Archon.Content.v0.Mod[]> {
return this.client.request<Archon.Content.v0.Mod[]>(`/servers/${serverId}/mods`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/** POST /modrinth/v0/servers/:server_id/mods */
public async install(
serverId: string,
request: Archon.Content.v0.InstallModRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/mods`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
/** POST /modrinth/v0/servers/:server_id/deleteMod */
public async delete(
serverId: string,
request: Archon.Content.v0.DeleteModRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/deleteMod`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
/** POST /modrinth/v0/servers/:server_id/mods/update */
public async update(
serverId: string,
request: Archon.Content.v0.UpdateModRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/mods/update`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
}

View File

@@ -1,5 +1,6 @@
export * from './backups/v0'
export * from './backups/v1'
export * from './content/v0'
export * from './servers/v0'
export * from './servers/v1'
export * from './types'

View File

@@ -78,4 +78,20 @@ export class ArchonServersV0Module extends AbstractModule {
method: 'GET',
})
}
/**
* Send a power action to a server (Start, Stop, Restart, Kill)
* POST /modrinth/v0/servers/:id/power
*/
public async power(
serverId: string,
action: 'Start' | 'Stop' | 'Restart' | 'Kill',
): Promise<void> {
await this.client.request(`/servers/${serverId}/power`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
body: { action },
})
}
}

View File

@@ -1,4 +1,40 @@
export namespace Archon {
export namespace Content {
export namespace v0 {
export type ContentKind = 'mod' | 'plugin'
export type Mod = {
filename: string
project_id: string | undefined
version_id: string | undefined
name: string | undefined
version_number: string | undefined
icon_url: string | undefined
owner: string | undefined
disabled: boolean
installing: boolean
}
export type InstallModRequest = {
rinth_ids: {
project_id: string
version_id: string
}
install_as: ContentKind
}
export type DeleteModRequest = {
path: string
}
export type UpdateModRequest = {
replace: string
project_id: string
version_id: string
}
}
}
export namespace Servers {
export namespace v0 {
export type ServerGetResponse = {
@@ -274,6 +310,11 @@ export namespace Archon {
started: string
}
export type QueuedFilesystemOp = {
op: FilesystemOpKind
src: string
}
export type WSFilesystemOpsEvent = {
event: 'filesystem-ops'
all: FilesystemOperation[]

View File

@@ -2,6 +2,7 @@ import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module'
import { ArchonBackupsV0Module } from './archon/backups/v0'
import { ArchonBackupsV1Module } from './archon/backups/v1'
import { ArchonContentV0Module } from './archon/content/v0'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
@@ -28,6 +29,7 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
export const MODULE_REGISTRY = {
archon_backups_v0: ArchonBackupsV0Module,
archon_backups_v1: ArchonBackupsV1Module,
archon_content_v0: ArchonContentV0Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
iso3166_data: ISO3166Module,

View File

@@ -1,4 +1,6 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export class KyrosFilesV0Module extends AbstractModule {
public getModuleID(): string {
@@ -6,47 +8,189 @@ export class KyrosFilesV0Module extends AbstractModule {
}
/**
* Download a file from a server's filesystem
* List directory contents with pagination
*
* @param nodeInstance - Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs")
* @param nodeToken - JWT token from getFilesystemAuth
* @param path - File path (e.g., "/server-icon-original.png")
* @returns Promise resolving to file Blob
* @param path - Directory path (e.g., "/")
* @param page - Page number (1-indexed)
* @param pageSize - Items per page
* @returns Directory listing with items and pagination info
*/
public async downloadFile(nodeInstance: string, nodeToken: string, path: string): Promise<Blob> {
return this.client.request<Blob>(`/fs/download`, {
api: `https://${nodeInstance.replace('v0/fs', '')}`,
method: 'GET',
public async listDirectory(
path: string,
page: number = 1,
pageSize: number = 100,
): Promise<Kyros.Files.v0.DirectoryResponse> {
return this.client.request<Kyros.Files.v0.DirectoryResponse>('/fs/list', {
api: '',
version: 'v0',
params: { path },
headers: { Authorization: `Bearer ${nodeToken}` },
method: 'GET',
params: { path, page, page_size: pageSize },
useNodeAuth: true,
})
}
/**
* Upload a file to a server's filesystem
* Create a file or directory
*
* @param path - Path for new item (e.g., "/new-folder")
* @param type - Type of item to create
*/
public async createFileOrFolder(path: string, type: 'file' | 'directory'): Promise<void> {
return this.client.request<void>('/fs/create', {
api: '',
version: 'v0',
method: 'POST',
params: { path, type },
headers: { 'Content-Type': 'application/octet-stream' },
useNodeAuth: true,
})
}
/**
* Download a file from a server's filesystem
*
* @param path - File path (e.g., "/server-icon-original.png")
* @returns Promise resolving to file Blob
*/
public async downloadFile(path: string): Promise<Blob> {
return this.client.request<Blob>('/fs/download', {
api: '',
version: 'v0',
method: 'GET',
params: { path },
useNodeAuth: true,
})
}
/**
* Upload a file to a server's filesystem with progress tracking
*
* @param nodeInstance - Node instance URL
* @param nodeToken - JWT token from getFilesystemAuth
* @param path - Destination path (e.g., "/server-icon.png")
* @param file - File to upload
* @param options - Optional progress callback and feature overrides
* @returns UploadHandle with promise, onProgress, and cancel
*/
public async uploadFile(
nodeInstance: string,
nodeToken: string,
public uploadFile(
path: string,
file: File,
): Promise<void> {
return this.client.request<void>(`/fs/create`, {
api: `https://${nodeInstance.replace('v0/fs', '')}`,
method: 'POST',
file: File | Blob,
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<void> {
return this.client.upload<void>('/fs/create', {
api: '',
version: 'v0',
file,
params: { path, type: 'file' },
headers: {
Authorization: `Bearer ${nodeToken}`,
'Content-Type': 'application/octet-stream',
},
body: file,
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
})
}
/**
* Update file contents
*
* @param path - File path to update
* @param content - New file content (string or Blob)
*/
public async updateFile(path: string, content: string | Blob): Promise<void> {
const blob = typeof content === 'string' ? new Blob([content]) : content
return this.client.request<void>('/fs/update', {
api: '',
version: 'v0',
method: 'PUT',
params: { path },
body: blob,
headers: { 'Content-Type': 'application/octet-stream' },
useNodeAuth: true,
})
}
/**
* Move a file or folder to a new location
*
* @param sourcePath - Current path
* @param destPath - New path
*/
public async moveFileOrFolder(sourcePath: string, destPath: string): Promise<void> {
return this.client.request<void>('/fs/move', {
api: '',
version: 'v0',
method: 'POST',
body: { source: sourcePath, destination: destPath },
useNodeAuth: true,
})
}
/**
* Rename a file or folder (convenience wrapper around move)
*
* @param path - Current file/folder path
* @param newName - New name (not full path)
*/
public async renameFileOrFolder(path: string, newName: string): Promise<void> {
const newPath = path.split('/').slice(0, -1).join('/') + '/' + newName
return this.moveFileOrFolder(path, newPath)
}
/**
* Delete a file or folder
*
* @param path - Path to delete
* @param recursive - If true, delete directory contents recursively
*/
public async deleteFileOrFolder(path: string, recursive: boolean): Promise<void> {
return this.client.request<void>('/fs/delete', {
api: '',
version: 'v0',
method: 'DELETE',
params: { path, recursive },
useNodeAuth: true,
})
}
/**
* Extract an archive file (zip, tar, etc.)
*
* Uses v1 API endpoint.
*
* @param path - Path to archive file
* @param override - If true, overwrite existing files
* @param dry - If true, perform dry run (returns conflicts without extracting)
* @returns Extract result with modpack name and conflicting files
*/
public async extractFile(
path: string,
override: boolean = true,
dry: boolean = false,
): Promise<Kyros.Files.v0.ExtractResult> {
return this.client.request<Kyros.Files.v0.ExtractResult>('/fs/unarchive', {
api: '',
version: 'v1',
method: 'POST',
params: { src: path, trg: '/', override, dry },
useNodeAuth: true,
})
}
/**
* Modify a filesystem operation (dismiss or cancel)
*
* Uses v1 API endpoint.
*
* @param opId - Operation ID (UUID)
* @param action - Action to perform
*/
public async modifyOperation(opId: string, action: 'dismiss' | 'cancel'): Promise<void> {
return this.client.request<void>(`/fs/ops/${action}`, {
api: '',
version: 'v1',
method: 'POST',
params: { id: opId },
useNodeAuth: true,
})
}
}

View File

@@ -1,7 +1,27 @@
export namespace Kyros {
export namespace Files {
export namespace v0 {
// Empty for now
export interface DirectoryItem {
name: string
type: 'file' | 'directory' | 'symlink'
path: string
modified: number
created: number
size?: number
count?: number
target?: string
}
export interface DirectoryResponse {
items: DirectoryItem[]
total: number
current: number
}
export interface ExtractResult {
modpack_name: string | null
conflicting_files: string[]
}
}
}
}

View File

@@ -1,10 +1,10 @@
import { $fetch, FetchError } from 'ofetch'
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Generic platform client using ofetch
@@ -24,7 +24,7 @@ import { GenericWebSocketClient } from './websocket-generic'
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class GenericModrinthClient extends AbstractModrinthClient {
export class GenericModrinthClient extends XHRUploadClient {
constructor(config: ClientConfig) {
super(config)

View File

@@ -1,11 +1,12 @@
import { FetchError } from 'ofetch'
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
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 { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Circuit breaker storage using Nuxt's useState
@@ -53,6 +54,8 @@ export interface NuxtClientConfig extends ClientConfig {
*
* 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
@@ -70,7 +73,7 @@ export interface NuxtClientConfig extends ClientConfig {
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class NuxtModrinthClient extends AbstractModrinthClient {
export class NuxtModrinthClient extends XHRUploadClient {
declare protected config: NuxtClientConfig
constructor(config: NuxtClientConfig) {
@@ -84,6 +87,20 @@ export class NuxtModrinthClient extends AbstractModrinthClient {
})
}
/**
* 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 runtime

View File

@@ -1,8 +1,8 @@
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Tauri-specific configuration
@@ -20,7 +20,9 @@ interface HttpError extends Error {
/**
* 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'
@@ -36,7 +38,7 @@ interface HttpError extends Error {
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class TauriModrinthClient extends AbstractModrinthClient {
export class TauriModrinthClient extends XHRUploadClient {
declare protected config: TauriClientConfig
constructor(config: TauriClientConfig) {

View File

@@ -0,0 +1,142 @@
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.config.labrinthBaseUrl!
} else if (options.api === 'archon') {
baseUrl = this.config.archonBaseUrl!
} else {
baseUrl = options.api
}
const url = this.buildUrl(path, baseUrl, options.version)
const mergedOptions: UploadRequestOptions = {
retry: false, // default: don't retry uploads
...options,
headers: {
...this.buildDefaultHeaders(),
'Content-Type': 'application/octet-stream',
...options.headers,
},
}
const context = this.buildUploadContext(url, path, mergedOptions)
const progressCallbacks: Array<(p: UploadProgress) => void> = []
if (mergedOptions.onProgress) {
progressCallbacks.push(mergedOptions.onProgress)
}
const abortController = new AbortController()
if (mergedOptions.signal) {
mergedOptions.signal.addEventListener('abort', () => abortController.abort())
}
const handle: UploadHandle<T> = {
promise: this.executeUploadFeatureChain<T>(context, progressCallbacks, abortController)
.then(async (result) => {
await this.config.hooks?.onResponse?.(result, context)
return result
})
.catch(async (error) => {
const apiError = this.normalizeError(error, 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')))
// 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)
}
xhr.send(metadata.file)
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,
)
}
}

View File

@@ -0,0 +1,45 @@
import type { NodeAuth } from '../features/node-auth'
/**
* Global node auth state.
* Set by server management pages, read by NodeAuthFeature.
*/
export const nodeAuthState = {
getAuth: null as (() => NodeAuth | null) | null,
refreshAuth: null as (() => Promise<void>) | null,
}
/**
* Configure the node auth state. Call this when entering server management.
*
* @param getAuth - Function that returns current auth or null
* @param refreshAuth - Function to refresh the auth token
*
* @example
* ```typescript
* // In server management page setup
* setNodeAuthState(
* () => fsAuth.value,
* refreshFsAuth,
* )
* ```
*/
export function setNodeAuthState(getAuth: () => NodeAuth | null, refreshAuth: () => Promise<void>) {
nodeAuthState.getAuth = getAuth
nodeAuthState.refreshAuth = refreshAuth
}
/**
* Clear the node auth state. Call this when leaving server management.
*
* @example
* ```typescript
* onUnmounted(() => {
* clearNodeAuthState()
* })
* ```
*/
export function clearNodeAuthState() {
nodeAuthState.getAuth = null
nodeAuthState.refreshAuth = null
}

View File

@@ -11,3 +11,4 @@ export type { ClientConfig, RequestHooks } from './client'
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
export { isModrinthErrorResponse } from './errors'
export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request'
export type { UploadHandle, UploadMetadata, UploadProgress, UploadRequestOptions } from './upload'

View File

@@ -73,6 +73,13 @@ export type RequestOptions = {
* @default false
*/
skipAuth?: boolean
/**
* Use node authentication for this request.
* When true, NodeAuthFeature will handle auth injection and URL building.
* @default false
*/
useNodeAuth?: boolean
}
/**
@@ -106,6 +113,13 @@ export type RequestContext = {
/**
* Additional metadata that features can attach
*
* For uploads, this contains:
* - isUpload: true
* - file: File | Blob being uploaded
* - onProgress: progress callback (if provided)
*
* Features can check `context.metadata?.isUpload` to detect uploads.
*/
metadata?: Record<string, unknown>
}

View File

@@ -0,0 +1,51 @@
import type { RequestOptions } from './request'
/**
* Progress information for file uploads
*/
export interface UploadProgress {
/** Bytes uploaded so far */
loaded: number
/** Total bytes to upload */
total: number
/** Progress as a decimal (0-1) */
progress: number
}
/**
* Options for upload requests (matches request() style)
*
* Extends RequestOptions but excludes body and method since those
* are determined by the upload itself.
*/
export interface UploadRequestOptions extends Omit<RequestOptions, 'body' | 'method'> {
/** File or Blob to upload */
file: File | Blob
/** Callback for progress updates */
onProgress?: (progress: UploadProgress) => void
}
/**
* Metadata attached to upload contexts
*
* Features can check `context.metadata?.isUpload` to detect uploads.
*/
export interface UploadMetadata extends Record<string, unknown> {
isUpload: true
file: File | Blob
onProgress?: (progress: UploadProgress) => void
}
/**
* Handle returned from upload operations
*
* Provides the upload promise, progress subscription, and cancellation.
*/
export interface UploadHandle<T> {
/** Promise that resolves when upload completes */
promise: Promise<T>
/** Subscribe to progress updates (chainable) */
onProgress: (callback: (progress: UploadProgress) => void) => UploadHandle<T>
/** Cancel the upload */
cancel: () => void
}

View File

@@ -0,0 +1,20 @@
import { ModrinthApiError } from '../core/errors'
/**
* Wrap a function with JWT retry logic.
* On 401, calls refreshToken() and retries once.
*/
export async function withJWTRetry<T>(
fn: () => Promise<T>,
refreshToken: () => Promise<void>,
): Promise<T> {
try {
return await fn()
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 401) {
await refreshToken()
return await fn()
}
throw error
}
}