feat: hosting access tab (#5995)

* feat: implement access tab with dummy data

* fix: spacing

* feat: qa

* feat: implement backend

* qa: qa pass

* feat: fix user "search"

* fix: lint

* feat: change to bitfield

* feat: fix fields

* fix: lint

* fix: lint

* feat: hook up api

* feat: fix permissions

* feat: audit log table event start

* feat: better mobile mode for audit log table

* feat: i18n

* feat: qa

* feat: enforce permissions

* feat: email template start

* feat: qa

* fix: tooltip bug

* feat: qa

* impl: sse support in api-client

* feat: sse impl

* fix: desync path

* feat: time frame picker from analytics

* feat: QA

* fix: spacing

* fix: permisison audit log entries

* fix: hosting manage page shared server detection

* fix: lint

* feat: qa + lint

* feat: audit log table sort by time

* feat: finish frontend panel stuff

* fix: lint

* fix: backend alignment

* fix: lint

* fix: supress friend errors

* feat: qa

* fix: qa

* fix: lint

* fix: utils barrel

* fix: safari cookies in dev

* fix: pin nuxt

* feat: fixes + notif fix

* fix: notifications

* feat: qa

* fix: notification sync not happening immediately

* fix: qa

* fix: qa

* feat: qa

* blog + prepr

* feat: toast shit

* blog images

* thumbnail update one last time

* prepr

* feat: use reinvite route

* update images

* fix: reinvite stuff

* fix: lint

* fix: alignment of save bar

* fix: notif sizing

* fix: split up access

* fix: lint

* fix: lint

* fix: link

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-04 16:58:01 +01:00
committed by GitHub
parent 58ad58f958
commit bd97ace974
227 changed files with 15578 additions and 2153 deletions
@@ -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<string, Record<string, AbstractModule>> = 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<T>(path: string, options: RequestOptions): Promise<T> {
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<ReadableStream<Uint8Array>> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
} else if (options.api === 'archon') {
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
} else {
baseUrl = options.api
}
const url = this.buildUrl(path, baseUrl, options.version)
const defaultHeaders = await this.buildDefaultHeaders()
const mergedOptions: RequestOptions = {
method: 'GET',
retry: false,
circuitBreaker: false,
...options,
headers: {
...defaultHeaders,
Accept: 'text/event-stream',
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const context = this.buildContext(url, path, mergedOptions)
try {
return await this.executeFeatureChain<ReadableStream<Uint8Array>>(context, () =>
this.executeStreamRequest(context.url, context.options),
)
} catch (error) {
const apiError = this.normalizeError(error, context)
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}
}
/**
* Execute the feature chain and the actual request
*
* Features are executed in order, with each feature calling next() to continue.
* The last "feature" in the chain is the actual request execution.
*/
protected async executeFeatureChain<T>(context: RequestContext): Promise<T> {
protected async executeFeatureChain<T>(
context: RequestContext,
executeTerminal: () => Promise<T> = () => this.executeRequest<T>(context.url, context.options),
): Promise<T> {
// Filter to only features that should apply
const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context))
@@ -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<T>(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<T>(url: string, options: RequestOptions): Promise<T>
protected abstract executeStreamRequest(
url: string,
options: RequestOptions,
): Promise<ReadableStream<Uint8Array>>
/**
* Execute the actual XHR upload
*
@@ -0,0 +1,167 @@
import type mitt from 'mitt'
import type { Archon } from '../modules/archon/types'
import type { RequestOptions } from '../types/request'
export type SyncEventType = Archon.Sync.v1.SyncEvent['type']
export type SyncEventOfType<E extends SyncEventType> = Extract<
Archon.Sync.v1.SyncEvent,
{ type: E }
>
export type SyncEventHandler<E extends Archon.Sync.v1.SyncEvent = Archon.Sync.v1.SyncEvent> = (
event: E,
) => void
export type SyncStatusState =
| 'idle'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'disconnected'
| 'error'
export type SyncStatus = {
state: SyncStatusState
connected: boolean
reconnecting: boolean
reconnectAttempts: number
retryDelay: number
lastEventId?: string
error?: unknown
}
export type SyncStatusHandler = (status: SyncStatus) => void
export type SyncConnectOptions = {
intent?: Archon.Sync.v1.SyncIntent
force?: boolean
}
export type SyncConnection = {
serverId: string
intent: Archon.Sync.v1.SyncIntent
controller?: AbortController
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
reconnectResolve?: () => void
retryDelay: number
lastEventId?: string
stopped: boolean
status: SyncStatusState
error?: unknown
}
export type SyncEmitterEvents = Record<string, unknown>
export abstract class AbstractSyncClient {
protected connections = new Map<string, SyncConnection>()
protected abstract emitter: ReturnType<typeof mitt<SyncEmitterEvents>>
constructor(
protected client: {
stream: (path: string, options: RequestOptions) => Promise<ReadableStream<Uint8Array>>
},
) {}
abstract safeConnectServer(serverId: string, options?: SyncConnectOptions): Promise<void>
abstract disconnect(serverId: string): void
abstract disconnectAll(): void
on<E extends SyncEventType>(
serverId: string,
eventType: E,
handler: SyncEventHandler<SyncEventOfType<E>>,
): () => void {
const eventKey = this.getEventKey(serverId, eventType)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
onAny(serverId: string, handler: SyncEventHandler): () => void {
const eventKey = this.getAnyEventKey(serverId)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
onStatus(serverId: string, handler: SyncStatusHandler): () => void {
const eventKey = this.getStatusEventKey(serverId)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
getStatus(serverId: string): SyncStatus | null {
const connection = this.connections.get(serverId)
if (!connection) return null
return this.connectionToStatus(connection)
}
protected emitSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent): void {
this.emitter.emit(this.getEventKey(serverId, event.type), event)
this.emitter.emit(this.getAnyEventKey(serverId), event)
}
protected updateStatus(
connection: SyncConnection,
status: SyncStatusState,
error?: unknown,
): void {
connection.status = status
connection.error = error
this.emitter.emit(
this.getStatusEventKey(connection.serverId),
this.connectionToStatus(connection),
)
}
protected clearListeners(serverId: string): void {
this.emitter.all.forEach((_handlers, type) => {
if (type.toString().startsWith(`${serverId}:`)) {
this.emitter.all.delete(type)
}
})
}
protected connectionToStatus(connection: SyncConnection): SyncStatus {
return {
state: connection.status,
connected: connection.status === 'connected',
reconnecting: connection.status === 'reconnecting',
reconnectAttempts: connection.reconnectAttempts,
retryDelay: connection.retryDelay,
lastEventId: connection.lastEventId,
error: connection.error,
}
}
private getEventKey(serverId: string, eventType: string): string {
return `${serverId}:${eventType}`
}
private getAnyEventKey(serverId: string): string {
return `${serverId}:*`
}
private getStatusEventKey(serverId: string): string {
return `${serverId}:__status`
}
}
+19
View File
@@ -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'
@@ -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<Archon.Actions.v1.ActionLogResponse> {
const params: Record<string, string | number> = {}
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<Archon.Actions.v1.ActionLogResponse>(
`/servers/${serverId}/action-log`,
{
api: 'archon',
version: 1,
method: 'GET',
params: Object.keys(params).length > 0 ? params : undefined,
},
)
}
}
@@ -1,3 +1,4 @@
export * from './actions/v1'
export * from './backups/v1'
export * from './backups-queue/v1'
export * from './content/v1'
@@ -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<Archon.Nodes.Internal.Overview> {
return this.client.request<Archon.Nodes.Internal.Overview>('/nodes/overview', {
api: 'archon',
version: 'internal',
method: 'GET',
})
}
}
@@ -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<Archon.Notices.v0.ListedNotice[]> {
return this.client.request<Archon.Notices.v0.ListedNotice[]>('/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<Archon.Notices.v0.PostNoticeResponseBody> {
return this.client.request<Archon.Notices.v0.PostNoticeResponseBody>('/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<void> {
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<void> {
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<void> {
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<void> {
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<string, string> {
if ('server' in target) {
return { server: target.server }
}
return { node: target.node }
}
}
@@ -0,0 +1,83 @@
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<Archon.ServerUsers.v1.ServerUser[]> {
return this.client.request<Archon.ServerUsers.v1.ServerUser[]>(`/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<void> {
await this.client.request(`/servers/${serverId}/users`, {
api: 'archon',
version: 1,
method: 'POST',
body: user,
})
}
/**
* Re-send an invite to a pending server user.
* POST /v1/servers/:server_id/users/:user_id/reinvite
*/
public async reinvite(
serverId: string,
userId: string,
): Promise<Archon.ServerUsers.v1.ReinviteResponse> {
return this.client.request<Archon.ServerUsers.v1.ReinviteResponse>(
`/servers/${serverId}/users/${userId}/reinvite`,
{
api: 'archon',
version: 1,
method: 'POST',
},
)
}
/**
* Remove a user from a server
* DELETE /v1/servers/:server_id/users/:user_id
*/
public async delete(serverId: string, userId: string): Promise<void> {
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<void> {
await this.client.request(`/servers/${serverId}/users/${userId}`, {
api: 'archon',
version: 1,
method: 'PATCH',
body: JSON.stringify(role),
})
}
}
@@ -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<Archon.Transfers.Internal.ScheduleTransfersResponse> {
return this.client.request<Archon.Transfers.Internal.ScheduleTransfersResponse>(
'/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<Archon.Transfers.Internal.ScheduleTransfersResponse> {
return this.client.request<Archon.Transfers.Internal.ScheduleTransfersResponse>(
'/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<Archon.Transfers.Internal.TransferHistoryResponse> {
const params: Record<string, number> = {}
if (options?.page !== undefined) params.page = options.page
if (options?.page_size !== undefined) params.page_size = options.page_size
return this.client.request<Archon.Transfers.Internal.TransferHistoryResponse>(
'/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<Archon.Transfers.Internal.CancelTransfersResponse> {
return this.client.request<Archon.Transfers.Internal.CancelTransfersResponse>(
'/transfers/cancel',
{
api: 'archon',
version: 'internal',
method: 'POST',
body: request,
},
)
}
}
+449 -4
View File
@@ -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<string, UserResp>
addons: Record<string, AddonResp>
versions: Record<string, VersionResp>
}
export type ActionLogFilter = {
users?: string[]
worlds?: Array<string | null>
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,66 @@ export namespace Archon {
}
}
export namespace ServerUsers {
export namespace v1 {
export type ServerUserRole = 'Owner' | 'Editor' | 'Viewer' | 'Unknown'
export type AssignableServerUserRole = Exclude<ServerUserRole, 'Owner' | 'Unknown'>
export const UserScope = {
NONE: '',
SERVER_ADMIN: 'SERVER_ADMIN',
BASE_READ: 'BASE_READ',
POWER_ACTIONS: 'POWER_ACTIONS',
EXEC_COMMANDS: 'EXEC_COMMANDS',
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',
INFRA_USERS: 'INFRA_USERS',
} 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
last_invite_sent?: string | null
permissions: UserScope
}
export type AddServerUserRequest = {
server_id?: string | null
user_id: string
added_on?: string | null
role: ServerUserRole
}
export type ReinviteResponse = {
sent: boolean
cooldown_seconds: number | null
}
}
}
export namespace Servers {
export namespace v0 {
export type ServerGetResponse = {
servers: Server[]
pagination: Pagination
users: Record<string, ServerOwner>
}
export type Pagination = {
@@ -236,6 +572,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 +623,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 +767,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 +900,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 = {
+12
View File
@@ -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'
@@ -21,6 +26,7 @@ import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCampaignInternalModule } from './labrinth/campaign/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'
@@ -61,13 +67,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,
@@ -84,6 +95,7 @@ export const MODULE_REGISTRY = {
labrinth_campaign_internal: LabrinthCampaignInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
labrinth_friends_v3: LabrinthFriendsV3Module,
labrinth_globals_internal: LabrinthGlobalsInternalModule,
labrinth_moderation_internal: LabrinthModerationInternalModule,
labrinth_notifications_v2: LabrinthNotificationsV2Module,
@@ -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<Labrinth.Friends.v3.UserFriend[]> {
return this.client.request<Labrinth.Friends.v3.UserFriend[]>('/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<void> {
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<void> {
return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}
@@ -4,6 +4,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'
@@ -1350,6 +1350,12 @@ export namespace Labrinth {
github_id?: number
}
export type SearchUser = {
id: string
username: string
avatar_url: string | null
}
export type AllProjectsResponse = {
projects: Projects.v3.Project[]
organizations: Record<string, Organizations.v3.Organization>
@@ -1357,6 +1363,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 = {
@@ -37,6 +37,25 @@ export class LabrinthUsersV3Module extends AbstractModule {
)
}
/**
* Search users by username prefix.
*
* @param query - Username search query
* @returns Promise resolving to compact user search results
*
* GET /v3/users/search?query=:query
*/
public async search(query: string): Promise<Labrinth.Users.v3.SearchUser[]> {
return this.client.request<Labrinth.Users.v3.SearchUser[]>(
`/users/search?query=${encodeURIComponent(query)}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Get all projects the authenticated user can access directly or through
* their organizations.
+41 -1
View File
@@ -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<T>(url: string, options: RequestOptions): Promise<T> {
@@ -54,6 +62,38 @@ export class GenericModrinthClient extends XHRUploadClient {
}
}
protected async executeStreamRequest(
url: string,
options: RequestOptions,
): Promise<ReadableStream<Uint8Array>> {
try {
const response = await fetch(appendRequestParams(url, options.params), {
method: options.method ?? 'GET',
headers: options.headers,
body: toFetchBody(options.body),
signal: options.signal,
})
if (!response.ok) {
throw this.createNormalizedError(
new Error(`HTTP ${response.status}: ${response.statusText}`),
response.status,
await parseResponseErrorData(response),
)
}
if (!response.body) {
throw new ModrinthApiError('Streaming response has no readable body', {
statusCode: response.status,
})
}
return response.body
} catch (error) {
throw this.normalizeError(error)
}
}
protected normalizeError(error: unknown): ModrinthApiError {
if (error instanceof FetchError) {
return this.createNormalizedError(error, error.response?.status, error.data)
+42
View File
@@ -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<ReadableStream<Uint8Array>> {
try {
const response = await fetch(appendRequestParams(url, options.params), {
method: options.method ?? 'GET',
headers: options.headers,
body: toFetchBody(options.body),
signal: options.signal,
// @ts-expect-error - import.meta is provided by Nuxt
cache: import.meta.server ? undefined : 'no-store',
})
if (!response.ok) {
throw this.createNormalizedError(
new Error(`HTTP ${response.status}: ${response.statusText}`),
response.status,
await parseResponseErrorData(response),
)
}
if (!response.body) {
throw new ModrinthApiError('Streaming response has no readable body', {
statusCode: response.status,
})
}
return response.body
} catch (error) {
throw this.normalizeError(error)
}
}
protected normalizeError(error: unknown): ModrinthApiError {
if (error instanceof FetchError) {
return this.createNormalizedError(error, error.response?.status, error.data)
@@ -0,0 +1,229 @@
import mitt from 'mitt'
import {
AbstractSyncClient,
type SyncConnection,
type SyncConnectOptions,
type SyncEmitterEvents,
} from '../core/abstract-sync'
import type { Archon } from '../modules/archon/types'
import { type ParsedSseItem, parseSyncEventData, SseParser } from '../utils/sse'
type StreamReadResult = 'closed' | 'protocol-reconnect'
const DEFAULT_RETRY_DELAY = 1000
const MAX_RECONNECT_DELAY = 30000
const JITTER_MS = 1000
export class GenericSyncClient extends AbstractSyncClient {
protected emitter = mitt<SyncEmitterEvents>()
async safeConnectServer(serverId: string, options: SyncConnectOptions = {}): Promise<void> {
const existing = this.connections.get(serverId)
if (existing && !options.force && !existing.stopped && existing.status !== 'disconnected') {
return
}
if (existing) {
this.closeConnection(serverId)
}
const connection: SyncConnection = {
serverId,
intent: options.intent ?? 'all',
reconnectAttempts: 0,
retryDelay: DEFAULT_RETRY_DELAY,
stopped: false,
status: 'idle',
}
this.connections.set(serverId, connection)
void this.runConnection(connection)
}
disconnect(serverId: string): void {
this.closeConnection(serverId)
this.clearListeners(serverId)
}
disconnectAll(): void {
for (const serverId of this.connections.keys()) {
this.disconnect(serverId)
}
}
private async runConnection(connection: SyncConnection): Promise<void> {
while (!connection.stopped) {
const hadConnected = connection.status === 'connected'
this.updateStatus(connection, hadConnected ? 'reconnecting' : 'connecting')
const controller = new AbortController()
connection.controller = controller
try {
const stream = await this.client.stream('/sync', {
api: 'archon',
version: 1,
method: 'GET',
params: {
scope: `server:${connection.serverId}`,
intent: this.intentToParam(connection.intent),
},
headers: connection.lastEventId
? {
'Last-Event-Id': connection.lastEventId,
}
: undefined,
signal: controller.signal,
retry: false,
circuitBreaker: false,
})
if (connection.stopped) return
connection.reconnectAttempts = 0
this.updateStatus(connection, 'connected')
const result = await this.consumeStream(connection, stream)
connection.controller = undefined
if (connection.stopped) return
if (result === 'protocol-reconnect') {
connection.reconnectAttempts = 0
continue
}
await this.waitForReconnect(connection)
} catch (error) {
connection.controller = undefined
if (connection.stopped || this.isAbortError(error)) return
connection.reconnectAttempts++
this.updateStatus(connection, 'error', error)
console.warn(`[Sync] Connection failed for server ${connection.serverId}:`, error)
await this.waitForReconnect(connection)
}
}
}
private async consumeStream(
connection: SyncConnection,
stream: ReadableStream<Uint8Array>,
): Promise<StreamReadResult> {
const reader = stream.getReader()
const decoder = new TextDecoder()
const parser = new SseParser()
try {
while (!connection.stopped) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const result = this.processParsedItems(connection, parser.feed(chunk))
if (result === 'protocol-reconnect') {
await reader.cancel()
connection.controller?.abort()
return result
}
}
const finalChunk = decoder.decode()
const finalItems = finalChunk ? parser.feed(finalChunk) : []
const result = this.processParsedItems(connection, [...finalItems, ...parser.end()])
if (result === 'protocol-reconnect') {
await reader.cancel()
connection.controller?.abort()
return result
}
} finally {
reader.releaseLock()
}
return 'closed'
}
private processParsedItems(connection: SyncConnection, items: ParsedSseItem[]): StreamReadResult {
for (const item of items) {
if (item.kind === 'retry') {
connection.retryDelay = Math.min(item.retry, MAX_RECONNECT_DELAY)
continue
}
this.updateLastEventId(connection, item.id)
const event = parseSyncEventData(item.data)
if (!event) {
console.warn('[Sync] Dropping malformed SSE payload:', {
serverId: connection.serverId,
event: item.event,
data: item.data,
})
continue
}
this.emitSyncEvent(connection.serverId, event)
if (event.type === 'protocol.reset' || event.type === 'protocol.invalid') {
connection.lastEventId = undefined
return 'protocol-reconnect'
}
}
return 'closed'
}
private async waitForReconnect(connection: SyncConnection): Promise<void> {
if (connection.stopped) return
this.updateStatus(connection, 'reconnecting')
const delay = this.getReconnectDelay(connection)
await new Promise<void>((resolve) => {
connection.reconnectResolve = resolve
connection.reconnectTimer = setTimeout(() => {
connection.reconnectTimer = undefined
connection.reconnectResolve = undefined
resolve()
}, delay)
})
}
private closeConnection(serverId: string): void {
const connection = this.connections.get(serverId)
if (!connection) return
connection.stopped = true
connection.controller?.abort()
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
connection.reconnectResolve?.()
connection.reconnectResolve = undefined
this.updateStatus(connection, 'disconnected')
this.connections.delete(serverId)
}
private getReconnectDelay(connection: SyncConnection): number {
const exponentialDelay =
connection.retryDelay * Math.pow(2, Math.max(connection.reconnectAttempts - 1, 0))
return Math.min(exponentialDelay, MAX_RECONNECT_DELAY) + Math.random() * JITTER_MS
}
private updateLastEventId(connection: SyncConnection, id: string | undefined): void {
if (id === undefined) return
connection.lastEventId = id || undefined
}
private intentToParam(intent: Archon.Sync.v1.SyncIntent): string {
return Array.isArray(intent) ? intent.join(',') : intent
}
private isAbortError(error: unknown): boolean {
if (!(error instanceof Error)) return false
return error.name === 'AbortError' || error.message.toLowerCase().includes('abort')
}
}
+45 -30
View File
@@ -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<T>(url: string, options: RequestOptions): Promise<T> {
@@ -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<string, string> = {}
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<ReadableStream<Uint8Array>> {
try {
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
const response = await tauriFetch(appendRequestParams(url, options.params), {
method: options.method ?? 'GET',
headers: options.headers,
body: toFetchBody(options.body),
signal: options.signal,
})
if (!response.ok) {
throw this.createNormalizedError(
new Error(`HTTP ${response.status}: ${response.statusText}`),
response.status,
await parseResponseErrorData(response),
)
}
if (!response.body) {
throw this.createNormalizedError(
new Error('Streaming response has no readable body'),
response.status,
undefined,
)
}
return response.body
} catch (error) {
throw this.normalizeError(error)
}
}
protected normalizeError(error: unknown): ModrinthApiError {
if (error instanceof Error) {
const httpError = error as HttpError
@@ -18,9 +18,9 @@ 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!
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
}
+5 -2
View File
@@ -3,6 +3,7 @@ import type { RequestContext } from './request'
export type MaybePromise<T> = T | Promise<T>
export type UserAgentProvider = string | (() => MaybePromise<string | undefined>)
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
+1 -1
View File
@@ -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'
+55
View File
@@ -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<string, string> = {}
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<unknown> {
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
}
}
+139
View File
@@ -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
}