feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -8,6 +8,8 @@ import type { RequestContext } from '../types/request'
export interface NodeAuth {
/** Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs") */
url: string
/** Base URL without path suffix (e.g., "node-xyz.modrinth.com") — used when available */
baseUrl?: string
/** JWT token */
token: string
}
@@ -105,7 +107,7 @@ export class NodeAuthFeature extends AbstractFeature {
}
private applyAuth(context: RequestContext, auth: NodeAuth): void {
const baseUrl = `https://${auth.url.replace('v0/fs', '')}`
const baseUrl = `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}`
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
context.options.headers = {

View File

@@ -1,77 +0,0 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonBackupsV0Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_v0'
}
/** GET /modrinth/v0/servers/:server_id/backups */
public async list(serverId: string): Promise<Archon.Backups.v1.Backup[]> {
return this.client.request<Archon.Backups.v1.Backup[]>(`/servers/${serverId}/backups`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/** GET /modrinth/v0/servers/:server_id/backups/:backup_id */
public async get(serverId: string, backupId: string): Promise<Archon.Backups.v1.Backup> {
return this.client.request<Archon.Backups.v1.Backup>(
`/servers/${serverId}/backups/${backupId}`,
{ api: 'archon', version: 'modrinth/v0', method: 'GET' },
)
}
/** POST /modrinth/v0/servers/:server_id/backups */
public async create(
serverId: string,
request: Archon.Backups.v1.BackupRequest,
): Promise<Archon.Backups.v1.PostBackupResponse> {
return this.client.request<Archon.Backups.v1.PostBackupResponse>(
`/servers/${serverId}/backups`,
{ api: 'archon', version: 'modrinth/v0', method: 'POST', body: request },
)
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/restore */
public async restore(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/restore`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** DELETE /modrinth/v0/servers/:server_id/backups/:backup_id */
public async delete(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'DELETE',
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/retry */
public async retry(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/retry`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** PATCH /modrinth/v0/servers/:server_id/backups/:backup_id */
public async rename(
serverId: string,
backupId: string,
request: Archon.Backups.v1.PatchBackup,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'PATCH',
body: request,
})
}
}

View File

@@ -1,102 +1,84 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
/**
* Default world ID - Uuid::nil() which the backend treats as "first/active world"
* See: apps/archon/src/routes/v1/servers/worlds/mod.rs - world_id_nullish()
* TODO:
* - Make sure world ID is being passed before we ship worlds.
* - The schema will change when Backups v4 (routes stay as v1) so remember to do that.
*/
const DEFAULT_WORLD_ID: string = '00000000-0000-0000-0000-000000000000' as const
export class ArchonBackupsV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_v1'
}
/** GET /v1/:server_id/worlds/:world_id/backups */
public async list(
serverId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<Archon.Backups.v1.Backup[]> {
/** GET /v1/servers/:server_id/worlds/:world_id/backups */
public async list(serverId: string, worldId: string): Promise<Archon.Backups.v1.Backup[]> {
return this.client.request<Archon.Backups.v1.Backup[]>(
`/${serverId}/worlds/${worldId}/backups`,
`/servers/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/** GET /v1/:server_id/worlds/:world_id/backups/:backup_id */
/** GET /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async get(
serverId: string,
worldId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<Archon.Backups.v1.Backup> {
return this.client.request<Archon.Backups.v1.Backup>(
`/${serverId}/worlds/${worldId}/backups/${backupId}`,
`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/** POST /v1/:server_id/worlds/:world_id/backups */
/** POST /v1/servers/:server_id/worlds/:world_id/backups */
public async create(
serverId: string,
worldId: string,
request: Archon.Backups.v1.BackupRequest,
worldId: string = DEFAULT_WORLD_ID,
): Promise<Archon.Backups.v1.PostBackupResponse> {
return this.client.request<Archon.Backups.v1.PostBackupResponse>(
`/${serverId}/worlds/${worldId}/backups`,
`/servers/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/restore */
public async restore(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/restore`, {
api: 'archon',
version: 1,
method: 'POST',
})
/** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/restore */
public async restore(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups/${backupId}/restore`,
{
api: 'archon',
version: 1,
method: 'POST',
},
)
}
/** DELETE /v1/:server_id/worlds/:world_id/backups/:backup_id */
public async delete(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
/** DELETE /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async delete(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'DELETE',
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/retry */
public async retry(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/retry`, {
api: 'archon',
version: 1,
method: 'POST',
})
/** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/retry */
public async retry(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups/${backupId}/retry`,
{
api: 'archon',
version: 1,
method: 'POST',
},
)
}
/** PATCH /v1/:server_id/worlds/:world_id/backups/:backup_id */
/** PATCH /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async rename(
serverId: string,
worldId: string,
backupId: string,
request: Archon.Backups.v1.PatchBackup,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'PATCH',

View File

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

View File

@@ -0,0 +1,276 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonContentV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_content_v1'
}
/** GET /v1/:server_id/worlds/:world_id/addons */
public async getAddons(
serverId: string,
worldId: string,
options?: {
from_modpack?: boolean
disabled?: boolean
addons?: boolean
updates?: boolean
},
): Promise<Archon.Content.v1.Addons> {
const params = new URLSearchParams()
if (options?.from_modpack !== undefined)
params.set('from_modpack', String(options.from_modpack))
if (options?.disabled !== undefined) params.set('disabled', String(options.disabled))
if (options?.addons !== undefined) params.set('addons', String(options.addons))
if (options?.updates !== undefined) params.set('updates', String(options.updates))
const query = params.toString()
return this.client.request<Archon.Content.v1.Addons>(
`/servers/${serverId}/worlds/${worldId}/addons${query ? `?${query}` : ''}`,
{
api: 'archon',
version: 1,
method: 'GET',
},
)
}
/** POST /v1/:server_id/worlds/:world_id/addons */
public async addAddon(
serverId: string,
worldId: string,
request: Archon.Content.v1.AddAddonRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons`, {
api: 'archon',
version: 1,
method: 'POST',
body: request,
})
}
/** POST /v1/:server_id/worlds/:world_id/addons/delete */
public async deleteAddon(
serverId: string,
worldId: string,
request: Archon.Content.v1.RemoveAddonRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/delete`, {
api: 'archon',
version: 1,
method: 'POST',
body: request,
})
}
/** POST /v1/:server_id/worlds/:world_id/addons/disable */
public async disableAddon(
serverId: string,
worldId: string,
request: Archon.Content.v1.RemoveAddonRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/disable`, {
api: 'archon',
version: 1,
method: 'POST',
body: request,
})
}
/** POST /v1/:server_id/worlds/:world_id/addons/enable */
public async enableAddon(
serverId: string,
worldId: string,
request: Archon.Content.v1.RemoveAddonRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/enable`, {
api: 'archon',
version: 1,
method: 'POST',
body: request,
})
}
/** POST /v1/:server_id/worlds/:world_id/addons/delete-many */
public async deleteAddons(
serverId: string,
worldId: string,
items: Archon.Content.v1.RemoveAddonRequest[],
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/delete-many`, {
api: 'archon',
version: 1,
method: 'POST',
body: { items },
})
}
/** POST /v1/:server_id/worlds/:world_id/addons/disable-many */
public async disableAddons(
serverId: string,
worldId: string,
items: Archon.Content.v1.RemoveAddonRequest[],
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/disable-many`, {
api: 'archon',
version: 1,
method: 'POST',
body: { items },
})
}
/** POST /v1/:server_id/worlds/:world_id/addons/enable-many */
public async enableAddons(
serverId: string,
worldId: string,
items: Archon.Content.v1.RemoveAddonRequest[],
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/enable-many`, {
api: 'archon',
version: 1,
method: 'POST',
body: { items },
})
}
/** POST /v1/:server_id/worlds/:world_id/content */
public async installContent(
serverId: string,
worldId: string,
request: Archon.Content.v1.InstallWorldContent,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/content`, {
api: 'archon',
version: 1,
method: 'POST',
body: request,
})
}
/** POST /v1/:server_id/worlds/:world_id/content/repair */
public async repair(serverId: string, worldId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/content/repair`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** POST /v1/:server_id/worlds/:world_id/content/unlink-modpack */
public async unlinkModpack(serverId: string, worldId: string): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/content/unlink-modpack`,
{
api: 'archon',
version: 1,
method: 'POST',
},
)
}
/** GET /v1/:server_id/worlds/:world_id/addons/update?filename=... */
public async getAddonUpdate(
serverId: string,
worldId: string,
filename: string,
): Promise<Archon.Content.v1.Addon> {
return this.client.request<Archon.Content.v1.Addon>(
`/servers/${serverId}/worlds/${worldId}/addons/update?filename=${encodeURIComponent(filename)}`,
{
api: 'archon',
version: 1,
method: 'GET',
},
)
}
/** POST /v1/:server_id/worlds/:world_id/addons/update */
public async updateAddon(
serverId: string,
worldId: string,
request: Archon.Content.v1.UpdateAddonRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/update`, {
api: 'archon',
version: 1,
method: 'POST',
body: request,
})
}
/** POST /v1/:server_id/worlds/:world_id/addons/update-many */
public async updateAddons(
serverId: string,
worldId: string,
addons: Archon.Content.v1.UpdateAddonRequest[],
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/update-many`, {
api: 'archon',
version: 1,
method: 'POST',
body: { addons },
})
}
/** GET /v1/:server_id/worlds/:world_id/content/modpack/update */
public async getModpackUpdate(
serverId: string,
worldId: string,
): Promise<Archon.Content.v1.ModpackFields> {
return this.client.request<Archon.Content.v1.ModpackFields>(
`/servers/${serverId}/worlds/${worldId}/content/modpack/update`,
{
api: 'archon',
version: 1,
method: 'GET',
},
)
}
/** POST /v1/:server_id/worlds/:world_id/content/modpack/update */
public async updateModpack(serverId: string, worldId: string): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/content/modpack/update`,
{
api: 'archon',
version: 1,
method: 'POST',
},
)
}
/** GET /v1/:server_id/worlds/:world_id/content/update-game-version?game_version=... */
public async getUpdateGameVersionPreview(
serverId: string,
worldId: string,
gameVersion: string,
signal?: AbortSignal,
): Promise<Archon.Content.v1.UpdateGameVersionPreview> {
return this.client.request<Archon.Content.v1.UpdateGameVersionPreview>(
`/servers/${serverId}/worlds/${worldId}/content/update-game-version?game_version=${encodeURIComponent(gameVersion)}`,
{
api: 'archon',
version: 1,
method: 'GET',
timeout: 1000 * 1000,
signal,
},
)
}
/** POST /v1/:server_id/worlds/:world_id/content/update-game-version?game_version=... */
public async applyGameVersionUpdate(
serverId: string,
worldId: string,
gameVersion: string,
): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/content/update-game-version?game_version=${encodeURIComponent(gameVersion)}`,
{
api: 'archon',
version: 1,
method: 'POST',
},
)
}
}

View File

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

View File

@@ -0,0 +1,37 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonOptionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_options_v1'
}
/** GET /v1/servers/:server_id/worlds/:world_id/options/startup */
public async getStartup(
serverId: string,
worldId: string,
): Promise<Archon.Content.v1.RuntimeOptions> {
return this.client.request<Archon.Content.v1.RuntimeOptions>(
`/servers/${serverId}/worlds/${worldId}/options/startup`,
{
api: 'archon',
version: 1,
method: 'GET',
},
)
}
/** PATCH /v1/servers/:server_id/worlds/:world_id/options/startup */
public async patchStartup(
serverId: string,
worldId: string,
body: Archon.Content.v1.PatchRuntimeOptions,
): Promise<void> {
await this.client.request(`/servers/${serverId}/worlds/${worldId}/options/startup`, {
api: 'archon',
version: 1,
method: 'PATCH',
body,
})
}
}

View File

@@ -0,0 +1,40 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonPropertiesV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_properties_v1'
}
/** GET /v1/servers/:server_id/worlds/:world_id/properties */
public async getProperties(
serverId: string,
worldId: string,
): Promise<Archon.Content.v1.PropertiesFields> {
return this.client.request<Archon.Content.v1.PropertiesFields>(
`/servers/${serverId}/worlds/${worldId}/properties`,
{
api: 'archon',
version: 1,
method: 'GET',
},
)
}
/** PATCH /v1/servers/:server_id/worlds/:world_id/properties */
public async patchProperties(
serverId: string,
worldId: string,
body: Archon.Content.v1.PatchPropertiesFields,
): Promise<Archon.Content.v1.PropertiesFields> {
return this.client.request<Archon.Content.v1.PropertiesFields>(
`/servers/${serverId}/worlds/${worldId}/properties`,
{
api: 'archon',
version: 1,
method: 'PATCH',
body,
},
)
}
}

View File

@@ -1,4 +1,5 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Archon } from '../types'
export class ArchonServersV0Module extends AbstractModule {
@@ -94,4 +95,210 @@ export class ArchonServersV0Module extends AbstractModule {
body: { action },
})
}
/**
* Reinstall a server with a new loader or modpack
* POST /modrinth/v0/servers/:id/reinstall
*/
public async reinstall(
serverId: string,
request: Archon.Servers.v0.ReinstallRequest,
hardReset: boolean = false,
): Promise<void> {
await this.client.request(`/servers/${serverId}/reinstall`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
params: { hard: String(hardReset) },
body: request,
})
}
/**
* Get authentication credentials for .mrpack file upload
* GET /modrinth/v0/servers/:id/reinstallFromMrpack
*/
public async getReinstallMrpackAuth(
serverId: string,
): Promise<Archon.Servers.v0.MrpackReinstallAuth> {
return this.client.request<Archon.Servers.v0.MrpackReinstallAuth>(
`/servers/${serverId}/reinstallFromMrpack`,
{
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
},
)
}
/**
* Reinstall a server from a .mrpack file with progress tracking
*
* Two-step flow: fetches upload auth, then uploads the .mrpack file to the node.
*
* @param serverId - Server ID
* @param file - .mrpack file to upload
* @param hardReset - Whether to erase all server data
* @param options - Optional progress callback
* @returns Promise resolving to an UploadHandle with progress tracking and cancellation
*/
public async reinstallFromMrpack(
serverId: string,
file: File,
hardReset: boolean = false,
options?: {
onProgress?: (progress: UploadProgress) => void
},
): Promise<UploadHandle<void>> {
const auth = await this.getReinstallMrpackAuth(serverId)
const formData = new FormData()
formData.append('file', file)
return this.client.upload<void>('', {
api: `https://${auth.url}`,
version: 'reinstallMrpackMultiparted',
formData,
params: { hard: String(hardReset) },
headers: { Authorization: `Bearer ${auth.token}` },
skipAuth: true,
onProgress: options?.onProgress,
retry: false,
})
}
/**
* Update a server's name
* POST /modrinth/v0/servers/:id/name
*/
public async updateName(serverId: string, name: string): Promise<void> {
await this.client.request(`/servers/${serverId}/name`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
body: { name },
})
}
/**
* Get allocations for a server
* GET /modrinth/v0/servers/:id/allocations
*/
public async getAllocations(serverId: string): Promise<Archon.Servers.v0.Allocation[]> {
return this.client.request<Archon.Servers.v0.Allocation[]>(`/servers/${serverId}/allocations`, {
api: 'archon',
method: 'GET',
version: 'modrinth/v0',
})
}
/**
* Reserve a new allocation for a server
* POST /modrinth/v0/servers/:id/allocations?name=...
*/
public async reserveAllocation(
serverId: string,
name: string,
): Promise<Archon.Servers.v0.Allocation> {
return this.client.request<Archon.Servers.v0.Allocation>(`/servers/${serverId}/allocations`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
params: { name },
})
}
/**
* Update an allocation's name
* PUT /modrinth/v0/servers/:id/allocations/:port?name=...
*/
public async updateAllocation(serverId: string, port: number, name: string): Promise<void> {
await this.client.request(`/servers/${serverId}/allocations/${port}`, {
api: 'archon',
method: 'PUT',
version: 'modrinth/v0',
params: { name },
})
}
/**
* Delete an allocation
* DELETE /modrinth/v0/servers/:id/allocations/:port
*/
public async deleteAllocation(serverId: string, port: number): Promise<void> {
await this.client.request(`/servers/${serverId}/allocations/${port}`, {
api: 'archon',
method: 'DELETE',
version: 'modrinth/v0',
})
}
/**
* Check if a subdomain is available
* GET /modrinth/v0/subdomains/:subdomain/isavailable
*/
public async checkSubdomainAvailability(subdomain: string): Promise<{ available: boolean }> {
return this.client.request<{ available: boolean }>(`/subdomains/${subdomain}/isavailable`, {
api: 'archon',
method: 'GET',
version: 'modrinth/v0',
})
}
/**
* Change a server's subdomain
* POST /modrinth/v0/servers/:id/subdomain
*/
public async changeSubdomain(serverId: string, subdomain: string): Promise<void> {
await this.client.request(`/servers/${serverId}/subdomain`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
body: { subdomain },
})
}
/**
* Get startup configuration for a server
* GET /modrinth/v0/servers/:id/startup
*/
public async getStartupConfig(serverId: string): Promise<Archon.Servers.v0.StartupConfig> {
return this.client.request<Archon.Servers.v0.StartupConfig>(`/servers/${serverId}/startup`, {
api: 'archon',
method: 'GET',
version: 'modrinth/v0',
})
}
/**
* Update startup configuration for a server
* POST /modrinth/v0/servers/:id/startup
*/
public async updateStartupConfig(
serverId: string,
config: {
invocation: string | null
jdk_version: string | null
jdk_build: string | null
},
): Promise<void> {
await this.client.request(`/servers/${serverId}/startup`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
body: config,
})
}
/**
* Dismiss a server notice
* POST /modrinth/v0/servers/:id/notices/:noticeId/dismiss
*/
public async dismissNotice(serverId: string, noticeId: number): Promise<void> {
await this.client.request(`/servers/${serverId}/notices/${noticeId}/dismiss`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
})
}
}

