diff --git a/.vscode/settings.json b/.vscode/settings.json index 7522198819..af3d483835 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,7 +9,7 @@ "files.insertFinalNewline": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", - "source.organizeImports": "always" + "source.organizeImports": "never" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "[vue]": { diff --git a/apps/app-frontend/src/pages/hosting/manage/Access.vue b/apps/app-frontend/src/pages/hosting/manage/Access.vue new file mode 100644 index 0000000000..59a3b82e36 --- /dev/null +++ b/apps/app-frontend/src/pages/hosting/manage/Access.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/app-frontend/src/pages/hosting/manage/index.js b/apps/app-frontend/src/pages/hosting/manage/index.js index 50052e3f9e..0c10b07133 100644 --- a/apps/app-frontend/src/pages/hosting/manage/index.js +++ b/apps/app-frontend/src/pages/hosting/manage/index.js @@ -1,7 +1,8 @@ +import Access from './Access.vue' import Backups from './Backups.vue' import Content from './Content.vue' import Files from './Files.vue' import Index from './Index.vue' import Overview from './Overview.vue' -export { Backups, Content, Files, Index, Overview } +export { Access, Backups, Content, Files, Index, Overview } diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index c12306b5ad..2057d67e17 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -73,6 +73,14 @@ export default new createRouter({ breadcrumb: [{ name: '?Server' }], }, }, + { + path: 'access', + name: 'ServerManageAccess', + component: Hosting.Access, + meta: { + breadcrumb: [{ name: '?Server' }], + }, + }, ], }, { diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 9d0d05c4df..03515cbb31 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -40,4 +40,3 @@ These composables are deprecated and should not be used in new code: - **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility. - **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead. -- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information. diff --git a/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue index 4a303112a0..a52bb5c01e 100644 --- a/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue +++ b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue @@ -1,20 +1,22 @@ + + diff --git a/apps/frontend/src/templates/emails/index.ts b/apps/frontend/src/templates/emails/index.ts index 719611b64b..015c1fdeb9 100644 --- a/apps/frontend/src/templates/emails/index.ts +++ b/apps/frontend/src/templates/emails/index.ts @@ -32,6 +32,10 @@ export default { 'project-invited': () => import('./project/ProjectInvited.vue'), 'project-transferred': () => import('./project/ProjectTransferred.vue'), + // Server + 'server-invited': () => import('./server/ServerInvited.vue'), + 'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'), + // Organizations 'organization-invited': () => import('./organization/OrganizationInvited.vue'), } as Record Promise<{ default: Component }>> diff --git a/apps/frontend/src/templates/emails/server/ServerInvited.vue b/apps/frontend/src/templates/emails/server/ServerInvited.vue new file mode 100644 index 0000000000..b3876aa3e3 --- /dev/null +++ b/apps/frontend/src/templates/emails/server/ServerInvited.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue new file mode 100644 index 0000000000..07b6f7cbee --- /dev/null +++ b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue @@ -0,0 +1,80 @@ + + + diff --git a/packages/api-client/src/core/abstract-client.ts b/packages/api-client/src/core/abstract-client.ts index 97ce38dd1b..70c2f1584f 100644 --- a/packages/api-client/src/core/abstract-client.ts +++ b/packages/api-client/src/core/abstract-client.ts @@ -1,10 +1,11 @@ import type { InferredClientModules } from '../modules' import { buildModuleStructure } from '../modules' -import type { ClientConfig } from '../types/client' +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' @@ -32,7 +33,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { private _moduleNamespaces: Map> = new Map() public readonly labrinth!: InferredClientModules['labrinth'] - public readonly archon!: ArchonClientModules & { sockets: AbstractWebSocketClient } + public readonly archon!: ArchonClientModules & { + sockets: AbstractWebSocketClient + sync: AbstractSyncClient + } public readonly kyros!: InferredClientModules['kyros'] public readonly iso3166!: InferredClientModules['iso3166'] public readonly mclogs!: InferredClientModules['mclogs'] @@ -116,9 +120,9 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { async request(path: string, options: RequestOptions): Promise { let baseUrl: string if (options.api === 'labrinth') { - baseUrl = this.config.labrinthBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { - baseUrl = this.config.archonBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) } else { baseUrl = options.api } @@ -160,13 +164,55 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { } } + async stream(path: string, options: RequestOptions): Promise> { + 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>(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(context: RequestContext): Promise { + protected async executeFeatureChain( + context: RequestContext, + executeTerminal: () => Promise = () => this.executeRequest(context.url, context.options), + ): Promise { // Filter to only features that should apply const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context)) @@ -184,7 +230,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { } else { // We've reached the end of the chain, execute the actual request await this.config.hooks?.onRequest?.(context) - return this.executeRequest(context.url, context.options) + return executeTerminal() } } @@ -243,6 +289,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { return `${base}${versionPath}${cleanPath}` } + protected resolveBaseUrl(baseUrl: BaseUrlConfig): string { + return typeof baseUrl === 'function' ? baseUrl() : baseUrl + } + /** * Build the request context */ @@ -354,6 +404,11 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { */ protected abstract executeRequest(url: string, options: RequestOptions): Promise + protected abstract executeStreamRequest( + url: string, + options: RequestOptions, + ): Promise> + /** * Execute the actual XHR upload * diff --git a/packages/api-client/src/core/abstract-sync.ts b/packages/api-client/src/core/abstract-sync.ts new file mode 100644 index 0000000000..a8e31c536e --- /dev/null +++ b/packages/api-client/src/core/abstract-sync.ts @@ -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 = Extract< + Archon.Sync.v1.SyncEvent, + { type: E } +> + +export type SyncEventHandler = ( + 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 + reconnectResolve?: () => void + retryDelay: number + lastEventId?: string + stopped: boolean + status: SyncStatusState + error?: unknown +} + +export type SyncEmitterEvents = Record + +export abstract class AbstractSyncClient { + protected connections = new Map() + protected abstract emitter: ReturnType> + + constructor( + protected client: { + stream: (path: string, options: RequestOptions) => Promise> + }, + ) {} + + abstract safeConnectServer(serverId: string, options?: SyncConnectOptions): Promise + + abstract disconnect(serverId: string): void + + abstract disconnectAll(): void + + on( + serverId: string, + eventType: E, + handler: SyncEventHandler>, + ): () => 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` + } +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 2e1e1a3ec0..e675904fc1 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,5 +1,16 @@ export { AbstractModrinthClient } from './core/abstract-client' export { AbstractFeature, type FeatureConfig } from './core/abstract-feature' +export { + AbstractSyncClient, + type SyncConnection, + type SyncConnectOptions, + type SyncEventHandler, + type SyncEventOfType, + type SyncEventType, + type SyncStatus, + type SyncStatusHandler, + type SyncStatusState, +} from './core/abstract-sync' export { AbstractUploadClient } from './core/abstract-upload-client' export { AbstractWebSocketClient, @@ -25,10 +36,18 @@ export * from './modules/types' export { GenericModrinthClient } from './platform/generic' export type { NuxtClientConfig } from './platform/nuxt' export { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt' +export { GenericSyncClient } from './platform/sync-generic' 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' +export { + type ParsedSseEvent, + type ParsedSseItem, + type ParsedSseRetry, + parseSyncEventData, + SseParser, +} from './utils/sse' export type { Override, RawDecimal } from './utils/types' diff --git a/packages/api-client/src/modules/archon/actions/v1.ts b/packages/api-client/src/modules/archon/actions/v1.ts new file mode 100644 index 0000000000..88227bf0e8 --- /dev/null +++ b/packages/api-client/src/modules/archon/actions/v1.ts @@ -0,0 +1,35 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonActionsV1Module extends AbstractModule { + public getModuleID(): string { + return 'archon_actions_v1' + } + + /** + * Get server action log entries. + * GET /v1/servers/:server_id/action-log + */ + public async list( + serverId: string, + options: Archon.Actions.v1.ListActionLogOptions = {}, + ): Promise { + const params: Record = {} + if (options.filter) params.filter = JSON.stringify(options.filter) + if (options.limit !== undefined) params.limit = options.limit + if (options.offset !== undefined) params.offset = options.offset + if (options.order !== undefined) params.order = options.order + if (options.min_datetime !== undefined) params.min_datetime = options.min_datetime + if (options.max_datetime !== undefined) params.max_datetime = options.max_datetime + + return this.client.request( + `/servers/${serverId}/action-log`, + { + api: 'archon', + version: 1, + method: 'GET', + params: Object.keys(params).length > 0 ? params : undefined, + }, + ) + } +} diff --git a/packages/api-client/src/modules/archon/index.ts b/packages/api-client/src/modules/archon/index.ts index ed9719ad3c..194ea0333e 100644 --- a/packages/api-client/src/modules/archon/index.ts +++ b/packages/api-client/src/modules/archon/index.ts @@ -1,3 +1,4 @@ +export * from './actions/v1' export * from './backups/v1' export * from './backups-queue/v1' export * from './content/v1' diff --git a/packages/api-client/src/modules/archon/nodes/internal.ts b/packages/api-client/src/modules/archon/nodes/internal.ts new file mode 100644 index 0000000000..254f392c8c --- /dev/null +++ b/packages/api-client/src/modules/archon/nodes/internal.ts @@ -0,0 +1,20 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonNodesInternalModule extends AbstractModule { + public getModuleID(): string { + return 'archon_nodes_internal' + } + + /** + * Get node hostnames and region summary for admin tooling. + * GET /_internal/nodes/overview + */ + public async overview(): Promise { + return this.client.request('/nodes/overview', { + api: 'archon', + version: 'internal', + method: 'GET', + }) + } +} diff --git a/packages/api-client/src/modules/archon/notices/v0.ts b/packages/api-client/src/modules/archon/notices/v0.ts new file mode 100644 index 0000000000..a6e76b0277 --- /dev/null +++ b/packages/api-client/src/modules/archon/notices/v0.ts @@ -0,0 +1,98 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonNoticesV0Module extends AbstractModule { + public getModuleID(): string { + return 'archon_notices_v0' + } + + /** + * Get all server notices. + * GET /modrinth/v0/notices + */ + public async list(): Promise { + return this.client.request('/notices', { + api: 'archon', + version: 'modrinth/v0', + method: 'GET', + }) + } + + /** + * Create a server notice. + * POST /modrinth/v0/notices + */ + public async create( + request: Archon.Notices.v0.Announce, + ): Promise { + return this.client.request('/notices', { + api: 'archon', + version: 'modrinth/v0', + method: 'POST', + body: request, + }) + } + + /** + * Update a server notice. + * PATCH /modrinth/v0/notices/:id + */ + public async update(id: number, request: Archon.Notices.v0.AnnouncePatch): Promise { + await this.client.request(`/notices/${id}`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PATCH', + body: request, + }) + } + + /** + * Delete a server notice. + * DELETE /modrinth/v0/notices/:id + */ + public async delete(id: number): Promise { + await this.client.request(`/notices/${id}`, { + api: 'archon', + version: 'modrinth/v0', + method: 'DELETE', + }) + } + + /** + * Assign a notice to a server or node. + * PUT /modrinth/v0/notices/:id/assign?server=:serverId + * PUT /modrinth/v0/notices/:id/assign?node=:nodeId + */ + public async assign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise { + await this.client.request(`/notices/${id}/assign`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PUT', + params: this.assignmentTargetToParams(target), + }) + } + + /** + * Unassign a notice from a server or node. + * PUT /modrinth/v0/notices/:id/unassign?server=:serverId + * PUT /modrinth/v0/notices/:id/unassign?node=:nodeId + */ + public async unassign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise { + await this.client.request(`/notices/${id}/unassign`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PUT', + params: this.assignmentTargetToParams(target), + }) + } + + private assignmentTargetToParams( + target: Archon.Notices.v0.AssignmentTarget, + ): Record { + if ('server' in target) { + return { server: target.server } + } + + return { node: target.node } + } +} diff --git a/packages/api-client/src/modules/archon/server-users/v1.ts b/packages/api-client/src/modules/archon/server-users/v1.ts new file mode 100644 index 0000000000..658c9fca17 --- /dev/null +++ b/packages/api-client/src/modules/archon/server-users/v1.ts @@ -0,0 +1,65 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonServerUsersV1Module extends AbstractModule { + public getModuleID(): string { + return 'archon_server_users_v1' + } + + /** + * Get list of users with access to a server + * GET /v1/servers/:server_id/users + */ + public async list(serverId: string): Promise { + return this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'GET', + }) + } + + /** + * Add a user to a server + * POST /v1/servers/:server_id/users + */ + public async add( + serverId: string, + user: Archon.ServerUsers.v1.AddServerUserRequest, + ): Promise { + await this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'POST', + body: user, + }) + } + + /** + * Remove a user from a server + * DELETE /v1/servers/:server_id/users/:user_id + */ + public async delete(serverId: string, userId: string): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'DELETE', + }) + } + + /** + * Update a user's server role + * PATCH /v1/servers/:server_id/users/:user_id + */ + public async update( + serverId: string, + userId: string, + role: Archon.ServerUsers.v1.AssignableServerUserRole, + ): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'PATCH', + body: JSON.stringify(role), + }) + } +} diff --git a/packages/api-client/src/modules/archon/transfers/internal.ts b/packages/api-client/src/modules/archon/transfers/internal.ts new file mode 100644 index 0000000000..ccbedaffd8 --- /dev/null +++ b/packages/api-client/src/modules/archon/transfers/internal.ts @@ -0,0 +1,84 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonTransfersInternalModule extends AbstractModule { + public getModuleID(): string { + return 'archon_transfers_internal' + } + + /** + * Schedule transfers for specific servers. + * POST /_internal/transfers/schedule/servers + */ + public async scheduleServers( + request: Archon.Transfers.Internal.ScheduleServerTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/schedule/servers', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } + + /** + * Schedule transfers for all servers on specific nodes. + * POST /_internal/transfers/schedule/nodes + */ + public async scheduleNodes( + request: Archon.Transfers.Internal.ScheduleNodeTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/schedule/nodes', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } + + /** + * Get transfer batch history. + * GET /_internal/transfers/history + */ + public async history( + options?: Archon.Transfers.Internal.TransferHistoryQuery, + ): Promise { + const params: Record = {} + if (options?.page !== undefined) params.page = options.page + if (options?.page_size !== undefined) params.page_size = options.page_size + + return this.client.request( + '/transfers/history', + { + api: 'archon', + version: 'internal', + method: 'GET', + params, + }, + ) + } + + /** + * Cancel pending transfer batches. + * POST /_internal/transfers/cancel + */ + public async cancel( + request: Archon.Transfers.Internal.CancelTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/cancel', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } +} diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index b348cbe7c8..1f541495af 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -1,6 +1,287 @@ import type { Labrinth } from '../labrinth/types' export namespace Archon { + export namespace Nodes { + export namespace Internal { + export type Node = { + id: string + hostname: string + region: string + created_at: string | null + locked: boolean + } + + export type Server = { + id: string + available: boolean + } + + export type NodeFull = Node & { + servers: Server[] + } + + export type Overview = { + node_hostnames: string[] + regions: Region[] + total_servers_active: number + } + + export type Region = { + display_name: string + country_code: string + key: string + server_count: number + node_count: number + } + + export type RegionWithStatistics = { + region: Region + active_servers: string[] + } + } + } + + export namespace Notices { + export namespace v0 { + export type Notice = { + id: number + dismissable: boolean + title: string | null + message: string + level: string + announced: string + } + + export type ListedNotice = { + id: number + dismissable: boolean + message: string + title: string | null + level: string + announce_at: string + expires: string | null + assigned: Assignment[] + dismissed_by: Dismisser[] + } + + export type Dismisser = { + server: string + dismissed_on: string + } + + export type Assignment = { + kind: string + id: string + name: string + } + + export type AssignmentTarget = { server: string } | { node: string } + + export type Announce = { + message: string + title?: string | null + level: string + dismissable: boolean + announce_at: string + expires?: string | null + } + + export type AnnouncePatch = { + message?: string + title?: string | null + level?: string + dismissable?: boolean + announce_at?: string + expires?: string | null + } + + export type PostNoticeResponseBody = { + id: number + } + } + } + + export namespace Actions { + export namespace v1 { + export type SortOrder = 'asc' | 'desc' + + export type ActionName = + | 'server_created' + | 'changed_server_name' + | 'changed_server_subdomain' + | 'server_reallocated' + | 'server_plan_changed' + | 'user_invited' + | 'user_invite_revoked' + | 'user_permission_modified' + | 'user_removed' + | 'addon_added' + | 'addon_uploaded' + | 'addon_disabled' + | 'addon_enabled' + | 'addon_deleted' + | 'addon_updated' + | 'modpack_changed' + | 'modpack_unlinked' + | 'server_repaired' + | 'server_reset' + | 'server_started' + | 'server_stopped' + | 'server_restarted' + | 'server_killed' + | 'port_allocation_added' + | 'port_allocation_removed' + | 'loader_version_edited' + | 'game_version_edited' + | 'server_properties_modified' + | 'file_uploaded' + | 'file_deleted' + | 'file_renamed' + | 'file_edited' + | 'sftp_login' + | 'console_command_executed' + | 'console_cleared' + | 'backup_created' + | 'backup_renamed' + | 'backup_restored' + | 'backup_deleted' + | 'startup_command_modified' + | 'java_runtime_modified' + | 'java_version_modified' + + export type Action = { + action: ActionName | string + metadata?: unknown + } + + export type UserPermissionsActionMetadata = { + user_id: string + permissions?: ServerUsers.v1.UserScope | null + } + + export type ActionUser = + | { + type: 'user' + user_id: string + } + | { + type: 'support' + user_id?: string | null + } + + export type ActionEntry = { + actor: ActionUser + action: Action + server_id: string + world_id?: string | null + timestamp: string + } + + export type UserResp = { + username: string + avatar_url?: string | null + } + + export type AddonResp = { + title: string + slug?: string | null + icon_url?: string | null + version?: string | null + } + + export type VersionResp = { + name: string + version_number?: string | null + } + + export type ActionLogResponse = { + next_offset?: number | null + data: ActionEntry[] + users: Record + addons: Record + versions: Record + } + + export type ActionLogFilter = { + users?: string[] + worlds?: Array + actions?: ActionName[] + } + + export type ListActionLogOptions = { + filter?: ActionLogFilter + limit?: number + offset?: number + order?: SortOrder + min_datetime?: string + max_datetime?: string + } + } + } + + export namespace Transfers { + export namespace Internal { + export type ProvisionOptions = { + region?: string | null + node_tags: string[] + } + + export type ScheduleServerTransfersRequest = { + server_ids: string[] + scheduled_at?: string | null + target_region?: string | null + node_tags?: string[] + reason?: string | null + } + + export type ScheduleNodeTransfersRequest = { + node_hostnames: string[] + scheduled_at?: string | null + target_region?: string | null + node_tags?: string[] + reason?: string | null + cordon_nodes?: boolean + tag_nodes?: string | null + } + + export type ScheduleTransfersResponse = { + batch_id: number + scheduled_count: number + } + + export type CancelTransfersRequest = { + batch_ids: number[] + } + + export type CancelTransfersResponse = { + cancelled_count: number + } + + export type TransferLogBatchEntry = { + id: number + created_by: string + created_at: string + reason?: string | null + scheduled_at: string + cancelled: boolean + log_count: number + provision_options: ProvisionOptions + } + + export type TransferHistoryQuery = { + page?: number + page_size?: number + } + + export type TransferHistoryResponse = { + batches: TransferLogBatchEntry[] + total: number + page: number + page_size: number + } + } + } + export namespace Content { export namespace v1 { export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack' @@ -222,11 +503,58 @@ export namespace Archon { } } + export namespace ServerUsers { + export namespace v1 { + export type ServerUserRole = 'Owner' | 'Editor' | 'Viewer' | 'Unknown' + + export type AssignableServerUserRole = Exclude + + export const UserScope = { + NONE: '', + SERVER_ADMIN: 'SERVER_ADMIN', + BASE_READ: 'BASE_READ', + POWER_ACTIONS: 'POWER_ACTIONS', + FILES_WRITE: 'FILES_WRITE', + SETUP: 'SETUP', + BACKUPS: 'BACKUPS', + ADVANCED: 'ADVANCED', + RESET_SERVER: 'RESET_SERVER', + MANAGE_USERS: 'MANAGE_USERS', + SUPPORT_AGENT: 'SUPPORT_AGENT', + INFRA_MANAGER: 'INFRA_MANAGER', + INFRA_MANAGER_READ: 'INFRA_MANAGER_READ', + INFRA_SERVERS_XFER: 'INFRA_SERVERS_XFER', + } as const + + export type UserScope = string | number + + export type UserResp = { + id: string + username: string + avatar_url?: string | null + } + + export type ServerUser = { + user: UserResp + added_on?: string | null + permissions: UserScope + } + + export type AddServerUserRequest = { + server_id?: string | null + user_id: string + added_on?: string | null + role: ServerUserRole + } + } + } + export namespace Servers { export namespace v0 { export type ServerGetResponse = { servers: Server[] pagination: Pagination + users: Record } export type Pagination = { @@ -236,6 +564,12 @@ export namespace Archon { total_items: number } + export type ServerOwner = { + id: string + username: string + avatar_url?: string | null + } + export type Status = 'installing' | 'broken' | 'available' | 'suspended' export type SuspensionReason = @@ -281,12 +615,15 @@ export namespace Archon { node: NodeInfo | null flows: Flows is_medal: boolean + current_user_permissions: UserScope medal_expires?: string } + export type UserScope = number + export type Net = { - ip: string + ip: string | null port: number domain: string } @@ -422,9 +759,9 @@ export namespace Archon { modloader: string modloader_version: string game_version: string - java_version: number - invocation: string - original_invocation: string + java_version: number | null + invocation: string | null + original_invocation: string | null } export type Region = { @@ -555,6 +892,106 @@ export namespace Archon { } } + export namespace Sync { + export namespace v1 { + export type SyncCategory = 'backup' | 'users' | 'server' | 'protocol' | 'world' + export type SyncIntent = 'all' | SyncCategory | SyncCategory[] + export type BackupOperationStatus = 'completed' | 'cancelled' | 'failed' | 'timed-out' + export type ServerNetworkPort = { port: number; name: string } + + export type ProtocolResetEvent = { type: 'protocol.reset' } + export type ProtocolInvalidEvent = { type: 'protocol.invalid' } + export type ProtocolErrorEvent = { type: 'protocol.error'; error: string } + + export type BackupNewEvent = { type: 'backup.new'; id: string } + export type BackupPatchEvent = { + type: 'backup.patch' + world_id: string + backup_id: string + name: string + } + export type BackupDeleteEvent = { + type: 'backup.delete' + world_id: string + backup_id: string + } + export type BackupOperationStartEvent = { + type: + | 'backup.operation.create.init' + | 'backup.operation.create.start' + | 'backup.operation.restore.init' + | 'backup.operation.restore.start' + world_id: string + backup_id: string + operation_id: number + } + export type BackupOperationDoneEvent = { + type: 'backup.operation.create.done' | 'backup.operation.restore.done' + world_id: string + backup_id: string + operation_id: number + status: BackupOperationStatus + } + + export type ServerPatchEvent = { + type: 'server.patch' + name: string + subdomain: string + } + export type ServerNetworkPatchEvent = { + type: 'server.network.patch' + ports: ServerNetworkPort[] + } + export type ServerTransferEvent = { + type: 'server.transfer.start' | 'server.transfer.done' + target_node: string + } + + export type UsersPatchEvent = { type: 'users.patch' } + + export type WorldPatchEvent = { + type: 'world.patch' + world_id: string + name: string + } + export type WorldStartupPatchEvent = { + type: 'world.startup.patch' + world_id: string + java_version: number | null + invocation: string | null + original_invocation: string | null + } + export type WorldContentAddonPatchEvent = { + type: 'world.content.addon.patch' + world_id: string + specs: Archon.Content.v1.Addon[] + } + export type WorldContentBaseUpdateEvent = { + type: 'world.content.base.update' + world_id: string + spec: Archon.Content.v1.Addons + } + + export type SyncEvent = + | ProtocolResetEvent + | ProtocolInvalidEvent + | ProtocolErrorEvent + | BackupNewEvent + | BackupPatchEvent + | BackupDeleteEvent + | BackupOperationStartEvent + | BackupOperationDoneEvent + | ServerPatchEvent + | ServerNetworkPatchEvent + | ServerTransferEvent + | UsersPatchEvent + | WorldPatchEvent + | WorldStartupPatchEvent + | WorldContentAddonPatchEvent + | WorldContentBaseUpdateEvent + } + } + export namespace Websocket { export namespace v0 { export type WSAuth = { diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index f1bb4f6661..4eedf8d1b8 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -1,12 +1,17 @@ import type { AbstractModrinthClient } from '../core/abstract-client' import type { AbstractModule } from '../core/abstract-module' +import { ArchonActionsV1Module } from './archon/actions/v1' import { ArchonBackupsV1Module } from './archon/backups/v1' import { ArchonBackupsQueueV1Module } from './archon/backups-queue/v1' import { ArchonContentV1Module } from './archon/content/v1' +import { ArchonNodesInternalModule } from './archon/nodes/internal' +import { ArchonNoticesV0Module } from './archon/notices/v0' import { ArchonOptionsV1Module } from './archon/options/v1' import { ArchonPropertiesV1Module } from './archon/properties/v1' +import { ArchonServerUsersV1Module } from './archon/server-users/v1' import { ArchonServersV0Module } from './archon/servers/v0' import { ArchonServersV1Module } from './archon/servers/v1' +import { ArchonTransfersInternalModule } from './archon/transfers/internal' import { ISO3166Module } from './iso3166' import { KyrosContentV1Module } from './kyros/content/v1' import { KyrosFilesV0Module } from './kyros/files/v0' @@ -19,6 +24,7 @@ import { LabrinthAuthV2Module } from './labrinth/auth/v2' import { LabrinthBillingInternalModule } from './labrinth/billing/internal' import { LabrinthCollectionsModule } from './labrinth/collections' import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal' +import { LabrinthFriendsV3Module } from './labrinth/friends/v3' import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal' import { LabrinthLimitsV3Module } from './labrinth/limits/v3' import { LabrinthModerationInternalModule } from './labrinth/moderation/internal' @@ -58,13 +64,18 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule * TODO: Better way? Probably not */ export const MODULE_REGISTRY = { + archon_actions_v1: ArchonActionsV1Module, archon_backups_queue_v1: ArchonBackupsQueueV1Module, archon_backups_v1: ArchonBackupsV1Module, archon_content_v1: ArchonContentV1Module, + archon_nodes_internal: ArchonNodesInternalModule, + archon_notices_v0: ArchonNoticesV0Module, archon_options_v1: ArchonOptionsV1Module, archon_properties_v1: ArchonPropertiesV1Module, + archon_server_users_v1: ArchonServerUsersV1Module, archon_servers_v0: ArchonServersV0Module, archon_servers_v1: ArchonServersV1Module, + archon_transfers_internal: ArchonTransfersInternalModule, iso3166_data: ISO3166Module, mclogs_insights_v1: MclogsInsightsV1Module, mclogs_logs_v1: MclogsLogsV1Module, @@ -79,6 +90,7 @@ export const MODULE_REGISTRY = { labrinth_billing_internal: LabrinthBillingInternalModule, labrinth_collections: LabrinthCollectionsModule, labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule, + labrinth_friends_v3: LabrinthFriendsV3Module, labrinth_globals_internal: LabrinthGlobalsInternalModule, labrinth_moderation_internal: LabrinthModerationInternalModule, labrinth_notifications_v2: LabrinthNotificationsV2Module, diff --git a/packages/api-client/src/modules/labrinth/friends/v3.ts b/packages/api-client/src/modules/labrinth/friends/v3.ts new file mode 100644 index 0000000000..d946880dc5 --- /dev/null +++ b/packages/api-client/src/modules/labrinth/friends/v3.ts @@ -0,0 +1,47 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +export class LabrinthFriendsV3Module extends AbstractModule { + public getModuleID(): string { + return 'labrinth_friends_v3' + } + + /** + * Get friends and pending friend requests for the authenticated user + * + * @returns Promise resolving to friend relationships + */ + public async list(): Promise { + return this.client.request('/friends', { + api: 'labrinth', + version: 3, + method: 'GET', + }) + } + + /** + * Send or accept a friend request + * + * @param idOrUsername - The target user's ID or username + */ + public async add(idOrUsername: string): Promise { + return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, { + api: 'labrinth', + version: 3, + method: 'POST', + }) + } + + /** + * Remove a friend or pending friend request + * + * @param idOrUsername - The target user's ID or username + */ + public async remove(idOrUsername: string): Promise { + return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, { + api: 'labrinth', + version: 3, + method: 'DELETE', + }) + } +} diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index 883bac31d7..725e57dc95 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -3,6 +3,7 @@ export * from './auth/v2' export * from './billing/internal' export * from './collections' export * from './external-projects/internal' +export * from './friends/v3' export * from './globals/internal' export * from './limits/v3' export * from './moderation/internal' diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 098e46d19f..c9c92bb3bf 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -1063,6 +1063,17 @@ export namespace Labrinth { } } + export namespace Friends { + export namespace v3 { + export type UserFriend = { + id: string + friend_id: string + accepted: boolean + created: string + } + } + } + export namespace ServerPing { export namespace Internal { export type MinecraftJavaPingRequest = { diff --git a/packages/api-client/src/platform/generic.ts b/packages/api-client/src/platform/generic.ts index 4a939b6f7a..7ec63b6824 100644 --- a/packages/api-client/src/platform/generic.ts +++ b/packages/api-client/src/platform/generic.ts @@ -1,8 +1,10 @@ import { $fetch, FetchError } from 'ofetch' -import type { ModrinthApiError } from '../core/errors' +import { ModrinthApiError } from '../core/errors' import type { ClientConfig } from '../types/client' import type { RequestOptions } from '../types/request' +import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch' +import { GenericSyncClient } from './sync-generic' import { GenericWebSocketClient } from './websocket-generic' import { XHRUploadClient } from './xhr-upload-client' @@ -34,6 +36,12 @@ export class GenericModrinthClient extends XHRUploadClient { enumerable: true, configurable: false, }) + Object.defineProperty(this.archon, 'sync', { + value: new GenericSyncClient(this), + writable: false, + enumerable: true, + configurable: false, + }) } protected async executeRequest(url: string, options: RequestOptions): Promise { @@ -54,6 +62,38 @@ export class GenericModrinthClient extends XHRUploadClient { } } + protected async executeStreamRequest( + url: string, + options: RequestOptions, + ): Promise> { + try { + const response = await fetch(appendRequestParams(url, options.params), { + method: options.method ?? 'GET', + headers: options.headers, + body: toFetchBody(options.body), + signal: options.signal, + }) + + if (!response.ok) { + throw this.createNormalizedError( + new Error(`HTTP ${response.status}: ${response.statusText}`), + response.status, + await parseResponseErrorData(response), + ) + } + + if (!response.body) { + throw new ModrinthApiError('Streaming response has no readable body', { + statusCode: response.status, + }) + } + + return response.body + } catch (error) { + throw this.normalizeError(error) + } + } + protected normalizeError(error: unknown): ModrinthApiError { if (error instanceof FetchError) { return this.createNormalizedError(error, error.response?.status, error.data) diff --git a/packages/api-client/src/platform/nuxt.ts b/packages/api-client/src/platform/nuxt.ts index ce0c456345..a5308535ba 100644 --- a/packages/api-client/src/platform/nuxt.ts +++ b/packages/api-client/src/platform/nuxt.ts @@ -5,6 +5,8 @@ import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/cir import type { ClientConfig } from '../types/client' import type { RequestOptions } from '../types/request' import type { UploadHandle, UploadRequestOptions } from '../types/upload' +import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch' +import { GenericSyncClient } from './sync-generic' import { GenericWebSocketClient } from './websocket-generic' import { XHRUploadClient } from './xhr-upload-client' @@ -97,6 +99,12 @@ export class NuxtModrinthClient extends XHRUploadClient { enumerable: true, configurable: false, }) + Object.defineProperty(this.archon, 'sync', { + value: new GenericSyncClient(this), + writable: false, + enumerable: true, + configurable: false, + }) } /** @@ -167,6 +175,40 @@ export class NuxtModrinthClient extends XHRUploadClient { } } + protected async executeStreamRequest( + url: string, + options: RequestOptions, + ): Promise> { + try { + const response = await fetch(appendRequestParams(url, options.params), { + method: options.method ?? 'GET', + headers: options.headers, + body: toFetchBody(options.body), + signal: options.signal, + // @ts-expect-error - import.meta is provided by Nuxt + cache: import.meta.server ? undefined : 'no-store', + }) + + if (!response.ok) { + throw this.createNormalizedError( + new Error(`HTTP ${response.status}: ${response.statusText}`), + response.status, + await parseResponseErrorData(response), + ) + } + + if (!response.body) { + throw new ModrinthApiError('Streaming response has no readable body', { + statusCode: response.status, + }) + } + + return response.body + } catch (error) { + throw this.normalizeError(error) + } + } + protected normalizeError(error: unknown): ModrinthApiError { if (error instanceof FetchError) { return this.createNormalizedError(error, error.response?.status, error.data) diff --git a/packages/api-client/src/platform/sync-generic.ts b/packages/api-client/src/platform/sync-generic.ts new file mode 100644 index 0000000000..e43fbaf2da --- /dev/null +++ b/packages/api-client/src/platform/sync-generic.ts @@ -0,0 +1,229 @@ +import mitt from 'mitt' + +import { + AbstractSyncClient, + type SyncConnection, + type SyncConnectOptions, + type SyncEmitterEvents, +} from '../core/abstract-sync' +import type { Archon } from '../modules/archon/types' +import { type ParsedSseItem, parseSyncEventData, SseParser } from '../utils/sse' + +type StreamReadResult = 'closed' | 'protocol-reconnect' + +const DEFAULT_RETRY_DELAY = 1000 +const MAX_RECONNECT_DELAY = 30000 +const JITTER_MS = 1000 + +export class GenericSyncClient extends AbstractSyncClient { + protected emitter = mitt() + + async safeConnectServer(serverId: string, options: SyncConnectOptions = {}): Promise { + const existing = this.connections.get(serverId) + if (existing && !options.force && !existing.stopped && existing.status !== 'disconnected') { + return + } + + if (existing) { + this.closeConnection(serverId) + } + + const connection: SyncConnection = { + serverId, + intent: options.intent ?? 'all', + reconnectAttempts: 0, + retryDelay: DEFAULT_RETRY_DELAY, + stopped: false, + status: 'idle', + } + + this.connections.set(serverId, connection) + void this.runConnection(connection) + } + + disconnect(serverId: string): void { + this.closeConnection(serverId) + this.clearListeners(serverId) + } + + disconnectAll(): void { + for (const serverId of this.connections.keys()) { + this.disconnect(serverId) + } + } + + private async runConnection(connection: SyncConnection): Promise { + while (!connection.stopped) { + const hadConnected = connection.status === 'connected' + this.updateStatus(connection, hadConnected ? 'reconnecting' : 'connecting') + + const controller = new AbortController() + connection.controller = controller + + try { + const stream = await this.client.stream('/sync', { + api: 'archon', + version: 1, + method: 'GET', + params: { + scope: `server:${connection.serverId}`, + intent: this.intentToParam(connection.intent), + }, + headers: connection.lastEventId + ? { + 'Last-Event-Id': connection.lastEventId, + } + : undefined, + signal: controller.signal, + retry: false, + circuitBreaker: false, + }) + + if (connection.stopped) return + + connection.reconnectAttempts = 0 + this.updateStatus(connection, 'connected') + + const result = await this.consumeStream(connection, stream) + connection.controller = undefined + if (connection.stopped) return + + if (result === 'protocol-reconnect') { + connection.reconnectAttempts = 0 + continue + } + + await this.waitForReconnect(connection) + } catch (error) { + connection.controller = undefined + if (connection.stopped || this.isAbortError(error)) return + + connection.reconnectAttempts++ + this.updateStatus(connection, 'error', error) + console.warn(`[Sync] Connection failed for server ${connection.serverId}:`, error) + await this.waitForReconnect(connection) + } + } + } + + private async consumeStream( + connection: SyncConnection, + stream: ReadableStream, + ): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + const parser = new SseParser() + + try { + while (!connection.stopped) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + const result = this.processParsedItems(connection, parser.feed(chunk)) + if (result === 'protocol-reconnect') { + await reader.cancel() + connection.controller?.abort() + return result + } + } + + const finalChunk = decoder.decode() + const finalItems = finalChunk ? parser.feed(finalChunk) : [] + const result = this.processParsedItems(connection, [...finalItems, ...parser.end()]) + if (result === 'protocol-reconnect') { + await reader.cancel() + connection.controller?.abort() + return result + } + } finally { + reader.releaseLock() + } + + return 'closed' + } + + private processParsedItems(connection: SyncConnection, items: ParsedSseItem[]): StreamReadResult { + for (const item of items) { + if (item.kind === 'retry') { + connection.retryDelay = Math.min(item.retry, MAX_RECONNECT_DELAY) + continue + } + + this.updateLastEventId(connection, item.id) + + const event = parseSyncEventData(item.data) + if (!event) { + console.warn('[Sync] Dropping malformed SSE payload:', { + serverId: connection.serverId, + event: item.event, + data: item.data, + }) + continue + } + + this.emitSyncEvent(connection.serverId, event) + + if (event.type === 'protocol.reset' || event.type === 'protocol.invalid') { + connection.lastEventId = undefined + return 'protocol-reconnect' + } + } + + return 'closed' + } + + private async waitForReconnect(connection: SyncConnection): Promise { + if (connection.stopped) return + + this.updateStatus(connection, 'reconnecting') + const delay = this.getReconnectDelay(connection) + + await new Promise((resolve) => { + connection.reconnectResolve = resolve + connection.reconnectTimer = setTimeout(() => { + connection.reconnectTimer = undefined + connection.reconnectResolve = undefined + resolve() + }, delay) + }) + } + + private closeConnection(serverId: string): void { + const connection = this.connections.get(serverId) + if (!connection) return + + connection.stopped = true + connection.controller?.abort() + + if (connection.reconnectTimer) { + clearTimeout(connection.reconnectTimer) + connection.reconnectTimer = undefined + } + connection.reconnectResolve?.() + connection.reconnectResolve = undefined + + this.updateStatus(connection, 'disconnected') + this.connections.delete(serverId) + } + + private getReconnectDelay(connection: SyncConnection): number { + const exponentialDelay = + connection.retryDelay * Math.pow(2, Math.max(connection.reconnectAttempts - 1, 0)) + return Math.min(exponentialDelay, MAX_RECONNECT_DELAY) + Math.random() * JITTER_MS + } + + private updateLastEventId(connection: SyncConnection, id: string | undefined): void { + if (id === undefined) return + connection.lastEventId = id || undefined + } + + private intentToParam(intent: Archon.Sync.v1.SyncIntent): string { + return Array.isArray(intent) ? intent.join(',') : intent + } + + private isAbortError(error: unknown): boolean { + if (!(error instanceof Error)) return false + return error.name === 'AbortError' || error.message.toLowerCase().includes('abort') + } +} diff --git a/packages/api-client/src/platform/tauri.ts b/packages/api-client/src/platform/tauri.ts index 725bcc7795..ac82b53edb 100644 --- a/packages/api-client/src/platform/tauri.ts +++ b/packages/api-client/src/platform/tauri.ts @@ -1,6 +1,8 @@ import type { ModrinthApiError } from '../core/errors' import type { ClientConfig } from '../types/client' import type { RequestOptions } from '../types/request' +import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch' +import { GenericSyncClient } from './sync-generic' import { GenericWebSocketClient } from './websocket-generic' import { XHRUploadClient } from './xhr-upload-client' @@ -49,6 +51,12 @@ export class TauriModrinthClient extends XHRUploadClient { enumerable: true, configurable: false, }) + Object.defineProperty(this.archon, 'sync', { + value: new GenericSyncClient(this), + writable: false, + enumerable: true, + configurable: false, + }) } protected async executeRequest(url: string, options: RequestOptions): Promise { @@ -57,36 +65,8 @@ export class TauriModrinthClient extends XHRUploadClient { // This allows the package to be used in non-Tauri environments const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http') - let body: BodyInit | null | undefined = undefined - if (options.body) { - const raw = options.body - if ( - typeof raw === 'object' && - !(raw instanceof FormData) && - !(raw instanceof URLSearchParams) && - !(raw instanceof Blob) && - !(raw instanceof ArrayBuffer) && - !ArrayBuffer.isView(raw as ArrayBufferView) - ) { - body = JSON.stringify(raw) - } else { - body = raw as BodyInit - } - } - - let fullUrl = url - if (options.params) { - const filteredParams: Record = {} - for (const [key, value] of Object.entries(options.params)) { - if (value !== undefined && value !== null) { - filteredParams[key] = String(value) - } - } - const queryString = new URLSearchParams(filteredParams).toString() - if (queryString) { - fullUrl = `${url}?${queryString}` - } - } + const body = toFetchBody(options.body) + const fullUrl = appendRequestParams(url, options.params) const response = await tauriFetch(fullUrl, { method: options.method ?? 'GET', @@ -147,6 +127,41 @@ export class TauriModrinthClient extends XHRUploadClient { } } + protected async executeStreamRequest( + url: string, + options: RequestOptions, + ): Promise> { + try { + const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http') + const response = await tauriFetch(appendRequestParams(url, options.params), { + method: options.method ?? 'GET', + headers: options.headers, + body: toFetchBody(options.body), + signal: options.signal, + }) + + if (!response.ok) { + throw this.createNormalizedError( + new Error(`HTTP ${response.status}: ${response.statusText}`), + response.status, + await parseResponseErrorData(response), + ) + } + + if (!response.body) { + throw this.createNormalizedError( + new Error('Streaming response has no readable body'), + response.status, + undefined, + ) + } + + return response.body + } catch (error) { + throw this.normalizeError(error) + } + } + protected normalizeError(error: unknown): ModrinthApiError { if (error instanceof Error) { const httpError = error as HttpError diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts index d16e765899..40190f96d4 100644 --- a/packages/api-client/src/platform/xhr-upload-client.ts +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -18,9 +18,9 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { upload(path: string, options: UploadRequestOptions): UploadHandle { let baseUrl: string if (options.api === 'labrinth') { - baseUrl = this.config.labrinthBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { - baseUrl = this.config.archonBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) } else { baseUrl = options.api } diff --git a/packages/api-client/src/types/client.ts b/packages/api-client/src/types/client.ts index 45828d5c20..57d33105ef 100644 --- a/packages/api-client/src/types/client.ts +++ b/packages/api-client/src/types/client.ts @@ -3,6 +3,7 @@ import type { RequestContext } from './request' export type MaybePromise = T | Promise export type UserAgentProvider = string | (() => MaybePromise) +export type BaseUrlConfig = string | (() => string) /** * Request lifecycle hooks @@ -39,13 +40,15 @@ export interface ClientConfig { * Base URL for Labrinth API (main Modrinth API) * @default 'https://api.modrinth.com' */ - labrinthBaseUrl?: string + labrinthBaseUrl?: BaseUrlConfig /** * Base URL for Archon API (Modrinth Hosting API) + * Can be a callback so apps can drive this from runtime feature flags. + * * @default 'https://archon.modrinth.com' */ - archonBaseUrl?: string + archonBaseUrl?: BaseUrlConfig /** * Default request timeout in milliseconds diff --git a/packages/api-client/src/types/index.ts b/packages/api-client/src/types/index.ts index 30daf6520c..2fb40b14e4 100644 --- a/packages/api-client/src/types/index.ts +++ b/packages/api-client/src/types/index.ts @@ -7,7 +7,7 @@ export type { } from '../features/circuit-breaker' export type { BackoffStrategy, RetryConfig } from '../features/retry' export type { Archon } from '../modules/archon/types' -export type { ClientConfig, RequestHooks } from './client' +export type { BaseUrlConfig, ClientConfig, RequestHooks } from './client' export type { ApiErrorData, ModrinthErrorResponse } from './errors' export { isModrinthErrorResponse } from './errors' export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request' diff --git a/packages/api-client/src/utils/fetch.ts b/packages/api-client/src/utils/fetch.ts new file mode 100644 index 0000000000..9ab9c7ab0a --- /dev/null +++ b/packages/api-client/src/utils/fetch.ts @@ -0,0 +1,55 @@ +import type { RequestOptions } from '../types/request' + +export function appendRequestParams(url: string, params?: RequestOptions['params']): string { + if (!params) return url + + const filteredParams: Record = {} + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + filteredParams[key] = String(value) + } + } + + const queryString = new URLSearchParams(filteredParams).toString() + if (!queryString) return url + + return `${url}${url.includes('?') ? '&' : '?'}${queryString}` +} + +export function toFetchBody(body: unknown): BodyInit | null | undefined { + if (!body) return undefined + + if ( + typeof body === 'object' && + !(body instanceof FormData) && + !(body instanceof URLSearchParams) && + !(body instanceof Blob) && + !(body instanceof ArrayBuffer) && + !ArrayBuffer.isView(body as ArrayBufferView) + ) { + return JSON.stringify(body) + } + + return body as BodyInit +} + +export async function parseResponseErrorData(response: Response): Promise { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' + + try { + if (contentType.includes('application/json') || contentType.includes('+json')) { + return await response.json() + } + + const text = await response.text() + if (!text) return undefined + + try { + return JSON.parse(text) + } catch { + return text + } + } catch { + return undefined + } +} diff --git a/packages/api-client/src/utils/sse.ts b/packages/api-client/src/utils/sse.ts new file mode 100644 index 0000000000..8497196875 --- /dev/null +++ b/packages/api-client/src/utils/sse.ts @@ -0,0 +1,139 @@ +import type { Archon } from '../modules/archon/types' + +export type ParsedSseEvent = { + kind: 'event' + id?: string + event?: string + data: string +} + +export type ParsedSseRetry = { + kind: 'retry' + retry: number +} + +export type ParsedSseItem = ParsedSseEvent | ParsedSseRetry + +export class SseParser { + private buffer = '' + private eventName = '' + private data = '' + private id: string | undefined + + feed(chunk: string): ParsedSseItem[] { + this.buffer += chunk + const items: ParsedSseItem[] = [] + + while (true) { + const lineEnd = this.findLineEnd() + if (!lineEnd) break + + const { line, length } = lineEnd + this.buffer = this.buffer.slice(length) + this.processLine(line, items) + } + + return items + } + + end(): ParsedSseItem[] { + const items: ParsedSseItem[] = [] + + if (this.buffer.length > 0) { + this.processLine(this.buffer.endsWith('\r') ? this.buffer.slice(0, -1) : this.buffer, items) + this.buffer = '' + } + + this.dispatch(items) + return items + } + + private findLineEnd(): { line: string; length: number } | null { + const lf = this.buffer.indexOf('\n') + const cr = this.buffer.indexOf('\r') + + if (lf === -1 && cr === -1) return null + + if (cr !== -1 && (lf === -1 || cr < lf)) { + if (cr === this.buffer.length - 1) return null + const length = this.buffer[cr + 1] === '\n' ? cr + 2 : cr + 1 + return { + line: this.buffer.slice(0, cr), + length, + } + } + + return { + line: this.buffer.slice(0, lf), + length: lf + 1, + } + } + + private processLine(line: string, items: ParsedSseItem[]): void { + if (line === '') { + this.dispatch(items) + return + } + + if (line.startsWith(':')) return + + const colon = line.indexOf(':') + const field = colon === -1 ? line : line.slice(0, colon) + let value = colon === -1 ? '' : line.slice(colon + 1) + if (value.startsWith(' ')) value = value.slice(1) + + switch (field) { + case 'event': + this.eventName = value + break + case 'data': + this.data += `${value}\n` + break + case 'id': + this.id = value + break + case 'retry': { + const retry = Number(value) + if (Number.isInteger(retry) && retry >= 0) { + items.push({ kind: 'retry', retry }) + } + break + } + } + } + + private dispatch(items: ParsedSseItem[]): void { + if (!this.data) { + this.eventName = '' + this.id = undefined + return + } + + items.push({ + kind: 'event', + id: this.id, + event: this.eventName || undefined, + data: this.data.endsWith('\n') ? this.data.slice(0, -1) : this.data, + }) + + this.eventName = '' + this.data = '' + this.id = undefined + } +} + +export function parseSyncEventData(data: string): Archon.Sync.v1.SyncEvent | null { + let parsed: unknown + + try { + parsed = JSON.parse(data) + } catch { + return null + } + + if (!parsed || typeof parsed !== 'object') return null + const event = parsed as { type?: unknown } + if (typeof event.type !== 'string') return null + + return parsed as Archon.Sync.v1.SyncEvent +} diff --git a/packages/assets/external/illustrations/intercom_bubble_icon.png b/packages/assets/external/illustrations/intercom_bubble_icon.png new file mode 100644 index 0000000000..6585b9b09e Binary files /dev/null and b/packages/assets/external/illustrations/intercom_bubble_icon.png differ diff --git a/packages/assets/index.ts b/packages/assets/index.ts index 4fb4028886..7a8ed170cd 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -41,6 +41,7 @@ import _DiscordIcon from './external/discord.svg?component' import _FacebookIcon from './external/facebook.svg?component' import _FlathubIcon from './external/flathub.svg?component' import _GithubIcon from './external/github.svg?component' +import _IntercomBubbleIcon from './external/illustrations/intercom_bubble_icon.png?url' import _MinecraftServerIcon from './external/illustrations/minecraft_server_icon.png?url' import _InstagramIcon from './external/instagram.svg?component' import _KoFiIcon from './external/kofi.svg?component' @@ -132,6 +133,7 @@ export const VenmoIcon = _VenmoIcon export const PolygonIcon = _PolygonIcon export const USDCColorIcon = _USDCColorIcon export const VisaIcon = _VisaIcon +export const IntercomBubbleIcon = _IntercomBubbleIcon export const MinecraftServerIcon = _MinecraftServerIcon export * from './generated-icons' diff --git a/packages/assets/styles/classes.scss b/packages/assets/styles/classes.scss index 1822c5fda5..3045700d1d 100644 --- a/packages/assets/styles/classes.scss +++ b/packages/assets/styles/classes.scss @@ -651,8 +651,8 @@ a:not(.no-click-animation), // TOOLTIPS -.v-popper--theme-dropdown, -.v-popper--theme-dropdown.v-popper--theme-ribbit-popout { +.v-popper__popper.v-popper--theme-dropdown, +.v-popper__popper.v-popper--theme-dropdown.v-popper--theme-ribbit-popout { .v-popper__inner { border: 1px solid var(--color-divider) !important; padding: var(--gap-sm) !important; @@ -710,9 +710,13 @@ a:not(.no-click-animation), //transform: scale(.9); } -.v-popper--theme-tooltip { +.v-popper__popper.v-popper--theme-tooltip { pointer-events: none; + &.v-popper--interactive { + pointer-events: auto; + } + .v-popper__inner { background: var(--color-tooltip-bg) !important; color: var(--color-tooltip-text) !important; @@ -730,7 +734,7 @@ a:not(.no-click-animation), } } -.v-popper--theme-dismissable-prompt { +.v-popper__popper.v-popper--theme-dismissable-prompt { z-index: 10; .v-popper__inner { diff --git a/packages/ui/.storybook/preview.scss b/packages/ui/.storybook/preview.scss new file mode 100644 index 0000000000..b9e6de3ad7 --- /dev/null +++ b/packages/ui/.storybook/preview.scss @@ -0,0 +1,17 @@ +html { + min-height: 100%; + overflow: auto; +} + +body { + position: static !important; + width: auto !important; + min-height: 100vh; + height: auto !important; + overflow: auto !important; +} + +#storybook-root { + min-height: 100vh; + height: auto; +} diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts index 9b340008eb..e68f912e93 100644 --- a/packages/ui/.storybook/preview.ts +++ b/packages/ui/.storybook/preview.ts @@ -6,6 +6,7 @@ import '../../assets/styles/defaults.scss' // --- // app-frontend css imports import '../../../apps/app-frontend/src/assets/stylesheets/global.scss' +import './preview.scss' import type { Labrinth } from '@modrinth/api-client' import { GenericModrinthClient } from '@modrinth/api-client' diff --git a/packages/ui/src/components/base/BaseTerminal.vue b/packages/ui/src/components/base/BaseTerminal.vue index d266fd07ee..bb6c1ddf9a 100644 --- a/packages/ui/src/components/base/BaseTerminal.vue +++ b/packages/ui/src/components/base/BaseTerminal.vue @@ -26,8 +26,9 @@ > { + if (props.disableInput) return const cmd = commandInput.value.trim() if (!cmd) return emit('command', cmd) diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue index eb50ece3bb..d297c47fe5 100644 --- a/packages/ui/src/components/base/Combobox.vue +++ b/packages/ui/src/components/base/Combobox.vue @@ -62,18 +62,18 @@ @click="handleTriggerClick($event)" @keydown="handleTriggerKeydown" > -
+
- + {{ triggerText }}
-
+
-
+
{{ noOptionsMessage }}
@@ -232,6 +233,9 @@ const props = withDefaults( forceDirection?: 'up' | 'down' noOptionsMessage?: string disableSearchFilter?: boolean + dropdownClass?: string + dropdownMinWidth?: string + minSearchLengthToOpen?: number /** Keep the selected option's label in the input after selection, and show all options on focus */ syncWithSelection?: boolean /** Select the searchable input text when the field receives focus */ @@ -249,6 +253,7 @@ const props = withDefaults( showIconInSelected: false, maxHeight: DEFAULT_MAX_HEIGHT, noOptionsMessage: 'No results found', + minSearchLengthToOpen: 0, syncWithSelection: true, selectSearchTextOnFocus: false, showSearchIcon: false, @@ -290,6 +295,7 @@ const dropdownStyle = ref({ top: '0px', left: '0px', width: '0px', + minWidth: '0px', }) const openDirection = ref<'down' | 'up'>('down') @@ -323,6 +329,10 @@ const triggerText = computed(() => { return props.placeholder }) +const hasMinimumSearchLength = computed( + () => !props.searchable || searchQuery.value.trim().length >= props.minSearchLengthToOpen, +) + const optionsWithKeys = computed(() => { return props.options.map((opt, index) => ({ ...opt, @@ -441,13 +451,15 @@ async function updateDropdownPosition() { top: `${top}px`, left: `${left}px`, width: `${triggerRect.width}px`, + minWidth: props.dropdownMinWidth ?? `${triggerRect.width}px`, } openDirection.value = direction } async function openDropdown() { - if (props.disabled || isOpen.value || !hasDropdownContent.value) return + if (props.disabled || isOpen.value || !hasMinimumSearchLength.value || !hasDropdownContent.value) + return isOpen.value = true emit('open') @@ -642,6 +654,10 @@ function handleSearchKeydown(event: KeyboardEvent) { function handleSearchInput() { userHasTyped.value = true emit('searchInput', searchQuery.value) + if (!hasMinimumSearchLength.value) { + closeDropdown() + return + } if (!isOpen.value) { openDropdown() } @@ -742,10 +758,16 @@ watch(hasDropdownContent, (value) => { } }) +watch(hasMinimumSearchLength, (canOpen) => { + if (!canOpen) { + closeDropdown() + } +}) + watch( [() => props.modelValue, () => props.options], ([val]) => { - if (props.searchable && props.syncWithSelection && !isOpen.value) { + if (props.searchable && props.syncWithSelection && !isOpen.value && !userHasTyped.value) { const opt = props.options.find((o) => isDropdownOption(o) && o.value === val) searchQuery.value = opt && isDropdownOption(opt) ? opt.label : '' } diff --git a/packages/ui/src/components/base/DropdownFilterBar.vue b/packages/ui/src/components/base/DropdownFilterBar.vue index 2cc71a73c1..cc1d65dfc4 100644 --- a/packages/ui/src/components/base/DropdownFilterBar.vue +++ b/packages/ui/src/components/base/DropdownFilterBar.vue @@ -277,6 +277,7 @@ const props = withDefaults( addLabel?: string clearLabel?: string useFilterIcon?: boolean + applyImmediately?: boolean emptyOptionsLabel?: string emptySearchLabel?: string }>(), @@ -285,6 +286,7 @@ const props = withDefaults( addLabel: 'Add', clearLabel: 'Clear', useFilterIcon: false, + applyImmediately: false, emptyOptionsLabel: 'No options available.', emptySearchLabel: 'No options found.', }, @@ -493,6 +495,9 @@ function setSelectedValues( if (isAddMenuOpen.value && activeCategoryKey.value === categoryKey) { scheduleSubmenuPositionUpdate() } + if (props.applyImmediately) { + emit('update:modelValue', nextFilters) + } } else { emit('update:modelValue', nextFilters) } @@ -672,9 +677,13 @@ function getPreviewSelectedValues(categoryKey: string): string[] { } function setPreviewSelectedValues(categoryKey: string, values: string[]) { + const normalizedValues = normalizeSelectedValues(values) previewSelectedValueDrafts.value = { ...previewSelectedValueDrafts.value, - [categoryKey]: normalizeSelectedValues(values), + [categoryKey]: normalizedValues, + } + if (props.applyImmediately) { + setSelectedValues(categoryKey, normalizedValues) } } diff --git a/packages/ui/src/components/base/JoinedButtons.vue b/packages/ui/src/components/base/JoinedButtons.vue index 2c56d50414..8aab2dc96d 100644 --- a/packages/ui/src/components/base/JoinedButtons.vue +++ b/packages/ui/src/components/base/JoinedButtons.vue @@ -2,6 +2,7 @@