You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -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
|
||||
*
|
||||
|
||||
21
packages/api-client/src/core/abstract-upload-client.ts
Normal file
21
packages/api-client/src/core/abstract-upload-client.ts
Normal 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>
|
||||
}
|
||||
149
packages/api-client/src/features/node-auth.ts
Normal file
149
packages/api-client/src/features/node-auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
56
packages/api-client/src/modules/archon/content/v0.ts
Normal file
56
packages/api-client/src/modules/archon/content/v0.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
142
packages/api-client/src/platform/xhr-upload-client.ts
Normal file
142
packages/api-client/src/platform/xhr-upload-client.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
45
packages/api-client/src/state/node-auth.ts
Normal file
45
packages/api-client/src/state/node-auth.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
51
packages/api-client/src/types/upload.ts
Normal file
51
packages/api-client/src/types/upload.ts
Normal 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
|
||||
}
|
||||
20
packages/api-client/src/utils/jwt-retry.ts
Normal file
20
packages/api-client/src/utils/jwt-retry.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user