View File

@@ -6,6 +6,30 @@ export class ArchonServersV1Module extends AbstractModule {
return 'archon_servers_v1'
}
/**
* Get list of servers for the authenticated user
* GET /v1/servers
*/
public async list(): Promise<Archon.Servers.v1.ServerFull[]> {
return this.client.request<Archon.Servers.v1.ServerFull[]>('/servers', {
api: 'archon',
version: 1,
method: 'GET',
})
}
/**
* Get full server details including worlds, backups, and content
* GET /v1/servers/:server_id
*/
public async get(serverId: string): Promise<Archon.Servers.v1.ServerFull> {
return this.client.request<Archon.Servers.v1.ServerFull>(`/servers/${serverId}`, {
api: 'archon',
version: 1,
method: 'GET',
})
}
/**
* Get available regions
* GET /v1/regions
@@ -17,4 +41,16 @@ export class ArchonServersV1Module extends AbstractModule {
method: 'GET',
})
}
/**
* End the intro flow for a server
* DELETE /v1/servers/:id/flows/intro
*/
public async endIntro(serverId: string): Promise<void> {
await this.client.request(`/servers/${serverId}/flows/intro`, {
api: 'archon',
version: 1,
method: 'DELETE',
})
}
}

View File

@@ -1,37 +1,211 @@
import type { Labrinth } from '../labrinth/types'
export namespace Archon {
export namespace Content {
export namespace v0 {
export type ContentKind = 'mod' | 'plugin'
export namespace v1 {
export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack'
export type Mod = {
export type ContentOwnerType = 'user' | 'organization'
export type ContentOwner = {
id: string
name: string
type: ContentOwnerType
icon_url: string | null
}
export type AddonVersion = {
id: string
name: string | null
environment?: Labrinth.Projects.v3.Environment | null
}
export type Addon = {
id: string
filename: string
project_id: string | undefined
version_id: string | undefined
name: string | undefined
version_number: string | undefined
icon_url: string | undefined
owner: string | undefined
filesize: number
disabled: boolean
installing: boolean
kind: AddonKind
from_modpack: boolean
has_update: string | null
name: string | null
project_id: string | null
version: AddonVersion | null
owner: ContentOwner | null
icon_url: string | null
}
export type InstallModRequest = {
rinth_ids: {
project_id: string
version_id: string
}
install_as: ContentKind
export type Addons = {
modloader: string | null
modloader_version: string | null
game_version: string | null
modpack: ModpackFields | null
addons: Addon[] | null
}
export type DeleteModRequest = {
path: string
export type AddAddonRequest = {
project_id: string
version_id?: string
kind?: AddonKind
}
export type UpdateModRequest = {
replace: string
export type RemoveAddonRequest = {
kind: AddonKind
filename: string
}
export type UpdateAddonRequest = {
filename: string
version_id?: string | null
}
export type Modloader =
| 'forge'
| 'neo_forge'
| 'fabric'
| 'quilt'
| 'paper'
| 'purpur'
| 'vanilla'
export type ModpackSpec = {
platform: 'modrinth'
project_id: string
version_id: string
}
export type ModpackOwner = {
id: string
name: string
type: 'user' | 'organization'
icon_url: string | null
}
export type ModpackFields = {
spec: ModpackSpec
has_update: string | null
title: string | null
description: string | null
icon_url: string | null
owner: ModpackOwner | null
version_number: string | null
date_published: string | null
downloads: number | null
followers: number | null
}
export type KnownPropertiesFields = {
allow_cheats?: string | null
allow_flight?: string | null
difficulty?: string | null
enforce_whitelist?: string | null
force_gamemode?: string | null
gamemode?: string | null
generate_structures?: string | null
generator_settings?: string | null
hardcore?: string | null
level_seed?: string | null
level_type?: string | null
max_players?: string | null
max_tick_time?: string | null
motd?: string | null
pause_when_empty_seconds?: string | null
player_idle_timeout?: string | null
require_resource_pack?: string | null
resource_pack?: string | null
resource_pack_id?: string | null
resource_pack_sha1?: string | null
simulation_distance?: string | null
spawn_protection?: string | null
sync_chunk_writes?: string | null
view_distance?: string | null
white_list?: string | null
}
export type PropertiesFields = {
known: KnownPropertiesFields
custom?: Record<string, string>
}
export type PatchPropertiesFields = {
known?: KnownPropertiesFields
custom?: Record<string, string | null>
}
export type JreVendor = 'temurin' | 'corretto' | 'graal'
export type RuntimeOptions = {
java_version: number | null
jre_vendor: JreVendor | null
original_invocation: string | null
startup_command: string | null
}
export type PatchRuntimeOptions = {
java_version?: number | null
jre_vendor?: JreVendor | null
startup_command?: string | null
}
export type InstallWorldContent =
| {
content_variant: 'modpack'
spec: ModpackSpec
soft_override: boolean
properties?: PropertiesFields | null
}
| {
content_variant: 'bare'
loader: Modloader
version: string
game_version?: string
soft_override: boolean
properties?: PropertiesFields | null
}
export type AddonDiffVersion = {
id: string
version_number: string
}
export type AddonDiffProject = {
id: string
title: string
icon_url: string | null
slug: string
}
export type AddonBaseDiffInfo = {
current_version: AddonDiffVersion | null
new_version: AddonDiffVersion | null
file_name: string | null
project_id: string | null
project: AddonDiffProject | null
}
export type AddonDiffAdded = AddonBaseDiffInfo & {
type: 'added'
new_version_id: string
}
export type AddonDiffRemoved = AddonBaseDiffInfo & {
type: 'removed'
}
export type AddonDiffUpdated = AddonBaseDiffInfo & {
type: 'updated'
current_version_id: string
new_version_id: string
}
export type AddonDiff = AddonDiffAdded | AddonDiffRemoved | AddonDiffUpdated
export type UpdateGameVersionPreview = {
addon_changes: AddonDiff[]
new_game_version: string
new_loader_version: string
has_unknown_content: boolean
}
}
}
@@ -148,9 +322,95 @@ export namespace Archon {
url: string // e.g., "node-xyz.modrinth.com/modrinth/v0/fs"
token: string // JWT token for filesystem access
}
export type ReinstallLoaderRequest = {
loader: string
loader_version?: string
game_version?: string
}
export type ReinstallModpackRequest = {
project_id: string
version_id?: string
}
export type ReinstallRequest = ReinstallLoaderRequest | ReinstallModpackRequest
export type MrpackReinstallAuth = {
url: string
token: string
}
export type Allocation = {
port: number
name: string
}
export type StartupConfig = {
invocation: string
original_invocation: string
jdk_version: 'lts8' | 'lts11' | 'lts17' | 'lts21'
jdk_build: 'corretto' | 'temurin' | 'graal'
}
}
export namespace v1 {
export type ServerFull = {
id: string
name: string
subdomain: string
specs: ServerResources
sftp_username: string
sftp_password: string
tags: string[]
location: ServerLocation
worlds: WorldFull[]
}
export type ServerResources = {
cpu: number
memory_mb: number
storage_mb: number
swap_mb: number
}
export type ServerLocation =
| {
status: 'assigned'
location_metadata: {
region: string
region_should_be_user_displayed: boolean
hostname: string
is_decommissioned_node: boolean
}
}
| {
status: 'unassigned'
}
export type WorldFull = {
id: string
name: string
created_at: string
is_active: boolean
backups: Archon.Backups.v1.Backup[]
content: WorldContentInfo | null
readiness: WorldReadiness
}
export type WorldReadiness = {
data_synchronized_fetched: boolean
}
export type WorldContentInfo = {
modloader: string
modloader_version: string
game_version: string
java_version: number
invocation: string
original_invocation: string
}
export type Region = {
shortcode: string
country_code: string
@@ -174,19 +434,18 @@ export namespace Archon {
export type Backup = {
id: string
physical_id: string
name: string
created_at: string
automated: boolean
interrupted: boolean
ongoing: boolean
locked: boolean
task?: {
file?: BackupTaskProgress
create?: BackupTaskProgress
restore?: BackupTaskProgress
}
// TODO: Uncomment when API supports these fields
// size?: number // bytes
// creator_id?: string // user ID, or 'auto' for automated backups
}
export type BackupRequest = {
@@ -319,6 +578,37 @@ export namespace Archon {
all: FilesystemOperation[]
}
export type ReadinessState =
| 'deprovisioned'
| 'waiting_active_world'
| 'waiting_world_spec_details_for_progress'
| 'pulling_world_data'
| 'migration_zfs'
| 'sync_content'
| 'container_readying'
| 'ready'
export type FlattenedPowerState = 'not_ready' | 'starting' | 'running' | 'stopping' | 'idle'
export type SyncInstallPhase = 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
export type SyncContentProgress = {
started_at: string
phase: SyncInstallPhase
percent: number
}
export type WSStateEvent = {
event: 'state'
debug: string
power_variant: FlattenedPowerState
exit_code?: number | null
was_oom?: boolean
target: 'start' | 'stop' | 'restart' | null
uptime: number
progress: SyncContentProgress | null
}
// Outgoing messages (client -> server)
export type WSOutgoingMessage = WSAuthMessage | WSCommandMessage
@@ -337,6 +627,7 @@ export namespace Archon {
| WSLogEvent
| WSStatsEvent
| WSPowerStateEvent
| WSStateEvent
| WSAuthExpiringEvent
| WSAuthIncorrectEvent
| WSAuthOkEvent

View File

@@ -1,13 +1,15 @@
import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module'
import { ArchonBackupsV0Module } from './archon/backups/v0'
import { ArchonBackupsV1Module } from './archon/backups/v1'
import { ArchonContentV0Module } from './archon/content/v0'
import { ArchonContentV1Module } from './archon/content/v1'
import { ArchonOptionsV1Module } from './archon/options/v1'
import { ArchonPropertiesV1Module } from './archon/properties/v1'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { LabrinthVersionsV3Module } from './labrinth'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
@@ -17,6 +19,9 @@ import { LabrinthStateModule } from './labrinth/state'
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
import { LabrinthThreadsV3Module } from './labrinth/threads/v3'
import { LabrinthUsersV2Module } from './labrinth/users/v2'
import { LauncherMetaManifestV0Module } from './launcher-meta/v0'
import { PaperVersionsV3Module } from './paper/v3'
import { PurpurVersionsV2Module } from './purpur/v2'
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
@@ -30,12 +35,15 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
* TODO: Better way? Probably not
*/
export const MODULE_REGISTRY = {
archon_backups_v0: ArchonBackupsV0Module,
archon_backups_v1: ArchonBackupsV1Module,
archon_content_v0: ArchonContentV0Module,
archon_content_v1: ArchonContentV1Module,
archon_options_v1: ArchonOptionsV1Module,
archon_properties_v1: ArchonPropertiesV1Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
iso3166_data: ISO3166Module,
launchermeta_manifest_v0: LauncherMetaManifestV0Module,
kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_collections: LabrinthCollectionsModule,
@@ -46,7 +54,10 @@ export const MODULE_REGISTRY = {
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
labrinth_threads_v3: LabrinthThreadsV3Module,
labrinth_users_v2: LabrinthUsersV2Module,
labrinth_versions_v2: LabrinthVersionsV2Module,
labrinth_versions_v3: LabrinthVersionsV3Module,
paper_versions_v3: PaperVersionsV3Module,
purpur_versions_v2: PurpurVersionsV2Module,
} as const satisfies Record<string, ModuleConstructor>
export type ModuleID = keyof typeof MODULE_REGISTRY

View File

@@ -0,0 +1,65 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Archon } from '../../archon/types'
export class KyrosContentV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_content_v1'
}
/**
* Upload addon files to a world via multipart form data
*
* @param worldId - World UUID
* @param files - Files to upload as addons
* @param options - Optional progress callback
* @returns UploadHandle with promise, onProgress, and cancel
*/
public uploadAddonFile(
worldId: string,
files: (File | Blob)[],
options?: {
onProgress?: (progress: UploadProgress) => void
},
): UploadHandle<void> {
const formData = new FormData()
for (const file of files) {
formData.append('file', file, file instanceof File ? file.name : 'file')
}
return this.client.upload<void>(`/worlds/${worldId}/content/upload-addon-file`, {
api: '',
version: 'v1',
formData,
onProgress: options?.onProgress,
useNodeAuth: true,
})
}
/** POST /v1/worlds/:world_id/content/upload-modpack-file */
public uploadModpackFile(
worldId: string,
file: File | Blob,
properties: Archon.Content.v1.PropertiesFields,
options?: {
softOverride?: boolean
onProgress?: (progress: UploadProgress) => void
},
): UploadHandle<void> {
const formData = new FormData()
formData.append('file', file, file instanceof File ? file.name : 'file')
formData.append('properties', JSON.stringify(properties))
return this.client.upload<void>(`/worlds/${worldId}/content/upload-modpack-file`, {
api: '',
version: 'v1',
formData,
params:
options?.softOverride !== undefined
? { soft_override: String(options.softOverride) }
: undefined,
onProgress: options?.onProgress,
useNodeAuth: true,
})
}
}

View File

@@ -22,7 +22,7 @@ export class KyrosFilesV0Module extends AbstractModule {
): Promise<Kyros.Files.v0.DirectoryResponse> {
return this.client.request<Kyros.Files.v0.DirectoryResponse>('/fs/list', {
api: '',
version: 'v0',
version: 'modrinth/v0',
method: 'GET',
params: { path, page, page_size: pageSize },
useNodeAuth: true,
@@ -38,7 +38,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async createFileOrFolder(path: string, type: 'file' | 'directory'): Promise<void> {
return this.client.request<void>('/fs/create', {
api: '',
version: 'v0',
version: 'modrinth/v0',
method: 'POST',
params: { path, type },
headers: { 'Content-Type': 'application/octet-stream' },
@@ -55,7 +55,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async downloadFile(path: string): Promise<Blob> {
return this.client.request<Blob>('/fs/download', {
api: '',
version: 'v0',
version: 'modrinth/v0',
method: 'GET',
params: { path },
useNodeAuth: true,
@@ -80,7 +80,7 @@ export class KyrosFilesV0Module extends AbstractModule {
): UploadHandle<void> {
return this.client.upload<void>('/fs/create', {
api: '',
version: 'v0',
version: 'modrinth/v0',
file,
params: { path, type: 'file' },
onProgress: options?.onProgress,
@@ -100,7 +100,7 @@ export class KyrosFilesV0Module extends AbstractModule {
return this.client.request<void>('/fs/update', {
api: '',
version: 'v0',
version: 'modrinth/v0',
method: 'PUT',
params: { path },
body: blob,
@@ -118,7 +118,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async moveFileOrFolder(sourcePath: string, destPath: string): Promise<void> {
return this.client.request<void>('/fs/move', {
api: '',
version: 'v0',
version: 'modrinth/v0',
method: 'POST',
body: { source: sourcePath, destination: destPath },
useNodeAuth: true,
@@ -145,7 +145,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async deleteFileOrFolder(path: string, recursive: boolean): Promise<void> {
return this.client.request<void>('/fs/delete', {
api: '',
version: 'v0',
version: 'modrinth/v0',
method: 'DELETE',
params: { path, recursive },
useNodeAuth: true,

View File

@@ -7,4 +7,5 @@ export * from './state'
export * from './tech-review/internal'
export * from './threads/v3'
export * from './users/v2'
export * from './versions/v2'
export * from './versions/v3'

View File

@@ -617,6 +617,14 @@ export namespace Labrinth {
game_versions: string[]
loaders: string[]
}
export interface GetProjectVersionsParams {
game_versions?: string[]
loaders?: string[]
include_changelog?: boolean
limit?: number
offset?: number
}
}
// TODO: consolidate duplicated types between v2 and v3 versions
@@ -632,7 +640,8 @@ export namespace Labrinth {
game_versions?: string[]
loaders?: string[]
include_changelog?: boolean
apiVersion?: 2 | 3
limit?: number
offset?: number
}
export type VersionChannel = 'release' | 'beta' | 'alpha'

View File

@@ -0,0 +1,141 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthVersionsV2Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_versions_v2'
}
/**
* Get versions for a project (v2)
*
* @param id - Project ID or slug (e.g., 'sodium' or 'AANobbMI')
* @param options - Optional query parameters to filter versions
* @returns Promise resolving to an array of v2 versions
*
* @example
* ```typescript
* const versions = await client.labrinth.versions_v2.getProjectVersions('sodium')
* const filteredVersions = await client.labrinth.versions_v2.getProjectVersions('sodium', {
* game_versions: ['1.20.1'],
* loaders: ['fabric'],
* include_changelog: false
* })
* console.log(versions[0].version_number)
* ```
*/
public async getProjectVersions(
id: string,
options?: Labrinth.Versions.v2.GetProjectVersionsParams,
): Promise<Labrinth.Versions.v2.Version[]> {
const params: Record<string, string> = {}
if (options?.game_versions?.length) {
params.game_versions = JSON.stringify(options.game_versions)
}
if (options?.loaders?.length) {
params.loaders = JSON.stringify(options.loaders)
}
if (options?.include_changelog === false) {
params.include_changelog = 'false'
}
if (options?.limit != null) {
params.limit = String(options.limit)
}
if (options?.offset != null) {
params.offset = String(options.offset)
}
return this.client.request<Labrinth.Versions.v2.Version[]>(`/project/${id}/version`, {
api: 'labrinth',
version: 2,
method: 'GET',
params: Object.keys(params).length > 0 ? params : undefined,
})
}
/**
* Get a specific version by ID (v2)
*
* @param id - Version ID
* @returns Promise resolving to the v2 version data
*
* @example
* ```typescript
* const version = await client.labrinth.versions_v2.getVersion('DXtmvS8i')
* console.log(version.version_number)
* ```
*/
public async getVersion(id: string): Promise<Labrinth.Versions.v2.Version> {
return this.client.request<Labrinth.Versions.v2.Version>(`/version/${id}`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
/**
* Get multiple versions by IDs (v2)
*
* @param ids - Array of version IDs
* @returns Promise resolving to an array of v2 versions
*
* @example
* ```typescript
* const versions = await client.labrinth.versions_v2.getVersions(['DXtmvS8i', 'abc123'])
* console.log(versions[0].version_number)
* ```
*/
public async getVersions(ids: string[]): Promise<Labrinth.Versions.v2.Version[]> {
return this.client.request<Labrinth.Versions.v2.Version[]>(`/versions`, {
api: 'labrinth',
version: 2,
method: 'GET',
params: { ids: JSON.stringify(ids) },
})
}
/**
* Get a version from a project by version ID or number (v2)
*
* @param projectId - Project ID or slug
* @param versionId - Version ID or version number
* @returns Promise resolving to the v2 version data
*
* @example
* ```typescript
* const version = await client.labrinth.versions_v2.getVersionFromIdOrNumber('sodium', 'DXtmvS8i')
* const versionByNumber = await client.labrinth.versions_v2.getVersionFromIdOrNumber('sodium', '0.4.12')
* ```
*/
public async getVersionFromIdOrNumber(
projectId: string,
versionId: string,
): Promise<Labrinth.Versions.v2.Version> {
return this.client.request<Labrinth.Versions.v2.Version>(
`/project/${projectId}/version/${versionId}`,
{
api: 'labrinth',
version: 2,
method: 'GET',
},
)
}
/**
* Delete a version by ID (v2)
*
* @param versionId - Version ID
*
* @example
* ```typescript
* await client.labrinth.versions_v2.deleteVersion('DXtmvS8i')
* ```
*/
public async deleteVersion(versionId: string): Promise<void> {
return this.client.request(`/version/${versionId}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
}

View File

@@ -35,8 +35,14 @@ export class LabrinthVersionsV3Module extends AbstractModule {
if (options?.loaders?.length) {
params.loaders = JSON.stringify(options.loaders)
}
if (options?.include_changelog !== undefined) {
params.include_changelog = options.include_changelog
if (options?.include_changelog === false) {
params.include_changelog = 'false'
}
if (options?.limit != null) {
params.limit = String(options.limit)
}
if (options?.offset != null) {
params.offset = String(options.offset)
}
return this.client.request<Labrinth.Versions.v3.Version[]>(`/project/${id}/version`, {

View File

@@ -0,0 +1,19 @@
export namespace LauncherMeta {
export namespace Manifest {
export namespace v0 {
export type LoaderVersion = {
id: string
stable: boolean
}
export type GameVersionEntry = {
id: string
loaders: LoaderVersion[]
}
export type Manifest = {
gameVersions: GameVersionEntry[]
}
}
}
}

View File

@@ -0,0 +1,23 @@
import { $fetch } from 'ofetch'
import { AbstractModule } from '../../core/abstract-module'
import type { LauncherMeta } from './types'
export type { LauncherMeta } from './types'
const BASE_URL = 'https://launcher-meta.modrinth.com'
export class LauncherMetaManifestV0Module extends AbstractModule {
public getModuleID(): string {
return 'launchermeta_manifest_v0'
}
/**
* Get the loader manifest for a given loader platform.
*
* @param loader - Loader platform (fabric, forge, quilt, neo)
*/
public async getManifest(loader: string): Promise<LauncherMeta.Manifest.v0.Manifest> {
return $fetch<LauncherMeta.Manifest.v0.Manifest>(`${BASE_URL}/${loader}/v0/manifest.json`)
}
}

View File

@@ -0,0 +1,9 @@
export namespace Paper {
export namespace Versions {
export namespace v3 {
export type VersionBuilds = {
builds: number[]
}
}
}
}

View File

@@ -0,0 +1,25 @@
import { $fetch } from 'ofetch'
import { AbstractModule } from '../../core/abstract-module'
import type { Paper } from './types'
export type { Paper } from './types'
const BASE_URL = 'https://fill.papermc.io/v3'
export class PaperVersionsV3Module extends AbstractModule {
public getModuleID(): string {
return 'paper_versions_v3'
}
/**
* Get available Paper builds for a Minecraft version.
*
* @param mcVersion - Minecraft version (e.g. "1.21.4")
*/
public async getBuilds(mcVersion: string): Promise<Paper.Versions.v3.VersionBuilds> {
return $fetch<Paper.Versions.v3.VersionBuilds>(
`${BASE_URL}/projects/paper/versions/${mcVersion}`,
)
}
}

View File

@@ -0,0 +1,11 @@
export namespace Purpur {
export namespace Versions {
export namespace v2 {
export type VersionBuilds = {
builds: {
all: string[]
}
}
}
}
}

View File

@@ -0,0 +1,23 @@
import { $fetch } from 'ofetch'
import { AbstractModule } from '../../core/abstract-module'
import type { Purpur } from './types'
export type { Purpur } from './types'
const BASE_URL = 'https://api.purpurmc.org/v2'
export class PurpurVersionsV2Module extends AbstractModule {
public getModuleID(): string {
return 'purpur_versions_v2'
}
/**
* Get available Purpur builds for a Minecraft version.
*
* @param mcVersion - Minecraft version (e.g. "1.21.4")
*/
public async getBuilds(mcVersion: string): Promise<Purpur.Versions.v2.VersionBuilds> {
return $fetch<Purpur.Versions.v2.VersionBuilds>(`${BASE_URL}/purpur/${mcVersion}`)
}
}

View File

@@ -2,3 +2,6 @@ export * from './archon/types'
export * from './iso3166/types'
export * from './kyros/types'
export * from './labrinth/types'
export * from './launcher-meta/types'
export * from './paper/types'
export * from './purpur/types'

View File

@@ -13,27 +13,32 @@ import { XHRUploadClient } from './xhr-upload-client'
*
* This provides cross-request persistence in SSR while also working in client-side.
* State is shared between requests in the same Nuxt context.
*
* Note: useState must be called during initialization (in setup context) and cached,
* as it won't work during async operations when the Nuxt context may be lost.
*/
export class NuxtCircuitBreakerStorage implements CircuitBreakerStorage {
private getState(): Map<string, CircuitBreakerState> {
private state: Map<string, CircuitBreakerState>
constructor() {
// @ts-expect-error - useState is provided by Nuxt runtime
const state = useState<Map<string, CircuitBreakerState>>(
const stateRef = useState<Map<string, CircuitBreakerState>>(
'circuit-breaker-state',
() => new Map(),
)
return state.value
this.state = stateRef.value
}
get(key: string): CircuitBreakerState | undefined {
return this.getState().get(key)
return this.state.get(key)
}
set(key: string, state: CircuitBreakerState): void {
this.getState().set(key, state)
this.state.set(key, state)
}
clear(key: string): void {
this.getState().delete(key)
this.state.delete(key)
}
}

View File

@@ -69,8 +69,16 @@ export class TauriModrinthClient extends XHRUploadClient {
let fullUrl = url
if (options.params) {
const queryParams = new URLSearchParams(options.params as Record<string, string>).toString()
fullUrl = `${url}?${queryParams}`
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 response = await tauriFetch(fullUrl, {

View File

@@ -14,7 +14,7 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
async connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void> {
if (this.connections.has(serverId)) {
this.disconnect(serverId)
this.closeConnection(serverId)
}
return new Promise((resolve, reject) => {
@@ -57,14 +57,30 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
ws.onclose = (event) => {
console.debug(`[WebSocket] Closed for server ${serverId}:`, {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
})
if (event.code !== NORMAL_CLOSURE) {
this.scheduleReconnect(serverId, auth)
}
}
ws.onerror = (error) => {
console.error(`[WebSocket] Error for server ${serverId}:`, error)
reject(new Error(`WebSocket connection failed for server ${serverId}`))
ws.onerror = (event) => {
const url = ws.url
const readyState = ws.readyState
console.error(`[WebSocket] Error for server ${serverId}:`, {
url,
readyState,
readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState],
type: (event as Event).type,
})
reject(
new Error(
`WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
),
)
}
} catch (error) {
reject(error)
@@ -73,6 +89,16 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
disconnect(serverId: string): void {
this.closeConnection(serverId)
this.emitter.all.forEach((_handlers, type) => {
if (type.toString().startsWith(`${serverId}:`)) {
this.emitter.all.delete(type)
}
})
}
private closeConnection(serverId: string): void {
const connection = this.connections.get(serverId)
if (!connection) return
@@ -88,12 +114,6 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
connection.socket.close(NORMAL_CLOSURE, 'Client disconnecting')
}
this.emitter.all.forEach((_handlers, type) => {
if (type.toString().startsWith(`${serverId}:`)) {
this.emitter.all.delete(type)
}
})
this.connections.delete(serverId)
}