feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,2 @@
# Autogenerated files
dist

View File

View File

@ -0,0 +1,208 @@
# @modrinth/api-client
Platform-agnostic API client for Modrinth's services. Works in Nuxt (SSR + CSR), Tauri (desktop app), and plain Node/browser environments.
## Architecture
```
Request Flow:
Module Method → client.request() → Feature Chain (middleware) → Platform executeRequest()
```
### Key Directories
- **`src/core/`** — base classes (`AbstractModrinthClient`, `AbstractModule`, `AbstractFeature`, etc.)
- **`src/platform/`** — platform implementations (generic, nuxt, tauri, xhr-upload, websocket)
- **`src/features/`** — middleware plugins (auth, retry, circuit-breaker, etc.)
- **`src/modules/`** — API endpoint modules organized by service (`labrinth/`, `archon/`, `kyros/`, `iso3166/`)
- **`src/types/`** — core type definitions (client config, request options, upload types, errors)
### Client Hierarchy
All platform clients extend `XHRUploadClient``AbstractModrinthClient`:
- **`GenericModrinthClient`** — uses `ofetch`, attaches WebSocket client to `archon.sockets`
- **`NuxtModrinthClient`** — uses Nuxt's `$fetch`, SSR-aware, blocks `upload()` during SSR
- **`TauriModrinthClient`** — uses `@tauri-apps/plugin-http`
### Module Access
Modules are lazy-loaded and accessed as a nested structure:
```ts
client.labrinth.projects_v2
client.labrinth.projects_v3
client.labrinth.versions_v3
client.labrinth.collections
client.labrinth.billing_internal
client.archon.servers_v0
client.archon.servers_v1
client.archon.backups_queue_v1
client.archon.backups_v1
client.archon.content_v0
client.kyros.files_v0
client.iso3166.data
... etc.
```
This structure is derived at runtime from the flat `MODULE_REGISTRY` in `modules/index.ts` via `buildModuleStructure()`, and the TypeScript types are inferred automatically via `InferredClientModules`.
## Critical: Always use `this.client.request()`
API modules **must** use `this.client.request()` (or `.upload`) for all HTTP calls — never `$fetch`, `fetch`, or any other HTTP library directly. The request method routes through the platform-specific implementation (Nuxt `$fetch`, Tauri HTTP plugin, etc.) and the feature middleware chain (auth, retry, circuit breaker). Using `$fetch` directly bypasses the platform layer and will fail in Tauri (CORS/sandboxing). The only exception is the `ISO3166Module` which is explicitly node-only.
For external APIs (non-Modrinth), pass the full base URL as the `api` field and set `skipAuth: true`:
```ts
this.client.request<MyType>('/endpoint', {
api: 'https://external-api.com',
version: 1,
method: 'POST',
body: { data },
skipAuth: true,
})
```
## Usage
The client is provided to the component tree via DI (see the `dependency-injection` skill). Each app creates a platform-specific client and provides it at the root:
```ts
// apps/website/src/app.vue (Nuxt)
const client = new NuxtModrinthClient({ ... })
provideModrinthClient(client)
// apps/app-frontend/src/App.vue (Tauri)
const client = new TauriModrinthClient({ ... })
provideModrinthClient(client)
```
Components anywhere in the tree then inject it:
```ts
const { labrinth, archon, kyros } = injectModrinthClient()
// Fetch data
const project = await labrinth.projects_v3.get(projectId)
// Use with TanStack Query
const { data } = useQuery({
queryKey: ['project', projectId],
queryFn: () => labrinth.projects_v3.get(projectId),
})
```
`provideModrinthClient` and `injectModrinthClient` are exported from `@modrinth/ui` (defined in `packages/ui/src/providers/api-client.ts`). The provider is typed as `AbstractModrinthClient`, so shared components in `packages/ui` work with any platform client.
## Types
Types must match 1:1 with how they are returned from the backend API they are fetching from. Do not reshape, rename, or omit fields — the types should be a direct representation of the API response.
Types are organized in namespaces that mirror the backend services:
```ts
import type { Labrinth, Archon, Kyros, ISO3166 } from '@modrinth/api-client'
const project: Labrinth.Projects.v3.Project = ...
const server: Archon.Servers.v0.Server = ...
const auth: Archon.Websocket.v0.WSAuth = ...
```
Each API has a `types.ts` in its module directory (`modules/labrinth/types.ts`, `modules/archon/types.ts`, etc.) using nested namespaces: `Namespace.Domain.Version.Type`.
## Features (Middleware)
Features wrap requests in a chain. Each feature can modify the request, retry, or short-circuit:
- **`AuthFeature`** — injects `Authorization: Bearer <token>`, supports async token providers
- **`RetryFeature`** — exponential/linear/constant backoff, retries on 408/429/5xx and network errors
- **`CircuitBreakerFeature`** — opens after N consecutive failures per endpoint, resets after timeout
## XHR Upload
File uploads use `XMLHttpRequest` for progress tracking (not available via `fetch`). The `upload()` method returns an `UploadHandle<T>`:
```ts
interface UploadHandle<T> {
promise: Promise<T>
onProgress(callback: (progress: UploadProgress) => void): UploadHandle<T> // chainable
cancel(): void
}
```
Supports two modes:
- **Single file** — `{ file: File | Blob }` sends with `Content-Type: application/octet-stream`
- **FormData** — `{ formData: FormData }` for multipart uploads (browser/platform sets boundary)
Uploads go through the feature chain (auth, retry, etc.). Features detect uploads via `context.metadata.isUpload`.
### Usage Example (server file upload)
```ts
const uploader = client.kyros.files_v0.uploadFile(path, file, {
onProgress: ({ progress }) => {
uploadProgress.value = Math.round(progress * 100)
},
})
// Cancel if needed: uploader.cancel()
await uploader.promise
```
### Usage Example (version creation with FormData)
```ts
const handle = client.labrinth.versions_v3.createVersion(draftVersion, files, projectType)
handle.onProgress((progress) => {
uploadProgress.value = progress
})
await handle.promise
```
See `packages/ui/src/components/servers/files/upload/FileUploadDropdown.vue` for real usage.
## WebSocket
WebSocket support is attached to `client.archon.sockets` (only on `GenericModrinthClient`). It provides event-based communication with Modrinth Hosting servers.
### Connection Flow
```
client.archon.sockets.safeConnect(serverId)
→ fetches JWT auth via archon.servers_v0.getWebSocketAuth()
→ opens wss:// connection
→ sends { event: 'auth', jwt: token }
→ server responds with { event: 'auth-ok' }
→ ready to receive events
```
Auto-reconnects on unexpected disconnection with exponential backoff (base 1s, max 30s, up to 10 attempts).
### Subscribing to Events
```ts
const unsub = client.archon.sockets.on(serverId, 'stats', (data) => {
// data is typed as Archon.Websocket.v0.WSStatsEvent
cpuUsage.value = data.cpu_percent
})
// Clean up
onUnmounted(() => {
unsub()
client.archon.sockets.disconnect(serverId)
})
```
Event types: `log`, `stats`, `power-state`, `uptime`, `backup-progress`, `installation-result`, `filesystem-ops`, `new-mod`, `auth-expiring`, `auth-incorrect`, `auth-ok`.
### Sending Commands
```ts
client.archon.sockets.send(serverId, { event: 'command', cmd: '/say hello' })
```
See `apps/app-frontend/src/pages/hosting/manage/Index.vue` for the desktop server panel WebSocket usage.
## Adding a New API Module
See the `api-module` skill (`.claude/skills/api-module/SKILL.md`) for step-by-step instructions.

165
packages/api-client/LICENSE Normal file
View File

@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@ -0,0 +1,177 @@
# @modrinth/api-client
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-c78aff?style=for-the-badge)](https://www.typescriptlang.org/)
[![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL%203.0-c78aff?style=for-the-badge)](LICENSE)
Platform-agnostic TypeScript client for Modrinth's API across Node.js, browsers, Nuxt, and Tauri.
**⚠️ We use this internally to power modrinth.com, Modrinth App, and Modrinth Hosting frontends. It may break without any notice, but you are welcome to use it.**
## Installation
```bash
pnpm add @modrinth/api-client
```
Tauri apps also need the optional peer dependency:
```bash
pnpm add @modrinth/api-client @tauri-apps/plugin-http
```
## Usage
### Generic Node.js or Browser Client
```ts
import { AuthFeature, GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
const client = new GenericModrinthClient({
userAgent: 'my-app/1.0.0',
features: [new AuthFeature({ token: process.env.MODRINTH_TOKEN })],
})
const project: Labrinth.Projects.v2.Project = await client.labrinth.projects_v2.get('sodium')
const members = await client.labrinth.projects_v3.getMembers(project.id)
```
You can still make direct requests through the same platform layer:
```ts
const project = await client.request<Labrinth.Projects.v2.Project>('/project/sodium', {
api: 'labrinth',
version: 2,
})
```
### Nuxt
```ts
import { AuthFeature, CircuitBreakerFeature, NuxtCircuitBreakerStorage, NuxtModrinthClient } from '@modrinth/api-client'
export const useModrinthClient = async () => {
const config = useRuntimeConfig()
return new NuxtModrinthClient({
userAgent: 'my-nuxt-app/1.0.0',
rateLimitKey: import.meta.server ? config.rateLimitKey : undefined,
features: [
new AuthFeature({
token: process.env.MODRINTH_TOKEN,
}),
new CircuitBreakerFeature({
storage: new NuxtCircuitBreakerStorage(),
}),
],
})
}
```
### Tauri
```ts
import { getVersion } from '@tauri-apps/api/app'
import { AuthFeature, TauriModrinthClient } from '@modrinth/api-client'
const client = new TauriModrinthClient({
userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`,
features: [new AuthFeature({ token: process.env.MODRINTH_TOKEN })],
})
const project = await client.labrinth.projects_v2.get('sodium')
```
## API Modules
Modules are available as nested properties on the client:
```ts
client.labrinth.projects_v2
client.labrinth.projects_v3
client.labrinth.versions_v3
```
Types are exported from the package root:
```ts
import type { Labrinth } from '@modrinth/api-client'
const project: Labrinth.Projects.v3.Project = await client.labrinth.projects_v3.get('sodium')
```
## Modrinth Hosting API Modules
- These modules are internal to Modrinth and are only supported inside the Modrinth Hosting panel in Modrinth App and on modrinth.com. They should not be expected to work in third-party clients today. We are discussing how to safely expose access to your own server through these APIs in the future.
## Base URLs
By default, the client uses Modrinth production services:
- `labrinthBaseUrl`: `https://api.modrinth.com`
Override them for staging or custom deployments:
```ts
const client = new GenericModrinthClient({
userAgent: 'my-app/1.0.0',
labrinthBaseUrl: 'https://staging-api.modrinth.com',
})
```
External APIs can be targeted per request by passing a full URL as `api` and disabling auth:
```ts
await client.request('/endpoint', {
api: 'https://example.com',
version: 1,
skipAuth: true,
})
```
## Features
Features wrap requests before they reach the platform implementation:
```ts
import { AuthFeature, CircuitBreakerFeature, RetryFeature } from '@modrinth/api-client'
const client = new GenericModrinthClient({
features: [new AuthFeature({ token: async () => process.env.MODRINTH_TOKEN }), new RetryFeature({ maxAttempts: 3, backoffStrategy: 'exponential' }), new CircuitBreakerFeature({ maxFailures: 3, resetTimeout: 30_000 })],
})
```
Built-in features include authentication, node auth, retries, circuit breaking, panel version headers, and verbose logging.
## Uploads
Upload endpoints return an `UploadHandle<T>` with progress and cancellation support:
```ts
const upload = client.kyros.files_v0.uploadFile(path, file)
upload.onProgress(({ progress }) => {
console.log(Math.round(progress * 100))
})
await upload.promise
```
Uploads use `XMLHttpRequest` for progress tracking and are only available in browser-capable contexts. `NuxtModrinthClient.upload()` throws during SSR.
## Third-Party API Typings
- This package also includes some third-party API modules and typings used by Modrinth internals. They are not part of the stable public API surface and should be used at your own risk.
## Development
```bash
pnpm --filter @modrinth/api-client build
pnpm --filter @modrinth/api-client lint
# or pnpm prepr:frontend:lib in turborepo root.
```
When adding a module, add it to `src/modules/index.ts` so it is included in the typed client structure.
## License
Licensed under LGPL-3.0. See [LICENSE](LICENSE).

View File

@ -0,0 +1,7 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config.append([
{
ignores: ['dist/'],
},
])

View File

@ -0,0 +1,60 @@
{
"name": "@modrinth/api-client",
"version": "0.0.0",
"description": "An API client for Modrinth's API for use in nuxt, tauri and plain node/browser environments.",
"license": "LGPL-3.0-only",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"sideEffects": false,
"repository": {
"type": "git",
"url": "https://github.com/modrinth/code.git",
"directory": "packages/api-client"
},
"bugs": {
"url": "https://github.com/modrinth/code/issues"
},
"homepage": "https://github.com/modrinth/code/tree/main/packages/api-client#readme",
"publishConfig": {
"access": "public"
},
"scripts": {
"clean": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "pnpm run clean && esbuild src/index.ts --bundle --format=esm --platform=neutral --target=es2020 --minify --legal-comments=none --outfile=dist/index.js --external:ofetch --external:mitt --external:@tauri-apps/plugin-http && tsc -p tsconfig.build.json",
"prepare": "pnpm run build",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write ."
},
"dependencies": {
"mitt": "^3.0.1",
"ofetch": "^1.4.1"
},
"devDependencies": {
"@modrinth/tooling-config": "workspace:*",
"@tauri-apps/plugin-http": "^2.0.0",
"esbuild": "0.27.2",
"typescript": "^5.9.3"
},
"peerDependencies": {
"@tauri-apps/plugin-http": "^2.0.0"
},
"peerDependenciesMeta": {
"@tauri-apps/plugin-http": {
"optional": true
}
}
}

View File

@ -0,0 +1,504 @@
import type { InferredClientModules } from '../modules'
import { buildModuleStructure } from '../modules'
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'
type ArchonClientModules = Omit<InferredClientModules['archon'], 'backups_v1'> & {
/** @deprecated Use `backups_queue_v1` for the Backups Queue API. */
backups_v1: InferredClientModules['archon']['backups_v1']
}
/**
* Abstract base client for Modrinth APIs
*/
export abstract class AbstractModrinthClient extends AbstractUploadClient {
protected config: ClientConfig
protected features: AbstractFeature[]
/**
* Maps full module ID (e.g., 'labrinth_projects_v2') to instantiated module
*/
private _moduleInstances: Map<string, AbstractModule> = new Map()
/**
* Maps API name (e.g., 'labrinth') to namespace object
*/
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
public readonly labrinth!: InferredClientModules['labrinth']
public readonly archon!: ArchonClientModules & {
sockets: AbstractWebSocketClient
sync: AbstractSyncClient
}
public readonly kyros!: InferredClientModules['kyros']
public readonly iso3166!: InferredClientModules['iso3166']
public readonly mclogs!: InferredClientModules['mclogs']
public readonly launchermeta!: InferredClientModules['launchermeta']
public readonly paper!: InferredClientModules['paper']
public readonly purpur!: InferredClientModules['purpur']
constructor(config: ClientConfig) {
super()
this.config = {
timeout: 10000,
labrinthBaseUrl: 'https://api.modrinth.com',
archonBaseUrl: 'https://archon.modrinth.com',
...config,
}
this.features = config.features ?? []
this.initializeModules()
}
/**
* This creates the nested API structure (e.g., client.labrinth.projects_v2)
* but doesn't instantiate modules until first access
*
* Module IDs in the registry are validated at runtime to ensure they match
* what the module declares via getModuleID().
*/
private initializeModules(): void {
const structure = buildModuleStructure()
for (const [api, modules] of Object.entries(structure)) {
const namespaceObj: Record<string, AbstractModule> = {}
// Define lazy getters for each module
for (const [moduleName, ModuleConstructor] of Object.entries(modules)) {
const fullModuleId = `${api}_${moduleName}`
Object.defineProperty(namespaceObj, moduleName, {
get: () => {
// Lazy instantiation
if (!this._moduleInstances.has(fullModuleId)) {
const instance = new ModuleConstructor(this)
// Validate the module ID matches what we expect
const declaredId = instance.getModuleID()
if (declaredId !== fullModuleId) {
throw new Error(
`Module ID mismatch: registry expects "${fullModuleId}" but module declares "${declaredId}"`,
)
}
this._moduleInstances.set(fullModuleId, instance)
}
return this._moduleInstances.get(fullModuleId)!
},
enumerable: true,
configurable: false,
})
}
// Assign namespace to client (e.g., this.labrinth = namespaceObj)
// defineProperty bypasses readonly restriction
Object.defineProperty(this, api, {
value: namespaceObj,
writable: false,
enumerable: true,
configurable: false,
})
this._moduleNamespaces.set(api, namespaceObj)
}
}
/**
* Make a request to the API
*
* @param path - API path (e.g., '/project/sodium')
* @param options - Request options
* @returns Promise resolving to the response data
* @throws {ModrinthApiError} When the request fails or features throw errors
*/
async request<T>(path: string, options: RequestOptions): Promise<T> {
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()
// Merge options with defaults
const mergedOptions: RequestOptions = {
method: 'GET',
timeout: this.config.timeout,
...options,
headers: {
...defaultHeaders,
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const headers = mergedOptions.headers
if (headers && 'Content-Type' in headers && headers['Content-Type'] === '') {
delete headers['Content-Type']
}
const context = this.buildContext(url, path, mergedOptions)
try {
const result = await this.executeFeatureChain<T>(context)
await this.config.hooks?.onResponse?.(result, context)
return result
} catch (error) {
const apiError = this.normalizeError(error, context)
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}
}
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,
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))
// Build the feature chain
// We work backwards from the actual request, wrapping each feature around the previous
let index = applicableFeatures.length
const next = async (): Promise<T> => {
index--
if (index >= 0) {
// Execute the next feature in the chain
const feature = applicableFeatures[index]
return feature.execute(next, context)
} else {
// We've reached the end of the chain, execute the actual request
await this.config.hooks?.onRequest?.(context)
return executeTerminal()
}
}
return next()
}
/**
* Execute the feature chain for an upload
*
* Similar to executeFeatureChain but calls executeXHRUpload at the end.
* This allows features (auth, retry, etc.) to wrap the upload execution.
*/
protected async executeUploadFeatureChain<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T> {
const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context))
let index = applicableFeatures.length
const next = async (): Promise<T> => {
index--
if (index >= 0) {
return applicableFeatures[index].execute(next, context)
} else {
await this.config.hooks?.onRequest?.(context)
return this.executeXHRUpload<T>(context, progressCallbacks, abortController)
}
}
return next()
}
/**
* Build the full URL for a request
*/
protected buildUrl(path: string, baseUrl: string, version: number | 'internal' | string): string {
// Remove trailing slash from base URL
const base = baseUrl.replace(/\/$/, '')
// Build version path
let versionPath = ''
if (version === 'internal') {
versionPath = '/_internal'
} else if (typeof version === 'number') {
versionPath = `/v${version}`
} else if (typeof version === 'string') {
// Custom version string (e.g., 'v0', 'modrinth/v0')
versionPath = `/${version}`
}
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${base}${versionPath}${cleanPath}`
}
protected resolveBaseUrl(baseUrl: BaseUrlConfig): string {
return typeof baseUrl === 'function' ? baseUrl() : baseUrl
}
/**
* Build the request context
*/
protected buildContext(url: string, path: string, options: RequestOptions): RequestContext {
return {
url,
path,
options,
attempt: 1,
startTime: Date.now(),
}
}
/**
* Build context for an upload request
*
* Sets metadata.isUpload = true so features can detect uploads.
* Supports both single file uploads and FormData uploads.
*/
protected buildUploadContext(
url: string,
path: string,
options: UploadRequestOptions,
): RequestContext {
let metadata: UploadMetadata
let body: File | Blob | FormData
if ('formData' in options && options.formData) {
metadata = {
isUpload: true,
formData: options.formData,
onProgress: options.onProgress,
}
body = options.formData
} else if ('file' in options && options.file) {
metadata = {
isUpload: true,
file: options.file,
onProgress: options.onProgress,
}
body = options.file
} else {
throw new Error('Upload options must include either file or formData')
}
return {
url,
path,
options: {
...options,
method: 'POST',
body,
},
attempt: 1,
startTime: Date.now(),
metadata,
}
}
/**
* Build default headers for all requests
*
* Subclasses can override this to add platform-specific headers
* (e.g., Nuxt rate limit key)
*/
protected async buildDefaultHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...this.config.headers,
}
const userAgent = await this.resolveUserAgent()
if (userAgent) {
headers['User-Agent'] = userAgent
}
return headers
}
private async resolveUserAgent(): Promise<string | undefined> {
const userAgent = this.config.userAgent
return typeof userAgent === 'function' ? await userAgent() : userAgent
}
protected attachArchonSentryCaptureHeader(options: RequestOptions): void {
if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) {
return
}
options.headers['modrinth-sentry-capture'] = '1'
}
private shouldCaptureArchonRequests(): boolean {
const archonSentryCapture = this.config.archonSentryCapture
return typeof archonSentryCapture === 'function'
? archonSentryCapture()
: archonSentryCapture === true
}
/**
* Execute the actual HTTP request
*
* This must be implemented by platform-specific clients.
*
* @param url - Full URL to request
* @param options - Request options
* @returns Promise resolving to the response data
* @throws {Error} Platform-specific errors that will be normalized by normalizeError()
*/
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
*
* This must be implemented by platform clients that support uploads.
* Called at the end of the upload feature chain.
*
* @param context - Request context with upload metadata
* @param progressCallbacks - Callbacks to invoke on progress events
* @param abortController - Controller for cancellation
* @returns Promise resolving to the response data
*/
protected abstract executeXHRUpload<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T>
/**
* Normalize an error into a ModrinthApiError
*
* Platform implementations should override this to handle platform-specific errors
* (e.g., FetchError from ofetch, Tauri HTTP errors)
*/
protected normalizeError(error: unknown, context?: RequestContext): ModrinthApiError {
if (error instanceof ModrinthApiError) {
return error
}
return ModrinthApiError.fromUnknown(error, context?.path)
}
/**
* Helper to create a normalized error from extracted status code and response data
*/
protected createNormalizedError(
error: Error,
statusCode: number | undefined,
responseData: unknown,
): ModrinthApiError {
if (statusCode && responseData) {
return ModrinthServerError.fromResponse(statusCode, responseData)
}
return new ModrinthApiError(error.message, {
statusCode,
originalError: error,
responseData,
})
}
/**
* Add a feature to this client
*
* Features are executed in the order they are added.
*
* @example
* ```typescript
* const client = new GenericModrinthClient()
* client.addFeature(new AuthFeature({ token: async () => getOAuthToken() }))
* client.addFeature(new RetryFeature({ maxAttempts: 3 }))
* ```
*/
addFeature(feature: AbstractFeature): this {
this.features.push(feature)
return this
}
/**
* Remove a feature from this client
*
* @example
* ```typescript
* const retryFeature = new RetryFeature({ maxAttempts: 3 })
* client.addFeature(retryFeature)
* // Later, remove it
* client.removeFeature(retryFeature)
* ```
*/
removeFeature(feature: AbstractFeature): this {
const index = this.features.indexOf(feature)
if (index !== -1) {
this.features.splice(index, 1)
}
return this
}
/**
* Get all features on this client
*/
getFeatures(): AbstractFeature[] {
return [...this.features]
}
}

View File

@ -0,0 +1,91 @@
import type { RequestContext } from '../types/request'
/**
* Base configuration for features
*/
export interface FeatureConfig {
/**
* Optional name for this feature (for debugging)
*/
name?: string
/**
* Whether this feature is enabled
* @default true
*/
enabled?: boolean
}
/**
* Abstract base class for request features
*
* Features are composable middleware that can intercept and modify requests.
* They are executed in a chain, with each feature calling next() to continue the chain.
*/
export abstract class AbstractFeature {
protected config: FeatureConfig
constructor(config?: FeatureConfig) {
this.config = {
enabled: true,
...config,
}
}
/**
* Execute the feature logic
*
* @param next - Function to call the next feature in the chain (or the actual request)
* @param context - Full request context
* @returns Promise resolving to the response data
*
* @example
* ```typescript
* async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
* // Do something before request
* console.log('Before request:', context.url)
*
* try {
* const result = await next()
*
* // Do something after successful request
* console.log('Request succeeded')
*
* return result
* } catch (error) {
* // Handle errors
* console.error('Request failed:', error)
* throw error
* }
* }
* ```
*/
abstract execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T>
/**
* Determine if this feature should apply to the given request
*
* By default, features apply if they are enabled.
* Override this to add custom logic (e.g., only apply to GET requests).
*
* @param context - Request context
* @returns true if the feature should execute, false to skip
*/
shouldApply(_context: RequestContext): boolean {
return this.config.enabled !== false
}
/**
* Get the name of this feature (for debugging)
*/
get name(): string {
return this.config.name ?? this.constructor.name
}
/**
* Check if this feature is enabled
*/
get enabled(): boolean {
return this.config.enabled !== false
}
}

View File

@ -0,0 +1,15 @@
import type { AbstractModrinthClient } from './abstract-client'
export abstract class AbstractModule {
protected client: AbstractModrinthClient
public constructor(client: AbstractModrinthClient) {
this.client = client
}
/**
* Get the module's name, used for error reporting & for module field generation.
* @returns Module name
*/
public abstract getModuleID(): string
}

View File

@ -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`
}
}

View File

@ -0,0 +1,21 @@
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
/**
* Abstract base class defining upload capability
*
* All clients that support file uploads must extend this class.
* Platform-specific implementations should provide the actual upload mechanism
* (e.g., XHR for browser environments).
*
* Upload goes through the feature chain (auth, retry, circuit-breaker, etc.)
* just like regular requests.
*/
export abstract class AbstractUploadClient {
/**
* Upload a file or FormData with progress tracking
* @param path - API path (e.g., '/fs/create')
* @param options - Upload options including file or formData, api, version
* @returns UploadHandle with promise, onProgress chain, and cancel method
*/
abstract upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T>
}

View File

@ -0,0 +1,104 @@
import type mitt from 'mitt'
import type { Archon } from '../modules/archon/types'
export type WebSocketEventHandler<
E extends Archon.Websocket.v0.WSEvent = Archon.Websocket.v0.WSEvent,
> = (event: E) => void
export interface WebSocketConnection {
serverId: string
socket: WebSocket
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
isReconnecting: boolean
}
export interface WebSocketStatus {
connected: boolean
reconnecting: boolean
reconnectAttempts: number
}
type WSEventMap = {
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
}
export abstract class AbstractWebSocketClient {
protected connections = new Map<string, WebSocketConnection>()
protected abstract emitter: ReturnType<typeof mitt<WSEventMap>>
protected readonly MAX_RECONNECT_ATTEMPTS = 10
protected readonly RECONNECT_BASE_DELAY = 1000
protected readonly RECONNECT_MAX_DELAY = 30000
constructor(
protected client: {
archon: {
servers_v0: {
getWebSocketAuth: (serverId: string) => Promise<Archon.Websocket.v0.WSAuth>
}
}
},
) {}
abstract connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void>
abstract disconnect(serverId: string): void
abstract disconnectAll(): void
abstract send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void
async safeConnect(serverId: string, options?: { force?: boolean }): Promise<void> {
const status = this.getStatus(serverId)
if (status?.connected && !options?.force) {
return
}
if (status && !status.connected && !options?.force) {
return
}
if (options?.force && status) {
this.disconnect(serverId)
}
const auth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
await this.connect(serverId, auth)
}
on<E extends Archon.Websocket.v0.WSEventType>(
serverId: string,
eventType: E,
handler: WebSocketEventHandler<Extract<Archon.Websocket.v0.WSEvent, { event: E }>>,
): () => void {
const eventKey = `${serverId}:${eventType}` as keyof WSEventMap
this.emitter.on(eventKey, handler as () => void)
return () => {
this.emitter.off(eventKey, handler as () => void)
}
}
getStatus(serverId: string): WebSocketStatus | null {
const connection = this.connections.get(serverId)
if (!connection) return null
return {
connected: connection.socket.readyState === WebSocket.OPEN,
reconnecting: connection.isReconnecting,
reconnectAttempts: connection.reconnectAttempts,
}
}
protected getReconnectDelay(attempt: number): number {
const delay = Math.min(
this.RECONNECT_BASE_DELAY * Math.pow(2, attempt),
this.RECONNECT_MAX_DELAY,
)
return delay + Math.random() * 1000
}
}

View File

@ -0,0 +1,142 @@
import type { ApiErrorData, ModrinthErrorResponse } from '../types/errors'
import { isModrinthErrorResponse } from '../types/errors'
/**
* Base error class for all Modrinth API errors
*/
export class ModrinthApiError extends Error {
/**
* HTTP status code (if available)
*/
readonly statusCode?: number
/**
* Original error that was caught
*/
readonly originalError?: Error
/**
* Response data from the API (if available)
*/
readonly responseData?: unknown
/**
* Error context (e.g., module name, operation being performed)
*/
readonly context?: string
constructor(message: string, data?: ApiErrorData) {
super(message)
this.name = 'ModrinthApiError'
this.statusCode = data?.statusCode
this.originalError = data?.originalError
this.responseData = data?.responseData
this.context = data?.context
// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ModrinthApiError)
}
}
/**
* Create a ModrinthApiError from an unknown error
*/
static fromUnknown(error: unknown, context?: string): ModrinthApiError {
if (error instanceof ModrinthApiError) {
return error
}
if (error instanceof Error) {
return new ModrinthApiError(error.message, {
originalError: error,
context,
})
}
return new ModrinthApiError(String(error), { context })
}
}
/**
* Error class for Modrinth server errors (kyros/archon)
* Extends ModrinthApiError with V1 error response parsing
*/
export class ModrinthServerError extends ModrinthApiError {
/**
* V1 error information (if available)
*/
readonly v1Error?: ModrinthErrorResponse
constructor(message: string, data?: ApiErrorData & { v1Error?: ModrinthErrorResponse }) {
// If we have a V1 error, format the message nicely
let errorMessage = message
if (data?.v1Error) {
errorMessage = `[${data.v1Error.error}] ${data.v1Error.description}`
if (data.v1Error.context) {
errorMessage = `${data.v1Error.context}: ${errorMessage}`
}
}
super(errorMessage, data)
this.name = 'ModrinthServerError'
this.v1Error = data?.v1Error
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ModrinthServerError)
}
}
/**
* Create a ModrinthServerError from response data
*/
static fromResponse(
statusCode: number,
responseData: unknown,
context?: string,
): ModrinthServerError {
const v1Error = isModrinthErrorResponse(responseData) ? responseData : undefined
let message = `HTTP ${statusCode}`
if (v1Error) {
message = v1Error.description
} else if (typeof responseData === 'string') {
message = responseData
}
return new ModrinthServerError(message, {
statusCode,
responseData,
context,
v1Error,
})
}
/**
* Create a ModrinthServerError from an unknown error
*/
static fromUnknown(error: unknown, context?: string): ModrinthServerError {
if (error instanceof ModrinthServerError) {
return error
}
if (error instanceof ModrinthApiError) {
return new ModrinthServerError(error.message, {
statusCode: error.statusCode,
originalError: error.originalError,
responseData: error.responseData,
context: context ?? error.context,
})
}
if (error instanceof Error) {
return new ModrinthServerError(error.message, {
originalError: error,
context,
})
}
return new ModrinthServerError(String(error), { context })
}
}

View File

@ -0,0 +1,89 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import type { RequestContext } from '../types/request'
/**
* Authentication feature configuration
*/
export interface AuthConfig extends FeatureConfig {
/**
* Authentication token
* - string: static token
* - function: async function that returns token (useful for dynamic tokens)
*/
token: string | (() => Promise<string | undefined>)
/**
* Token prefix (e.g., 'Bearer', 'Token')
* @default 'Bearer'
*/
tokenPrefix?: string
/**
* Custom header name for the token
* @default 'Authorization'
*/
headerName?: string
}
/**
* Authentication feature
*
* Automatically injects authentication tokens into request headers.
* Supports both static tokens and dynamic token providers.
*
* @example
* ```typescript
* const auth = new AuthFeature({
* token: async () => process.env.MODRINTH_TOKEN
* })
* ```
*/
export class AuthFeature extends AbstractFeature {
declare protected config: AuthConfig
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const token = await this.getToken()
if (token) {
const headerName = this.config.headerName ?? 'Authorization'
const tokenPrefix = this.config.tokenPrefix ?? 'Bearer'
const headerValue = tokenPrefix ? `${tokenPrefix} ${token}` : token
context.options.headers = {
...context.options.headers,
[headerName]: headerValue,
}
}
return next()
}
shouldApply(context: RequestContext): boolean {
if (context.options.skipAuth) {
return false
}
// Skip if Authorization header is already explicitly set
const headerName = this.config.headerName ?? 'Authorization'
if (context.options.headers?.[headerName]) {
return false
}
return super.shouldApply(context)
}
/**
* Get the authentication token
*
* Handles both static tokens and async token providers
*/
private async getToken(): Promise<string | undefined> {
const { token } = this.config
if (typeof token === 'function') {
return await token()
}
return token
}
}

View File

@ -0,0 +1,269 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
/**
* Circuit breaker state
*/
export type CircuitBreakerState = {
/**
* Number of consecutive failures
*/
failures: number
/**
* Timestamp of last failure
*/
lastFailure: number
}
/**
* Circuit breaker storage interface
*/
export interface CircuitBreakerStorage {
/**
* Get circuit breaker state for a key
*/
get(key: string): CircuitBreakerState | undefined
/**
* Set circuit breaker state for a key
*/
set(key: string, state: CircuitBreakerState): void
/**
* Clear circuit breaker state for a key
*/
clear?(key: string): void
}
/**
* Circuit breaker feature configuration
*/
export interface CircuitBreakerConfig extends FeatureConfig {
/**
* Maximum number of consecutive failures before opening circuit
* @default 3
*/
maxFailures?: number
/**
* Time in milliseconds before circuit resets after opening
* @default 30000
*/
resetTimeout?: number
/**
* HTTP status codes that count as failures
* @default [500, 502, 503, 504]
*/
failureStatusCodes?: number[]
/**
* Storage implementation for circuit state
* If not provided, uses in-memory Map
*/
storage?: CircuitBreakerStorage
/**
* Function to generate circuit key from request context
* By default, uses the base path (without query params)
*/
getCircuitKey?: (url: string, method: string) => string
}
/**
* In-memory storage for circuit breaker state
*/
export class InMemoryCircuitBreakerStorage implements CircuitBreakerStorage {
private state = new Map<string, CircuitBreakerState>()
get(key: string): CircuitBreakerState | undefined {
return this.state.get(key)
}
set(key: string, state: CircuitBreakerState): void {
this.state.set(key, state)
}
clear(key: string): void {
this.state.delete(key)
}
}
/**
* Circuit breaker feature
*
* Prevents requests to failing endpoints by "opening the circuit" after
* a threshold of consecutive failures. The circuit automatically resets
* after a timeout period.
*
* This implements the circuit breaker pattern to prevent cascading failures
* and give failing services time to recover.
*
* @example
* ```typescript
* const circuitBreaker = new CircuitBreakerFeature({
* maxFailures: 3,
* resetTimeout: 30000, // 30 seconds
* failureStatusCodes: [500, 502, 503, 504]
* })
* ```
*/
export class CircuitBreakerFeature extends AbstractFeature {
declare protected config: Required<CircuitBreakerConfig>
private storage: CircuitBreakerStorage
constructor(config?: CircuitBreakerConfig) {
super(config)
this.config = {
enabled: true,
name: 'circuit-breaker',
maxFailures: 3,
resetTimeout: 30000,
failureStatusCodes: [500, 502, 503, 504],
...config,
} as Required<CircuitBreakerConfig>
// Use provided storage or default to in-memory
this.storage = config?.storage ?? new InMemoryCircuitBreakerStorage()
}
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const circuitKey = this.getCircuitKey(context)
if (this.isCircuitOpen(circuitKey)) {
throw new ModrinthApiError('Circuit breaker open - too many recent failures', {
statusCode: 503,
context: context.path,
})
}
try {
const result = await next()
this.recordSuccess(circuitKey)
return result
} catch (error) {
if (this.isFailureError(error)) {
this.recordFailure(circuitKey)
}
throw error
}
}
shouldApply(context: RequestContext): boolean {
if (context.options.circuitBreaker === false) {
return false
}
return super.shouldApply(context)
}
/**
* Get the circuit key for a request
*
* By default, uses the path and method to identify unique circuits
*/
private getCircuitKey(context: RequestContext): string {
if (this.config.getCircuitKey) {
return this.config.getCircuitKey(context.url, context.options.method ?? 'GET')
}
// Default: use method + path (without query params)
const method = context.options.method ?? 'GET'
const pathWithoutQuery = context.path.split('?')[0]
return `${method}_${pathWithoutQuery}`
}
/**
* Check if the circuit is open for a given key
*/
private isCircuitOpen(key: string): boolean {
const state = this.storage.get(key)
if (!state) {
return false
}
const now = Date.now()
const timeSinceLastFailure = now - state.lastFailure
if (timeSinceLastFailure > this.config.resetTimeout) {
this.storage.clear?.(key)
return false
}
return state.failures >= this.config.maxFailures
}
/**
* Record a successful request
*/
private recordSuccess(key: string): void {
this.storage.clear?.(key)
}
/**
* Record a failed request
*/
private recordFailure(key: string): void {
const now = Date.now()
const state = this.storage.get(key)
if (!state) {
// First failure
this.storage.set(key, {
failures: 1,
lastFailure: now,
})
} else {
// Subsequent failure
this.storage.set(key, {
failures: state.failures + 1,
lastFailure: now,
})
}
}
/**
* Determine if an error should count as a circuit failure
*/
private isFailureError(error: unknown): boolean {
if (error instanceof ModrinthApiError && error.statusCode) {
return this.config.failureStatusCodes.includes(error.statusCode)
}
return false
}
/**
* Get current circuit state for debugging
*
* @example
* ```typescript
* const state = circuitBreaker.getCircuitState('GET_/v2/project/sodium')
* console.log(`Failures: ${state?.failures}, Last failure: ${state?.lastFailure}`)
* ```
*/
getCircuitState(key: string): CircuitBreakerState | undefined {
return this.storage.get(key)
}
/**
* Manually reset a circuit
*
* @example
* ```typescript
* // Reset circuit after manual intervention
* circuitBreaker.resetCircuit('GET_/v2/project/sodium')
* ```
*/
resetCircuit(key: string): void {
this.storage.clear?.(key)
}
}

View File

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

View File

@ -0,0 +1,18 @@
import { AbstractFeature } from '../core/abstract-feature'
import type { RequestContext } from '../types/request'
export const PANEL_VERSION = 1
export class PanelVersionFeature extends AbstractFeature {
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
context.options.headers = {
...context.options.headers,
'X-Panel-Version': String(PANEL_VERSION),
}
return next()
}
shouldApply(context: RequestContext): boolean {
return context.options.api === 'labrinth' || context.options.api === 'archon'
}
}

View File

@ -0,0 +1,220 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
/**
* Backoff strategy for retries
*/
export type BackoffStrategy = 'exponential' | 'linear' | 'constant'
/**
* Retry feature configuration
*/
export interface RetryConfig extends FeatureConfig {
/**
* Maximum number of retry attempts
* @default 3
*/
maxAttempts?: number
/**
* Backoff strategy to use
* @default 'exponential'
*/
backoffStrategy?: BackoffStrategy
/**
* Initial delay in milliseconds before first retry
* @default 1000
*/
initialDelay?: number
/**
* Maximum delay in milliseconds between retries
* @default 15000
*/
maxDelay?: number
/**
* HTTP status codes that should trigger a retry
* @default [408, 429, 500, 502, 503, 504]
*/
retryableStatusCodes?: number[]
/**
* Whether to retry on network errors (connection refused, timeout, etc.)
* @default true
*/
retryOnNetworkError?: boolean
/**
* Custom function to determine if an error should be retried
*/
shouldRetry?: (error: unknown, attempt: number) => boolean
}
/**
* Retry feature
*
* Automatically retries failed requests with configurable backoff strategy.
* Only retries errors that are likely to succeed on retry (e.g., timeout, 5xx errors).
*
* @example
* ```typescript
* const retry = new RetryFeature({
* maxAttempts: 3,
* backoffStrategy: 'exponential',
* initialDelay: 1000,
* maxDelay: 15000
* })
* ```
*/
export class RetryFeature extends AbstractFeature {
declare protected config: Required<RetryConfig>
constructor(config?: RetryConfig) {
super(config)
this.config = {
enabled: true,
name: 'retry',
maxAttempts: 3,
backoffStrategy: 'exponential',
initialDelay: 1000,
maxDelay: 15000,
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
retryOnNetworkError: true,
...config,
} as Required<RetryConfig>
}
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
let lastError: Error | null = null
const maxAttempts = this.getMaxAttempts(context)
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
context.attempt = attempt
try {
const result = await next()
return result
} catch (error) {
lastError = error as Error
const shouldRetry = this.shouldRetryError(error, attempt, maxAttempts)
if (!shouldRetry || attempt >= maxAttempts) {
throw error
}
const delay = this.calculateDelay(attempt)
console.warn(
`[${this.name}] Retrying request to ${context.path} (attempt ${attempt + 1}/${maxAttempts}) after ${delay}ms`,
)
await this.sleep(delay)
}
}
// This shouldn't be reached, but TypeScript requires it
throw lastError ?? new Error('Max retry attempts reached')
}
shouldApply(context: RequestContext): boolean {
if (context.options.retry === false) {
return false
}
return super.shouldApply(context)
}
/**
* Determine if an error should be retried
*/
private shouldRetryError(error: unknown, attempt: number, _maxAttempts: number): boolean {
if (this.config.shouldRetry) {
return this.config.shouldRetry(error, attempt)
}
if (this.config.retryOnNetworkError && this.isNetworkError(error)) {
return true
}
if (error instanceof ModrinthApiError && error.statusCode) {
return this.config.retryableStatusCodes.includes(error.statusCode)
}
return false
}
/**
* Check if an error is a network error
*/
private isNetworkError(error: unknown): boolean {
// Common network error indicators
const networkErrorPatterns = [
/network/i,
/timeout/i,
/ECONNREFUSED/i,
/ENOTFOUND/i,
/ETIMEDOUT/i,
/ECONNRESET/i,
]
const errorMessage = error instanceof Error ? error.message : String(error)
return networkErrorPatterns.some((pattern) => pattern.test(errorMessage))
}
/**
* Get max attempts for this request
*/
private getMaxAttempts(context: RequestContext): number {
if (typeof context.options.retry === 'number') {
return context.options.retry
}
return this.config.maxAttempts
}
/**
* Calculate delay before next retry based on backoff strategy
*/
private calculateDelay(attempt: number): number {
const { backoffStrategy, initialDelay, maxDelay } = this.config
let delay: number
switch (backoffStrategy) {
case 'exponential':
// Exponential: delay = initialDelay * 2^(attempt-1)
delay = initialDelay * Math.pow(2, attempt - 1)
break
case 'linear':
// Linear: delay = initialDelay * attempt
delay = initialDelay * attempt
break
case 'constant':
// Constant: delay = initialDelay
delay = initialDelay
break
default:
delay = initialDelay
}
// Add jitter (random 0-1000ms) to prevent thundering herd
delay += Math.random() * 1000
return Math.min(delay, maxDelay)
}
/**
* Sleep for a given duration
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}

View File

@ -0,0 +1,72 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import type { RequestContext } from '../types/request'
export type VerboseLoggingConfig = FeatureConfig
export class VerboseLoggingFeature extends AbstractFeature {
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const method = context.options.method ?? 'GET'
const api = context.options.api
const version = context.options.version
const prefix = `[${method}] [${api}_v${version}]`
console.debug(`${prefix} ${context.url} SENT`)
try {
const result = await next()
try {
const size = result ? JSON.stringify(result).length : 0
console.debug(`${prefix} ${context.url} RECEIVED ${size} bytes`)
} catch {
// ignore size calc fail
console.debug(`${prefix} ${context.url} RECEIVED`)
}
return result
} catch (error) {
const details = formatErrorDetails(error)
console.debug(`${prefix} ${context.url} FAILED${details ? `${details}` : ''}`)
throw error
}
}
}
function formatErrorDetails(error: unknown): string {
if (!error || typeof error !== 'object') {
return typeof error === 'string' ? error : ''
}
const err = error as {
status?: number
statusCode?: number
statusText?: string
message?: string
data?: unknown
responseData?: unknown
originalError?: unknown
response?: { status?: number; statusText?: string; _data?: unknown }
}
const status = err.status ?? err.statusCode ?? err.response?.status
const statusText = err.statusText ?? err.response?.statusText
const data = err.responseData ?? err.data ?? err.response?._data
const parts: string[] = []
if (status !== undefined) {
parts.push(statusText ? `${status} ${statusText}` : String(status))
}
if (data !== undefined) {
parts.push(`body: ${safeStringify(data)}`)
} else if (err.message) {
parts.push(err.message)
}
return parts.join(' ')
}
function safeStringify(value: unknown): string {
if (typeof value === 'string') return value
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}

View File

@ -0,0 +1,55 @@
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,
type WebSocketConnection,
type WebSocketEventHandler,
type WebSocketStatus,
} from './core/abstract-websocket'
export { ModrinthApiError, ModrinthServerError } from './core/errors'
export { type AuthConfig, AuthFeature } from './features/auth'
export {
type CircuitBreakerConfig,
CircuitBreakerFeature,
type CircuitBreakerState,
type CircuitBreakerStorage,
InMemoryCircuitBreakerStorage,
} from './features/circuit-breaker'
export { type NodeAuth, type NodeAuthConfig, NodeAuthFeature } from './features/node-auth'
export { PANEL_VERSION, PanelVersionFeature } from './features/panel-version'
export { type BackoffStrategy, type RetryConfig, RetryFeature } from './features/retry'
export { type VerboseLoggingConfig, VerboseLoggingFeature } from './features/verbose-logging'
export type { InferredClientModules } from './modules'
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 { getNodeWebSocketUrl } from './utils/node-url'
export { pingWebSocketUrl, type WebSocketPingOptions } from './utils/pingtest'
export {
type ParsedSseEvent,
type ParsedSseItem,
type ParsedSseRetry,
parseSyncEventData,
SseParser,
} from './utils/sse'
export type { Override, RawDecimal } from './utils/types'

View File

@ -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,
},
)
}
}

View File

@ -0,0 +1,113 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonBackupsQueueV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_queue_v1'
}
/** GET /v1/servers/:server_id/worlds/:world_id/backups-queue */
public async list(
serverId: string,
worldId: string,
): Promise<Archon.BackupsQueue.v1.BackupsQueueResponse> {
return this.client.request<Archon.BackupsQueue.v1.BackupsQueueResponse>(
`/servers/${serverId}/worlds/${worldId}/backups-queue`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue */
public async create(
serverId: string,
worldId: string,
request: Archon.BackupsQueue.v1.BackupRequest,
): Promise<Archon.BackupsQueue.v1.PostBackupQueueResponse> {
return this.client.request<Archon.BackupsQueue.v1.PostBackupQueueResponse>(
`/servers/${serverId}/worlds/${worldId}/backups-queue`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/history/create/:operation_id/ack */
public async ackCreate(serverId: string, worldId: string, operationId: number): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/history/create/${operationId}/ack`,
{ api: 'archon', version: 1, method: 'POST' },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/history/create/:operation_id/cancel */
public async cancelCreate(serverId: string, worldId: string, operationId: number): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/history/create/${operationId}/cancel`,
{ api: 'archon', version: 1, method: 'POST' },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/history/restore/:operation_id/ack */
public async ackRestore(serverId: string, worldId: string, operationId: number): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/history/restore/${operationId}/ack`,
{ api: 'archon', version: 1, method: 'POST' },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/history/restore/:operation_id/cancel */
public async cancelRestore(
serverId: string,
worldId: string,
operationId: number,
): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/history/restore/${operationId}/cancel`,
{ api: 'archon', version: 1, method: 'POST' },
)
}
/** DELETE /v1/servers/:server_id/worlds/:world_id/backups-queue/:backup_id */
public async delete(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/${backupId}`,
{
api: 'archon',
version: 1,
method: 'DELETE',
},
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/delete-many */
public async deleteMany(serverId: string, worldId: string, backupIds: string[]): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/delete-many`,
{
api: 'archon',
version: 1,
method: 'POST',
body: { backup_ids: backupIds } satisfies Archon.BackupsQueue.v1.DeleteManyBackupRequest,
},
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/:backup_id/restore */
public async restore(
serverId: string,
worldId: string,
backupId: string,
request: Archon.BackupsQueue.v1.BackupRequest,
): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/${backupId}/restore`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/:backup_id/retry */
public async retry(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/${backupId}/retry`,
{ api: 'archon', version: 1, method: 'POST' },
)
}
}

View File

@ -0,0 +1,113 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
/**
* @deprecated Use `client.archon.backups_queue_v1` (Backups Queue API) instead.
*/
export class ArchonBackupsV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_v1'
}
/**
* @deprecated Use `client.archon.backups_queue_v1.list` instead.
*/
/** 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[]>(
`/servers/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/**
* @deprecated Use `client.archon.backups_queue_v1.list` instead.
*/
/** GET /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async get(
serverId: string,
worldId: string,
backupId: string,
): Promise<Archon.Backups.v1.Backup> {
return this.client.request<Archon.Backups.v1.Backup>(
`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/**
* @deprecated Use `client.archon.backups_queue_v1.create` instead.
*/
/** POST /v1/servers/:server_id/worlds/:world_id/backups */
public async create(
serverId: string,
worldId: string,
request: Archon.Backups.v1.BackupRequest,
): Promise<Archon.Backups.v1.PostBackupResponse> {
return this.client.request<Archon.Backups.v1.PostBackupResponse>(
`/servers/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
/**
* @deprecated Use `client.archon.backups_queue_v1.restore` instead.
*/
/** 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',
},
)
}
/**
* @deprecated Use `client.archon.backups_queue_v1.delete` for backup deletion, or
* `client.archon.backups_queue_v1.cancelCreate` / `cancelRestore` for active operations.
*/
/** 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',
})
}
/**
* @deprecated Use `client.archon.backups_queue_v1.retry` instead.
*/
/** 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',
},
)
}
/**
* @deprecated Legacy backups only; no queue equivalent. Prefer renaming via other supported flows if available.
*/
/** 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,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'PATCH',
body: request,
})
}
}

View File

@ -0,0 +1,290 @@
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/install-many */
public async addAddons(
serverId: string,
worldId: string,
addons: Archon.Content.v1.AddAddonRequest[],
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/install-many`, {
api: 'archon',
version: 1,
method: 'POST',
body: addons satisfies Archon.Content.v1.AddAddonsRequest,
})
}
/** 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

@ -0,0 +1,8 @@
export * from './actions/v1'
export * from './backups/v1'
export * from './backups-queue/v1'
export * from './content/v1'
export * from './properties/v1'
export * from './servers/v0'
export * from './servers/v1'
export * from './types'

View File

@ -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',
})
}
}

View File

@ -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 }
}
}

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

@ -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),
})
}
}

View File

@ -0,0 +1,321 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Archon } from '../types'
export class ArchonServersV0Module extends AbstractModule {
public getModuleID(): string {
return 'archon_servers_v0'
}
/**
* Get a specific server by ID
* GET /modrinth/v0/servers/:id
*/
public async get(serverId: string): Promise<Archon.Servers.v0.Server> {
return this.client.request<Archon.Servers.v0.Server>(`/servers/${serverId}`, {
api: 'archon',
method: 'GET',
version: 'modrinth/v0',
})
}
/**
* Get list of servers for the authenticated user
* GET /modrinth/v0/servers
*/
public async list(
options?: Archon.Servers.v0.GetServersOptions,
): Promise<Archon.Servers.v0.ServerGetResponse> {
const params = new URLSearchParams()
if (options?.limit) params.set('limit', options.limit.toString())
if (options?.offset) params.set('offset', options.offset.toString())
const query = params.toString() ? `?${params.toString()}` : ''
return this.client.request<Archon.Servers.v0.ServerGetResponse>(`servers${query}`, {
api: 'archon',
method: 'GET',
version: 'modrinth/v0',
})
}
/**
* Check stock availability for a region
* POST /modrinth/v0/stock?region=:region
*/
public async checkStock(
region: string,
request: Archon.Servers.v0.StockRequest,
): Promise<Archon.Servers.v0.StockResponse> {
return this.client.request<Archon.Servers.v0.StockResponse>(`/stock?region=${region}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
skipAuth: true,
})
}
/**
* Check stock availability (without region filter)
* POST /modrinth/v0/stock
*/
public async checkStockGlobal(
request: Archon.Servers.v0.StockRequest,
): Promise<Archon.Servers.v0.StockResponse> {
return this.client.request<Archon.Servers.v0.StockResponse>('/stock', {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
skipAuth: true,
})
}
/**
* Get filesystem authentication credentials for a server
* Returns URL and JWT token for accessing the server's filesystem via Kyros
* GET /modrinth/v0/servers/:id/fs
*/
public async getFilesystemAuth(serverId: string): Promise<Archon.Servers.v0.JWTAuth> {
return this.client.request<Archon.Servers.v0.JWTAuth>(`/servers/${serverId}/fs`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/**
* Get WebSocket authentication credentials for a server
* GET /modrinth/v0/servers/:id/ws
*/
public async getWebSocketAuth(serverId: string): Promise<Archon.Websocket.v0.WSAuth> {
return this.client.request<Archon.Websocket.v0.WSAuth>(`/servers/${serverId}/ws`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/**
* Send a power action to a server (Start, Stop, Restart, Kill)
* POST /modrinth/v0/servers/:id/power
*/
public async power(
serverId: string,
action: 'Start' | 'Stop' | 'Restart' | 'Kill',
): Promise<void> {
await this.client.request(`/servers/${serverId}/power`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
body: { action },
})
}
/**
* 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

@ -0,0 +1,69 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonServersV1Module extends AbstractModule {
public getModuleID(): string {
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
*/
public async getRegions(): Promise<Archon.Servers.v1.Region[]> {
return this.client.request<Archon.Servers.v1.Region[]>('/regions', {
api: 'archon',
version: 1,
method: 'GET',
skipAuth: true,
})
}
/**
* 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',
})
}
/**
* Reset a world to onboarding
* POST /v1/servers/:id/worlds/:wid/onboard
*/
public async resetToOnboarding(serverId: string, worldId: string): Promise<void> {
await this.client.request(`/servers/${serverId}/worlds/${worldId}/onboard`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
}

View File

@ -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,
},
)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,211 @@
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'
import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3'
import { LabrinthAttributionInternalModule } from './labrinth/attribution/internal'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthContentV3Module } from './labrinth/content/v3'
import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal'
import { LabrinthFriendsV3Module } from './labrinth/friends/v3'
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
import { LabrinthImagesV3Module } from './labrinth/images/v3'
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
import { LabrinthNotificationsV2Module } from './labrinth/notifications/v2'
import { LabrinthOAuthInternalModule } from './labrinth/oauth/internal'
import { LabrinthOrganizationsV3Module } from './labrinth/organizations/v3'
import { LabrinthPatsV2Module } from './labrinth/pats/v2'
import { LabrinthPayoutV3Module } from './labrinth/payout/v3'
import { LabrinthPayoutsV3Module } from './labrinth/payouts/v3'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
import { LabrinthProjectsV3Module } from './labrinth/projects/v3'
import { LabrinthReportsV3Module } from './labrinth/reports/v3'
import { LabrinthServerPingInternalModule } from './labrinth/server-ping/internal'
import { LabrinthSessionsV2Module } from './labrinth/sessions/v2'
import { LabrinthStateModule } from './labrinth/state'
import { LabrinthTagsV2Module } from './labrinth/tags/v2'
import { LabrinthTeamsV2Module } from './labrinth/teams/v2'
import { LabrinthTeamsV3Module } from './labrinth/teams/v3'
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
import { LabrinthThreadsV3Module } from './labrinth/threads/v3'
import { LabrinthUsersV2Module } from './labrinth/users/v2'
import { LabrinthUsersV3Module } from './labrinth/users/v3'
import { LauncherMetaManifestV0Module } from './launcher-meta/v0'
import { MclogsLogsV1Module } from './mclogs/logs/v1'
import { PaperVersionsV3Module } from './paper/v3'
import { PurpurVersionsV2Module } from './purpur/v2'
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
/**
* To add a new module:
* 1. Create your module class extending AbstractModule
* 2. Add one line here: `<api>_<module>: YourModuleClass`
*
* TypeScript will automatically infer the client's field structure from this registry.
*
* 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_logs_v1: MclogsLogsV1Module,
launchermeta_manifest_v0: LauncherMetaManifestV0Module,
kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
labrinth_analytics_v3: LabrinthAnalyticsV3Module,
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
labrinth_attribution_internal: LabrinthAttributionInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_content_v3: LabrinthContentV3Module,
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
labrinth_friends_v3: LabrinthFriendsV3Module,
labrinth_globals_internal: LabrinthGlobalsInternalModule,
labrinth_images_v3: LabrinthImagesV3Module,
labrinth_moderation_internal: LabrinthModerationInternalModule,
labrinth_notifications_v2: LabrinthNotificationsV2Module,
labrinth_oauth_internal: LabrinthOAuthInternalModule,
labrinth_organizations_v3: LabrinthOrganizationsV3Module,
labrinth_pats_v2: LabrinthPatsV2Module,
labrinth_limits_v3: LabrinthLimitsV3Module,
labrinth_payout_v3: LabrinthPayoutV3Module,
labrinth_payouts_v3: LabrinthPayoutsV3Module,
labrinth_projects_v2: LabrinthProjectsV2Module,
labrinth_projects_v3: LabrinthProjectsV3Module,
labrinth_reports_v3: LabrinthReportsV3Module,
labrinth_server_ping_internal: LabrinthServerPingInternalModule,
labrinth_sessions_v2: LabrinthSessionsV2Module,
labrinth_state: LabrinthStateModule,
labrinth_tags_v2: LabrinthTagsV2Module,
labrinth_teams_v2: LabrinthTeamsV2Module,
labrinth_teams_v3: LabrinthTeamsV3Module,
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
labrinth_threads_v3: LabrinthThreadsV3Module,
labrinth_users_v2: LabrinthUsersV2Module,
labrinth_users_v3: LabrinthUsersV3Module,
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
/**
* Parse a module ID into [api, moduleName] tuple
*
* @param id - Module ID in format `<api>_<module>` (e.g., 'labrinth_projects_v2')
* @returns Tuple of [api, moduleName] (e.g., ['labrinth', 'projects_v2'])
* @throws Error if module ID doesn't match expected format
*/
export function parseModuleID(id: string): [string, string] {
const parts = id.split('_')
if (parts.length < 2) {
throw new Error(
`Invalid module ID "${id}". Expected format: <api>_<module> (e.g., "labrinth_projects_v2")`,
)
}
const api = parts[0]
const moduleName = parts.slice(1).join('_')
return [api, moduleName]
}
/**
* Build nested module structure from flat registry
*
* Transforms:
* ```
* { labrinth_projects_v2: Constructor, labrinth_users_v2: Constructor }
* ```
* Into:
* ```
* { labrinth: { projects_v2: Constructor, users_v2: Constructor } }
* ```
*
* @returns Nested structure organized by API namespace
*/
export function buildModuleStructure(): Record<string, Record<string, ModuleConstructor>> {
const structure: Record<string, Record<string, ModuleConstructor>> = {}
for (const [id, constructor] of Object.entries(MODULE_REGISTRY)) {
const [api, moduleName] = parseModuleID(id)
if (!structure[api]) {
structure[api] = {}
}
structure[api][moduleName] = constructor
}
return structure
}
/**
* Extract API name from module ID
* @example ParseAPI<'labrinth_projects_v2'> = 'labrinth'
*/
type ParseAPI<T extends string> = T extends `${infer API}_${string}` ? API : never
/**
* Extract module name for a given API
* @example ParseModule<'labrinth_projects_v2', 'labrinth'> = 'projects_v2'
*/
type ParseModule<T extends string, API extends string> = T extends `${API}_${infer Module}`
? Module
: never
/**
* Group registry modules by API namespace
*
* Transforms flat registry into nested structure at the type level:
* ```
* { labrinth_projects_v2: ModuleClass } → { labrinth: { projects_v2: ModuleInstance } }
* ```
*/
type GroupByAPI<Registry extends Record<string, ModuleConstructor>> = {
[API in ParseAPI<keyof Registry & string>]: {
[Module in ParseModule<keyof Registry & string, API>]: InstanceType<
Registry[`${API}_${Module}`]
>
}
}
/**
* Inferred client module structure
**/
export type InferredClientModules = GroupByAPI<typeof MODULE_REGISTRY>

View File

@ -0,0 +1,121 @@
import { $fetch } from 'ofetch'
import { AbstractModule } from '../../core/abstract-module'
import type { ISO3166 } from './types'
export type { ISO3166 } from './types'
const ISO3166_REPO = 'https://raw.githubusercontent.com/ipregistry/iso3166/master'
/**
* Parse CSV string into array of objects
* @param csv - CSV string to parse
* @returns Array of objects with header keys mapped to row values
*/
function parseCSV(csv: string): Record<string, string>[] {
const lines = csv
.trim()
.split('\n')
.filter((line) => line.trim() !== '')
if (lines.length === 0) return []
const headerLine = lines[0]
const headers = (headerLine.startsWith('#') ? headerLine.slice(1) : headerLine).split(',')
return lines.slice(1).map((line) => {
const values = line.split(',')
const row: Record<string, string> = {}
headers.forEach((header, index) => {
row[header] = values[index] || ''
})
return row
})
}
/**
* Module for fetching ISO 3166 country and subdivision data
* Data from https://github.com/ipregistry/iso3166 (Licensed under CC BY-SA 4.0)
* @platform Not for use in Tauri or Nuxt environments, only node.
*/
export class ISO3166Module extends AbstractModule {
public getModuleID(): string {
return 'iso3166_data'
}
/**
* Build ISO 3166 country and subdivision data from the ipregistry repository
*
* @returns Promise resolving to countries and subdivisions data
*
* @example
* ```typescript
* const data = await client.iso3166.data.build()
* console.log(data.countries) // Array of country data
* console.log(data.subdivisions['US']) // Array of US state data
* ```
*/
public async build(): Promise<ISO3166.State> {
try {
const [countriesCSV, subdivisionsCSV] = await Promise.all([
$fetch<string>(`${ISO3166_REPO}/countries.csv`, {
// @ts-expect-error supports text
responseType: 'text',
}),
$fetch<string>(`${ISO3166_REPO}/subdivisions.csv`, {
// @ts-expect-error supports text
responseType: 'text',
}),
])
const countriesData = parseCSV(countriesCSV)
const subdivisionsData = parseCSV(subdivisionsCSV)
const countries: ISO3166.Country[] = countriesData.map((c) => ({
alpha2: c.country_code_alpha2,
alpha3: c.country_code_alpha3,
numeric: c.numeric_code,
nameShort: c.name_short,
nameLong: c.name_long,
}))
// Group subdivisions by country code
const subdivisions: Record<string, ISO3166.Subdivision[]> = subdivisionsData.reduce(
(acc, sub) => {
const countryCode = sub.country_code_alpha2
if (!countryCode || typeof countryCode !== 'string' || countryCode.trim() === '') {
return acc
}
if (!acc[countryCode]) acc[countryCode] = []
acc[countryCode].push({
code: sub['subdivision_code_iso3166-2'],
name: sub.subdivision_name,
localVariant: sub.localVariant || null,
category: sub.category,
parent: sub.parent_subdivision || null,
language: sub.language_code,
})
return acc
},
{} as Record<string, ISO3166.Subdivision[]>,
)
return {
countries,
subdivisions,
}
} catch (err) {
console.error('Error fetching ISO3166 data:', err)
return {
countries: [],
subdivisions: {},
}
}
}
}

View File

@ -0,0 +1,23 @@
export namespace ISO3166 {
export interface Country {
alpha2: string
alpha3: string
numeric: string
nameShort: string
nameLong: string
}
export interface Subdivision {
code: string // Full ISO 3166-2 code (e.g., "US-NY")
name: string // Official name in local language
localVariant: string | null // English variant if different
category: string // STATE, PROVINCE, REGION, etc.
parent: string | null // Parent subdivision code
language: string // Language code
}
export interface State {
countries: Country[]
subdivisions: Record<string, Subdivision[]>
}
}

View File

@ -0,0 +1,66 @@
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
* @deprecated Use `kyros.upload_sessions_v1` so cancellation can remove staged addon files before finalize.
*/
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

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

View File

@ -0,0 +1,17 @@
import { AbstractModule } from '../../../core/abstract-module'
export class KyrosLogsV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_logs_v1'
}
/** POST /v1/logs/clear — clear the live logs buffer for the current server */
public async clear(): Promise<void> {
return this.client.request<void>('/logs/clear', {
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
})
}
}

View File

@ -0,0 +1,55 @@
export namespace Kyros {
export namespace UploadSessions {
export namespace v1 {
export type Scope = 'content' | 'files'
export type UploadSessionStatus =
| 'active'
| 'uploading'
| 'finalizing'
| 'cancelled'
| 'finalized'
| 'expired'
export interface UploadSessionResponse {
upload_id: string
status: UploadSessionStatus
created_at: number
updated_at: number
last_upload_at: number | null
expires_at: number
entry_count: number
uploaded_byte_count: number
}
export interface GetUploadSessionResponse {
session: UploadSessionResponse | null
}
}
}
export namespace Files {
export namespace v0 {
export interface DirectoryItem {
name: string
type: 'file' | 'directory' | 'symlink'
path: string
modified: number
created: number
size?: number
count?: number
target?: string
}
export interface DirectoryResponse {
items: DirectoryItem[]
total: number
current: number
}
export interface ExtractResult {
modpack_name: string | null
conflicting_files: string[]
}
}
}
}

View File

@ -0,0 +1,104 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export type UploadSessionFile = {
file: File | Blob
filename: string
}
export class KyrosUploadSessionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_upload_sessions_v1'
}
public async create(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async get(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'GET',
useNodeAuth: true,
},
)
}
public uploadFiles(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
files: UploadSessionFile[],
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<Kyros.UploadSessions.v1.UploadSessionResponse> {
const formData = new FormData()
for (const { file, filename } of files) {
formData.append('file', file, filename)
}
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/files`,
{
api: '',
version: 'v1',
formData,
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
},
)
}
public async finalize(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async cancel(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}`,
{
api: '',
version: 'v1',
method: 'DELETE',
useNodeAuth: true,
},
)
}
}

View File

@ -0,0 +1,72 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthAffiliateInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_affiliate_internal'
}
/**
* Get all affiliate codes for the authenticated user (or all if admin)
* GET /_internal/affiliate
*/
public async getAll(): Promise<Labrinth.Affiliate.Internal.AffiliateCode[]> {
return this.client.request<Labrinth.Affiliate.Internal.AffiliateCode[]>('/affiliate', {
api: 'labrinth',
version: 'internal',
method: 'GET',
})
}
/**
* Create a new affiliate code
* PUT /_internal/affiliate
*/
public async create(
data: Labrinth.Affiliate.Internal.CreateRequest,
): Promise<Labrinth.Affiliate.Internal.AffiliateCode> {
return this.client.request<Labrinth.Affiliate.Internal.AffiliateCode>('/affiliate', {
api: 'labrinth',
version: 'internal',
method: 'PUT',
body: data,
})
}
/**
* Get a specific affiliate code by ID
* GET /_internal/affiliate/{id}
*/
public async get(id: string): Promise<Labrinth.Affiliate.Internal.AffiliateCode> {
return this.client.request<Labrinth.Affiliate.Internal.AffiliateCode>(`/affiliate/${id}`, {
api: 'labrinth',
version: 'internal',
method: 'GET',
})
}
/**
* Delete an affiliate code
* DELETE /_internal/affiliate/{id}
*/
public async delete(id: string): Promise<void> {
return this.client.request<void>(`/affiliate/${id}`, {
api: 'labrinth',
version: 'internal',
method: 'DELETE',
})
}
/**
* Update an affiliate code's source name
* PATCH /_internal/affiliate/{id}
*/
public async patch(id: string, data: Labrinth.Affiliate.Internal.PatchRequest): Promise<void> {
return this.client.request<void>(`/affiliate/${id}`, {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
})
}
}

View File

@ -0,0 +1,116 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthAnalyticsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_analytics_v3'
}
/**
* Fetch analytics data for the authenticated user's accessible projects
* and affiliate codes.
*
* @param data - Analytics request body defining time range and requested metrics
* @returns Promise resolving to the analytics response, with time slices in `metrics`
*
* @example
* ```typescript
* const response = await client.labrinth.analytics_v3.fetch({
* time_range: {
* start: '2026-01-01T00:00:00Z',
* end: '2026-02-01T00:00:00Z',
* resolution: { slices: 31 },
* },
* project_ids: ['A1B2C3D4'],
* return_metrics: {
* project_views: { bucket_by: ['project_id'] },
* },
* })
* const timeSlices = response.metrics
* ```
*/
public async fetch(
data: Labrinth.Analytics.v3.FetchRequest,
): Promise<Labrinth.Analytics.v3.FetchResponse> {
return this.client.request<Labrinth.Analytics.v3.FetchResponse>('/analytics', {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
timeout: 100 * 1000,
})
}
/**
* Fetch available analytics filter facets for the authenticated user's
* accessible projects.
*
* POST /v3/analytics/facets
*/
public async fetchFacets(
data: Labrinth.Analytics.v3.FetchRequest,
): Promise<Labrinth.Analytics.v3.FacetsResponse> {
return this.client.request<Labrinth.Analytics.v3.FacetsResponse>('/analytics/facets', {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
timeout: 100 * 1000,
})
}
/**
* Fetch all analytics events.
* GET /v3/analytics-event
*/
public async getEvents(): Promise<Labrinth.Analytics.v3.AnalyticsEvent[]> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent[]>('/analytics-event', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Create an analytics event.
* POST /v3/analytics-event
*/
public async createEvent(
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>('/analytics-event', {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
})
}
/**
* Edit an analytics event.
* PATCH /v3/analytics-event/{id}
*/
public async editEvent(
id: Labrinth.Analytics.v3.AnalyticsEventId,
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>(`/analytics-event/${id}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete an analytics event.
* DELETE /v3/analytics-event/{id}
*/
public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise<void> {
return this.client.request<void>(`/analytics-event/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}

View File

@ -0,0 +1,135 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
/**
* Decode a base62-encoded ID string into a number.
* The backend serializes attribution group IDs as base62 strings in responses,
* but the assign/update endpoints expect raw integer IDs in their request payloads.
*/
function decodeBase62Id(id: string): number {
let value = 0
for (const char of id) {
const digit = BASE62_CHARS.indexOf(char)
if (digit < 0) {
throw new Error(`Invalid base62 character "${char}" in id "${id}"`)
}
value = value * 62 + digit
if (!Number.isSafeInteger(value)) {
throw new Error(`Base62 id "${id}" exceeds safe integer range`)
}
}
return value
}
export class LabrinthAttributionInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_attribution_internal'
}
/**
* List attribution groups for a project
* GET /_internal/attribution/{project_id}
*/
public async listProjectAttribution(
projectId: string,
): Promise<Labrinth.Attribution.Internal.AttributionGroup[]> {
return this.client.request<Labrinth.Attribution.Internal.AttributionGroup[]>(
`/attribution/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Update an attribution group's attribution payload.
* PATCH /_internal/attribution/group/{group_id}
*
* @param groupId - The base62 attribution group id (as returned from listProjectAttribution).
*/
public async updateGroup(
groupId: string,
body: Labrinth.Attribution.Internal.UpdateGroupRequest,
): Promise<void> {
const numericId = decodeBase62Id(groupId)
return this.client.request<void>(`/attribution/group/${numericId}`, {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body,
})
}
/**
* Delete an attribution group and all files inside it.
* DELETE /_internal/attribution/group/{group_id}
*
* @param groupId - The base62 attribution group id (as returned from listProjectAttribution).
*/
public async deleteGroup(groupId: string): Promise<void> {
const numericId = decodeBase62Id(groupId)
return this.client.request<void>(`/attribution/group/${numericId}`, {
api: 'labrinth',
version: 'internal',
method: 'DELETE',
})
}
/**
* Reassign a file (by sha1) to another attribution group within the same project.
* POST /_internal/attribution/assign
*
* @param body.target_group_id - The base62 id of the attribution group to assign the file to.
*/
public async assignFileToGroup(body: {
sha1: string
target_group_id: string
project_id: string
}): Promise<void> {
const wireBody: Labrinth.Attribution.Internal.AssignRequest = {
sha1: body.sha1,
target_group_id: decodeBase62Id(body.target_group_id),
project_id: body.project_id,
}
return this.client.request<void>('/attribution/assign', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: wireBody,
})
}
/**
* Split a file (by sha1) out of its current attribution group into a new group.
* POST /_internal/attribution/split
*/
public async splitFile(body: Labrinth.Attribution.Internal.SplitRequest): Promise<void> {
return this.client.request<void>('/attribution/split', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body,
})
}
/**
* Scan a file for attribution information.
* POST /_internal/attribution/file/{file_id}/scan
*
* @param fileId - The file ID to scan.
*/
public async scanFile(fileId: string): Promise<Labrinth.Attribution.Internal.FileScanResponse> {
return this.client.request<Labrinth.Attribution.Internal.FileScanResponse>(
`/attribution/file/${fileId}/scan`,
{
api: 'labrinth',
version: 'internal',
method: 'POST',
},
)
}
}

View File

@ -0,0 +1,46 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthAuthInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_auth_internal'
}
/**
* Check if the user is subscribed to the newsletter
*
* @returns Promise resolving to the subscription status
*/
public async getNewsletterStatus(): Promise<Labrinth.Auth.Internal.SubscriptionStatus> {
return this.client.request<Labrinth.Auth.Internal.SubscriptionStatus>('/auth/email/subscribe', {
api: 'labrinth',
version: 'internal',
method: 'GET',
})
}
/**
* Subscribe to the newsletter
*/
public async subscribeNewsletter(): Promise<void> {
return this.client.request('/auth/email/subscribe', {
api: 'labrinth',
version: 'internal',
method: 'POST',
})
}
/**
* Create a signed Discord community bot handoff URL
*/
public async createDiscordCommunityLink(): Promise<Labrinth.Auth.Internal.DiscordCommunityLinkResponse> {
return this.client.request<Labrinth.Auth.Internal.DiscordCommunityLinkResponse>(
'/auth/discord-community-link',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
},
)
}
}

View File

@ -0,0 +1,232 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthAuthV2Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_auth_v2'
}
/**
* Log in with a password
*
* Returns a session token on success, or a flow ID if 2FA is required.
*
* @param data - Login credentials and captcha challenge
* @returns Promise resolving to a login response with session or flow
*/
public async login(data: Labrinth.Auth.v2.LoginRequest): Promise<Labrinth.Auth.v2.LoginResponse> {
return this.client.request<Labrinth.Auth.v2.LoginResponse>(`/auth/login`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Complete a 2FA login flow
*
* @param data - The 2FA code and flow ID
* @returns Promise resolving to a session response
*/
public async login2FA(
data: Labrinth.Auth.v2.Login2FARequest,
): Promise<Labrinth.Auth.v2.Login2FAResponse> {
return this.client.request<Labrinth.Auth.v2.Login2FAResponse>(`/auth/login/2fa`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Create a new account with a password
*
* @param data - Account creation data
* @returns Promise resolving to a session response
*/
public async createAccount(
data: Labrinth.Auth.v2.CreateAccountRequest,
): Promise<Labrinth.Auth.v2.CreateAccountResponse> {
return this.client.request<Labrinth.Auth.v2.CreateAccountResponse>(`/auth/create`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Validate email/password inputs for account creation without creating an account.
*
* @param data - Prospective account credentials
*/
public async validateCreateAccount(
data: Labrinth.Auth.v2.ValidateCreateAccountRequest,
): Promise<void> {
return this.client.request(`/auth/create/validate`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Create a new account from an OAuth callback flow state
*
* @param data - OAuth account creation data
* @returns Promise resolving to a session response
*/
public async createOAuthAccount(
data: Labrinth.Auth.v2.CreateOAuthAccountRequest,
): Promise<Labrinth.Auth.v2.CreateOAuthAccountResponse> {
return this.client.request<Labrinth.Auth.v2.CreateOAuthAccountResponse>(`/auth/create/oauth`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Begin a password reset flow by sending a recovery email
*
* @param data - The username/email and captcha challenge
*/
public async resetPasswordBegin(data: Labrinth.Auth.v2.ResetPasswordRequest): Promise<void> {
return this.client.request(`/auth/password/reset`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Change a user's password (via reset flow or with old password)
*
* @param data - The password change data
*/
public async changePassword(data: Labrinth.Auth.v2.ChangePasswordRequest): Promise<void> {
return this.client.request(`/auth/password`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
body: data,
})
}
/**
* List the current user's registered passkeys
*
* @returns A promise that resolves to a list of the user's registered passkeys
*/
public async listPasskeys(): Promise<Labrinth.Auth.v2.Passkey[]> {
return this.client.request<Labrinth.Auth.v2.Passkey[]>(`/auth/passkey`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
/**
* Begin registering a new passkey, returning the WebAuthn creation options and a flow
*
* @returns A promise that resolves to the WebAuthn creation options and flow
*/
public async registerPasskeyStart(): Promise<Labrinth.Auth.v2.PasskeyRegisterStartResponse> {
return this.client.request<Labrinth.Auth.v2.PasskeyRegisterStartResponse>(
`/auth/passkey/register/start`,
{
api: 'labrinth',
version: 2,
method: 'POST',
},
)
}
/**
* Complete passkey registration with the created credential
*
* @param data The credential data and flow to complete registration with
* @returns A promise that resolves to the newly registered passkey
*/
public async registerPasskeyFinish(
data: Labrinth.Auth.v2.PasskeyRegisterFinishRequest,
): Promise<Labrinth.Auth.v2.Passkey> {
return this.client.request<Labrinth.Auth.v2.Passkey>(`/auth/passkey/register/finish`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Begin a passkey authentication flow, returning the WebAuthn request options and a flow
*
* @returns A promise that resolves to the WebAuthn request options and a flow
*/
public async authenticatePasskeyStart(): Promise<Labrinth.Auth.v2.PasskeyAuthenticateStartResponse> {
return this.client.request<Labrinth.Auth.v2.PasskeyAuthenticateStartResponse>(
`/auth/passkey/start`,
{
api: 'labrinth',
version: 2,
method: 'POST',
skipAuth: true,
},
)
}
/**
* Complete a passkey authentication flow, returning the new session
*
* @param data The credential data and flow to complete authentication with
* @returns A promise that resolves to the new session
*/
public async authenticatePasskeyFinish(
data: Labrinth.Auth.v2.PasskeyAuthenticateFinishRequest,
): Promise<Labrinth.Sessions.v2.Session> {
return this.client.request<Labrinth.Sessions.v2.Session>(`/auth/passkey/finish`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
skipAuth: true,
})
}
/**
* Rename a passkey
*
* @param id The ID of the passkey to rename
* @param data The new name for the passkey
*/
public async renamePasskey(
id: string,
data: Labrinth.Auth.v2.PasskeyRenameRequest,
): Promise<void> {
return this.client.request(`/auth/passkey/${id}`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
body: data,
})
}
/**
* Delete a passkey
*
* @param id The ID of the passkey to delete
*/
public async deletePasskey(id: string): Promise<void> {
return this.client.request(`/auth/passkey/${id}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
}

View File

@ -0,0 +1,128 @@
import { AbstractModule } from '../../core/abstract-module.js'
import type { Labrinth } from '../types'
export class LabrinthCollectionsModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_collections'
}
/**
* Get a collection by ID (v3)
*
* @param id - Collection ID
* @returns Promise resolving to the collection data
*
* @example
* ```typescript
* const collection = await client.labrinth.collections.get('AANobbMI')
* ```
*/
public async get(id: string): Promise<Labrinth.Collections.Collection> {
return this.client.request<Labrinth.Collections.Collection>(`/collection/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get multiple collections by IDs (v3)
*
* @param ids - Array of collection IDs
* @returns Promise resolving to array of collections
*
* @example
* ```typescript
* const collections = await client.labrinth.collections.getMultiple(['AANobbMI', 'BBNoobMI'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Collections.Collection[]> {
return this.client.request<Labrinth.Collections.Collection[]>(`/collections`, {
api: 'labrinth',
version: 3,
method: 'GET',
params: { ids: JSON.stringify(ids) },
})
}
/**
* Edit a collection (v3)
*
* @param id - Collection ID
* @param data - Collection update data
*
* @example
* ```typescript
* await client.labrinth.collections.edit('AANobbMI', {
* name: 'Updated name',
* description: 'Updated description',
* status: 'listed'
* })
* ```
*/
public async edit(id: string, data: Labrinth.Collections.EditCollectionRequest): Promise<void> {
return this.client.request(`/collection/${id}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete a collection (v3)
*
* @param id - Collection ID
*
* @example
* ```typescript
* await client.labrinth.collections.delete('AANobbMI')
* ```
*/
public async delete(id: string): Promise<void> {
return this.client.request(`/collection/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
/**
* Edit a collection icon (v3)
*
* @param id - Collection ID
* @param icon - Icon file
* @param ext - File extension (e.g., 'png', 'jpg')
*
* @example
* ```typescript
* await client.labrinth.collections.editIcon('AANobbMI', iconFile, 'png')
* ```
*/
public async editIcon(id: string, icon: Blob, ext: string): Promise<void> {
return this.client.request(`/collection/${id}/icon?ext=${ext}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: icon,
})
}
/**
* Delete a collection icon (v3)
*
* @param id - Collection ID
*
* @example
* ```typescript
* await client.labrinth.collections.deleteIcon('AANobbMI')
* ```
*/
public async deleteIcon(id: string): Promise<void> {
return this.client.request(`/collection/${id}/icon`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}

View File

@ -0,0 +1,19 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthContentV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_content_v3'
}
public async resolve(
request: Labrinth.Content.v3.ResolveContentRequest,
): Promise<Labrinth.Content.v3.ResolveContentPlan> {
return this.client.request<Labrinth.Content.v3.ResolveContentPlan>('/content/resolve', {
api: 'labrinth',
version: 3,
method: 'POST',
body: request,
})
}
}

View File

@ -0,0 +1,64 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthExternalProjectsInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_external_projects_internal'
}
public async search(
data: Labrinth.ExternalProjects.Internal.SearchRequest,
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject[]> {
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject[]>(
'/moderation/external-license/search',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
},
)
}
public async getBySha1(
sha1: string,
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
`/moderation/external-license/by-sha1/${sha1}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
public async update(
id: number,
data: Labrinth.ExternalProjects.Internal.UpdateLicenseRequest,
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
`/moderation/external-license/${id}`,
{
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
},
)
}
public async addFile(
data: Labrinth.ExternalProjects.Internal.AddFileRequest,
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
'/moderation/external-license/file',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
},
)
}
}

View File

@ -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',
})
}
}

View File

@ -0,0 +1,22 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthGlobalsInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_globals_internal'
}
/**
* Get configured global non-secret variables for this backend instance
*
* @returns Promise resolving to the global configuration
*/
public async get(): Promise<Labrinth.Globals.Internal.Globals> {
return this.client.request<Labrinth.Globals.Internal.Globals>(`/globals`, {
api: 'labrinth',
version: 'internal',
method: 'GET',
skipAuth: true,
})
}
}

View File

@ -0,0 +1,47 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle } from '../../../types/upload'
import type { Labrinth } from '../types'
function buildImageQueryParams(
ext: Labrinth.Images.v3.ImageExtension,
target: Labrinth.Images.v3.UploadImageParams,
): Record<string, string> {
const params: Record<string, string> = {
ext,
context: target.context,
}
switch (target.context) {
case 'project':
params.project_id = target.project_id
break
case 'version':
params.version_id = target.version_id
break
case 'thread_message':
params.thread_message_id = target.thread_message_id
break
case 'report':
params.report_id = target.report_id
break
}
return params
}
export class LabrinthImagesV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_images_v3'
}
public uploadImage(
file: File | Blob,
ext: Labrinth.Images.v3.ImageExtension,
target: Labrinth.Images.v3.UploadImageParams,
): UploadHandle<Labrinth.Images.v3.UploadedImage> {
return this.client.upload<Labrinth.Images.v3.UploadedImage>('/image', {
api: 'labrinth',
version: 3,
file,
params: buildImageQueryParams(ext, target),
})
}
}

View File

@ -0,0 +1,30 @@
export * from './analytics/v3'
export * from './attribution/internal'
export * from './auth/internal'
export * from './auth/v2'
export * from './collections'
export * from './content/v3'
export * from './external-projects/internal'
export * from './friends/v3'
export * from './globals/internal'
export * from './images/v3'
export * from './limits/v3'
export * from './moderation/internal'
export * from './notifications/v2'
export * from './oauth/internal'
export * from './organizations/v3'
export * from './pats/v2'
export * from './payout/v3'
export * from './payouts/v3'
export * from './projects/v2'
export * from './projects/v3'
export * from './reports/v3'
export * from './server-ping/internal'
export * from './sessions/v2'
export * from './state'
export * from './tech-review/internal'
export * from './threads/v3'
export * from './users/v2'
export * from './users/v3'
export * from './versions/v2'
export * from './versions/v3'

View File

@ -0,0 +1,41 @@
import { AbstractModule } from '../../../core/abstract-module.js'
import type { Labrinth } from '../types'
export class LabrinthLimitsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_limits_v3'
}
/**
* Get project creation limits for the authenticated user.
*/
public async getProjectLimits(): Promise<Labrinth.Limits.v3.UserLimits> {
return this.client.request<Labrinth.Limits.v3.UserLimits>('/limits/projects', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get organization creation limits for the authenticated user.
*/
public async getOrganizationLimits(): Promise<Labrinth.Limits.v3.UserLimits> {
return this.client.request<Labrinth.Limits.v3.UserLimits>('/limits/organizations', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get collection creation limits for the authenticated user.
*/
public async getCollectionLimits(): Promise<Labrinth.Limits.v3.UserLimits> {
return this.client.request<Labrinth.Limits.v3.UserLimits>('/limits/collections', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
}

View File

@ -0,0 +1,99 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthModerationInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_moderation_internal'
}
public async getProjects(
params: Labrinth.Moderation.Internal.ProjectsRequest = {},
): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
return this.client.request<Labrinth.Moderation.Internal.ProjectsResponse>(
'/moderation/projects',
{
api: 'labrinth',
version: 'internal',
method: 'GET',
params,
},
)
}
public async getProjectIds(
params: Omit<Labrinth.Moderation.Internal.ProjectsRequest, 'count' | 'offset'> = {},
): Promise<Labrinth.Moderation.Internal.ProjectIdsResponse> {
return this.client.request<Labrinth.Moderation.Internal.ProjectIdsResponse>(
'/moderation/projects/ids',
{
api: 'labrinth',
version: 'internal',
method: 'GET',
params,
},
)
}
public async acquireLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
return this.client.request<Labrinth.Moderation.Internal.LockAcquireResponse>(
`/moderation/lock/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'POST',
},
)
}
public async overrideLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
return this.client.request<Labrinth.Moderation.Internal.LockAcquireResponse>(
`/moderation/lock/${projectId}/override`,
{
api: 'labrinth',
version: 'internal',
method: 'POST',
},
)
}
public async releaseLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.ReleaseLockResponse> {
return this.client.request<Labrinth.Moderation.Internal.ReleaseLockResponse>(
`/moderation/lock/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'DELETE',
},
)
}
public async checkLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.LockStatusResponse> {
return this.client.request<Labrinth.Moderation.Internal.LockStatusResponse>(
`/moderation/lock/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
public async setProjectJudgements(
judgements: Labrinth.Moderation.Internal.ProjectJudgements,
): Promise<void> {
return this.client.request<void>('/moderation/project', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: judgements,
})
}
}

View File

@ -0,0 +1,128 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthNotificationsV2Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_notifications_v2'
}
/**
* Get all notifications for a user
*
* @param userId - The user's ID
* @returns Promise resolving to the user's notifications
*
* @example
* ```typescript
* const notifications = await client.labrinth.notifications_v2.getUserNotifications('user123')
* ```
*/
public async getUserNotifications(
userId: string,
): Promise<Labrinth.Notifications.v2.Notification[]> {
return this.client.request<Labrinth.Notifications.v2.Notification[]>(
`/user/${userId}/notifications`,
{
api: 'labrinth',
version: 2,
method: 'GET',
},
)
}
/**
* Get multiple notifications by their IDs
*
* @param ids - Array of notification IDs
* @returns Promise resolving to an array of notifications
*
* @example
* ```typescript
* const notifications = await client.labrinth.notifications_v2.getMultiple(['id1', 'id2'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Notifications.v2.Notification[]> {
return this.client.request<Labrinth.Notifications.v2.Notification[]>(
`/notifications?ids=${encodeURIComponent(JSON.stringify(ids))}`,
{
api: 'labrinth',
version: 2,
method: 'GET',
},
)
}
/**
* Mark a single notification as read
*
* @param id - Notification ID
*
* @example
* ```typescript
* await client.labrinth.notifications_v2.markAsRead('notif123')
* ```
*/
public async markAsRead(id: string): Promise<void> {
return this.client.request(`/notification/${id}`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
})
}
/**
* Mark multiple notifications as read
*
* @param ids - Array of notification IDs to mark as read
*
* @example
* ```typescript
* await client.labrinth.notifications_v2.markMultipleAsRead(['id1', 'id2'])
* ```
*/
public async markMultipleAsRead(ids: string[]): Promise<void> {
return this.client.request(`/notifications`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
params: { ids: JSON.stringify([...new Set(ids)]) },
})
}
/**
* Delete a single notification
*
* @param id - Notification ID
*
* @example
* ```typescript
* await client.labrinth.notifications_v2.delete('notif123')
* ```
*/
public async delete(id: string): Promise<void> {
return this.client.request(`/notification/${id}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
/**
* Delete multiple notifications
*
* @param ids - Array of notification IDs to delete
*
* @example
* ```typescript
* await client.labrinth.notifications_v2.deleteMultiple(['id1', 'id2'])
* ```
*/
public async deleteMultiple(ids: string[]): Promise<void> {
return this.client.request(`/notifications`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
params: { ids: JSON.stringify([...new Set(ids)]) },
})
}
}

View File

@ -0,0 +1,208 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle } from '../../../types/upload'
import type { Labrinth } from '../types'
export class LabrinthOAuthInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_oauth_internal'
}
/**
* Get a user's OAuth applications
*
* @param userId - The user's ID
* @returns Promise resolving to an array of the user's OAuth clients
*/
public async getUserApps(userId: string): Promise<Labrinth.OAuth.Internal.OAuthClient[]> {
return this.client.request<Labrinth.OAuth.Internal.OAuthClient[]>(
`/user/${userId}/oauth_apps`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Get a single OAuth application by ID
*
* @param id - The OAuth client ID
* @returns Promise resolving to the OAuth client
*/
public async getApp(id: string): Promise<Labrinth.OAuth.Internal.OAuthClient> {
return this.client.request<Labrinth.OAuth.Internal.OAuthClient>(`/oauth/app/${id}`, {
api: 'labrinth',
version: 'internal',
method: 'GET',
})
}
/**
* Get multiple OAuth applications by their IDs
*
* @param ids - Array of OAuth client IDs
* @returns Promise resolving to an array of OAuth clients
*/
public async getApps(ids: string[]): Promise<Labrinth.OAuth.Internal.OAuthClient[]> {
return this.client.request<Labrinth.OAuth.Internal.OAuthClient[]>(
`/oauth/apps?ids=${encodeURIComponent(JSON.stringify(ids))}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Create a new OAuth application
*
* @param data - The OAuth app creation data
* @returns Promise resolving to the created OAuth client with its client secret
*/
public async createApp(
data: Labrinth.OAuth.Internal.CreateOAuthAppRequest,
): Promise<Labrinth.OAuth.Internal.OAuthClientCreationResult> {
return this.client.request<Labrinth.OAuth.Internal.OAuthClientCreationResult>(`/oauth/app`, {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
})
}
/**
* Edit an existing OAuth application
*
* @param id - The OAuth client ID
* @param data - The fields to update
*/
public async editApp(
id: string,
data: Labrinth.OAuth.Internal.EditOAuthAppRequest,
): Promise<void> {
return this.client.request(`/oauth/app/${id}`, {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
})
}
/**
* Delete an OAuth application
*
* @param id - The OAuth client ID
*/
public async deleteApp(id: string): Promise<void> {
return this.client.request(`/oauth/app/${id}`, {
api: 'labrinth',
version: 'internal',
method: 'DELETE',
})
}
/**
* Update the icon for an OAuth application
*
* @param id - The OAuth client ID
* @param file - The icon file
* @param ext - The file extension (e.g. 'png', 'jpeg')
* @returns UploadHandle for progress tracking and cancellation
*/
public uploadAppIcon(id: string, file: File | Blob, ext: string): UploadHandle<void> {
return this.client.upload<void>(`/oauth/app/${id}/icon`, {
api: 'labrinth',
version: 'internal',
file,
params: { ext },
})
}
/**
* Get the current user's OAuth authorizations
*
* @returns Promise resolving to an array of OAuth client authorizations
*/
public async getAuthorizations(): Promise<Labrinth.OAuth.Internal.OAuthClientAuthorization[]> {
return this.client.request<Labrinth.OAuth.Internal.OAuthClientAuthorization[]>(
`/oauth/authorizations`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Revoke an OAuth authorization for a client
*
* @param clientId - The OAuth client ID to revoke
*/
public async revokeAuthorization(clientId: string): Promise<void> {
return this.client.request(`/oauth/authorizations`, {
api: 'labrinth',
version: 'internal',
method: 'DELETE',
params: { client_id: clientId },
})
}
/**
* Initialize an OAuth authorization flow
*
* Returns either an OAuthClientAccessRequest (if user needs to approve)
* or a redirect URL string (if already authorized).
*
* @param params - The OAuth query parameters
* @returns Promise resolving to an access request object or redirect URL string
*/
public async authorize(params: {
client_id: string
redirect_uri: string
scope: string
state?: string
}): Promise<Labrinth.OAuth.Internal.OAuthClientAccessRequest | string> {
return this.client.request<Labrinth.OAuth.Internal.OAuthClientAccessRequest | string>(
`/oauth/authorize`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
params: params as Record<string, string>,
},
)
}
/**
* Accept an OAuth authorization request
*
* @param data - The flow ID to accept
* @returns Promise resolving to a redirect URL string
*/
public async accept(data: Labrinth.OAuth.Internal.AcceptRejectRequest): Promise<string> {
return this.client.request<string>(`/oauth/accept`, {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
})
}
/**
* Reject an OAuth authorization request
*
* @param data - The flow ID to reject
* @returns Promise resolving to a redirect URL string
*/
public async reject(data: Labrinth.OAuth.Internal.AcceptRejectRequest): Promise<string> {
return this.client.request<string>(`/oauth/reject`, {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
})
}
}

View File

@ -0,0 +1,122 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthOrganizationsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_organizations_v3'
}
/**
* Get an organization by ID or slug
*
* @param idOrSlug - Organization ID or slug
* @returns Promise resolving to the organization data
*
* @example
* ```typescript
* const org = await client.labrinth.organizations_v3.get('my-org')
* ```
*/
public async get(idOrSlug: string): Promise<Labrinth.Organizations.v3.Organization> {
return this.client.request<Labrinth.Organizations.v3.Organization>(
`/organization/${idOrSlug}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Get an organization's projects
*
* @param idOrSlug - Organization ID or slug
* @returns Promise resolving to the organization's projects
*
* @example
* ```typescript
* const projects = await client.labrinth.organizations_v3.getProjects('my-org')
* ```
*/
public async getProjects(idOrSlug: string): Promise<Labrinth.Projects.v3.Project[]> {
return this.client.request<Labrinth.Projects.v3.Project[]>(
`/organization/${idOrSlug}/projects`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Get multiple organizations by their IDs
*
* @param ids - Array of organization IDs
* @returns Promise resolving to an array of organizations
*
* @example
* ```typescript
* const orgs = await client.labrinth.organizations_v3.getMultiple(['id1', 'id2'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Organizations.v3.Organization[]> {
return this.client.request<Labrinth.Organizations.v3.Organization[]>(
`/organizations?ids=${encodeURIComponent(JSON.stringify(ids))}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Add a project to an organization
*
* @param idOrSlug - Organization ID or slug
* @param request - The project to add
*
* @example
* ```typescript
* await client.labrinth.organizations_v3.addProject('my-org', { project_id: 'AABBCCDD' })
* ```
*/
public async addProject(
idOrSlug: string,
request: Labrinth.Organizations.v3.AddProjectRequest,
): Promise<void> {
return this.client.request(`/organization/${idOrSlug}/projects`, {
api: 'labrinth',
version: 3,
method: 'POST',
body: request,
})
}
/**
* Remove a project from an organization
*
* @param idOrSlug - Organization ID or slug
* @param projectId - Project ID to remove
* @param data - Request body containing the new_owner user ID
*
* @example
* ```typescript
* await client.labrinth.organizations_v3.removeProject('my-org', 'proj123', { new_owner: 'user456' })
* ```
*/
public async removeProject(
idOrSlug: string,
projectId: string,
data: Labrinth.Organizations.v3.RemoveProjectRequest,
): Promise<void> {
return this.client.request(`/organization/${idOrSlug}/projects/${projectId}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
body: data,
})
}
}

View File

@ -0,0 +1,66 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthPatsV2Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_pats_v2'
}
/**
* Get all personal access tokens for the authenticated user
*
* @returns Promise resolving to an array of PATs
*/
public async list(): Promise<Labrinth.Pats.v2.PersonalAccessToken[]> {
return this.client.request<Labrinth.Pats.v2.PersonalAccessToken[]>('/pat', {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
/**
* Create a new personal access token
*
* @param data - The PAT creation request data
* @returns Promise resolving to the newly created PAT (includes access_token)
*/
public async create(
data: Labrinth.Pats.v2.CreatePatRequest,
): Promise<Labrinth.Pats.v2.PersonalAccessToken> {
return this.client.request<Labrinth.Pats.v2.PersonalAccessToken>('/pat', {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Modify an existing personal access token
*
* @param id - The PAT ID
* @param data - The fields to update
*/
public async modify(id: string, data: Labrinth.Pats.v2.ModifyPatRequest): Promise<void> {
return this.client.request(`/pat/${id}`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
body: data,
})
}
/**
* Delete a personal access token
*
* @param id - The PAT ID
*/
public async delete(id: string): Promise<void> {
return this.client.request(`/pat/${id}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
}

View File

@ -0,0 +1,115 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Override, RawDecimal } from '../../../utils/types'
import type { Labrinth } from '../types'
type RawPayoutBalance = Override<
Labrinth.Payout.v3.PayoutBalance,
{
available: RawDecimal
withdrawn_lifetime: RawDecimal
withdrawn_ytd: RawDecimal
pending: RawDecimal
dates: Record<string, RawDecimal>
}
>
type RawTransactionItem =
| Override<
Extract<Labrinth.Payout.v3.TransactionItem, { type: 'withdrawal' }>,
{
amount: RawDecimal
fee: RawDecimal | null
}
>
| Override<
Extract<Labrinth.Payout.v3.TransactionItem, { type: 'payout_available' }>,
{
amount: RawDecimal
}
>
export class LabrinthPayoutV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_payout_v3'
}
/**
* Get the authenticated user's payout balance
*
* @returns Promise resolving to the user's payout balance
*/
public async getBalance(): Promise<Labrinth.Payout.v3.PayoutBalance> {
const balance = await this.client.request<RawPayoutBalance>('/payout/balance', {
api: 'labrinth',
version: 3,
method: 'GET',
})
return {
...balance,
available: Number(balance.available),
withdrawn_lifetime: Number(balance.withdrawn_lifetime),
withdrawn_ytd: Number(balance.withdrawn_ytd),
pending: Number(balance.pending),
dates: Object.fromEntries(
Object.entries(balance.dates).map(([date, amount]) => [date, Number(amount)]),
),
}
}
/**
* Get the authenticated user's transaction history (withdrawals and payouts)
*
* @returns Promise resolving to an array of transaction items
*/
public async getHistory(): Promise<Labrinth.Payout.v3.TransactionItem[]> {
const history = await this.client.request<RawTransactionItem[]>('/payout/history', {
api: 'labrinth',
version: 3,
method: 'GET',
})
return history.map((transaction) => {
if (transaction.type === 'withdrawal') {
return {
...transaction,
amount: Number(transaction.amount),
fee: transaction.fee === null ? null : Number(transaction.fee),
}
}
return {
...transaction,
amount: Number(transaction.amount),
}
})
}
/**
* Get available payout methods, optionally filtered by country
*
* @param country - Optional ISO country code to filter methods by supported countries
* @returns Promise resolving to an array of payout methods
*/
public async getMethods(country?: string): Promise<Labrinth.Payout.v3.PayoutMethod[]> {
return this.client.request<Labrinth.Payout.v3.PayoutMethod[]>('/payout/methods', {
api: 'labrinth',
version: 3,
method: 'GET',
params: country ? { country } : undefined,
})
}
/**
* Cancel a pending payout
*
* @param id - The payout ID to cancel
*/
public async cancel(id: string): Promise<void> {
return this.client.request<void>(`/payout/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}

View File

@ -0,0 +1,26 @@
import { AbstractModule } from '../../../core/abstract-module.js'
import type { Labrinth } from '../types'
export class LabrinthPayoutsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_payouts_v3'
}
/**
* Get platform revenue data.
*
* @param params - Optional start/end date filters
* @returns Promise resolving to platform revenue data
*/
public async getPlatformRevenue(params?: {
start?: string
end?: string
}): Promise<Labrinth.Payouts.v3.RevenueResponse> {
return this.client.request<Labrinth.Payouts.v3.RevenueResponse>('/payout/platform_revenue', {
api: 'labrinth',
version: 3,
method: 'GET',
params: params as Record<string, string>,
})
}
}

View File

@ -0,0 +1,303 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthProjectsV2Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_projects_v2'
}
/**
* Get a project by ID or slug
*
* @param id - Project ID or slug (e.g., 'sodium' or 'AANobbMI')
* @returns Promise resolving to the project data
*
* @example
* ```typescript
* const project = await client.labrinth.projects_v2.get('sodium')
* console.log(project.title) // "Sodium"
* ```
*/
public async get(id: string): Promise<Labrinth.Projects.v2.Project> {
return this.client.request<Labrinth.Projects.v2.Project>(`/project/${id}`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
/**
* Check that a project slug or ID exists and return its canonical project ID.
*
* @param idOrSlug - Project ID or slug (e.g. `sodium` or `AANobbMI`)
*/
public async check(idOrSlug: string): Promise<Labrinth.Projects.v2.ProjectCheckResponse> {
const encoded = encodeURIComponent(idOrSlug)
return this.client.request<Labrinth.Projects.v2.ProjectCheckResponse>(
`/project/${encoded}/check`,
{
api: 'labrinth',
version: 2,
method: 'GET',
},
)
}
/**
* Get multiple projects by IDs
*
* @param ids - Array of project IDs or slugs
* @returns Promise resolving to array of projects
*
* @example
* ```typescript
* const projects = await client.labrinth.projects_v2.getMultiple(['sodium', 'lithium', 'phosphor'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Projects.v2.Project[]> {
return this.client.request<Labrinth.Projects.v2.Project[]>(`/projects`, {
api: 'labrinth',
version: 2,
method: 'GET',
params: { ids: JSON.stringify(ids) },
})
}
/**
* Search projects
*
* @param params - Search parameters (query, facets, filters, etc.)
* @returns Promise resolving to search results
*
* @example
* ```typescript
* const results = await client.labrinth.projects_v2.search({
* query: 'optimization',
* facets: [['categories:optimization'], ['project_type:mod']],
* limit: 20
* })
* ```
*/
public async search(
params: Labrinth.Projects.v2.ProjectSearchParams,
): Promise<Labrinth.Projects.v2.SearchResult> {
return this.client.request<Labrinth.Projects.v2.SearchResult>(`/search`, {
api: 'labrinth',
version: 2,
method: 'GET',
params: {
...params,
facets: params.facets ? JSON.stringify(params.facets) : undefined,
new_filters: params.new_filters ?? undefined,
},
})
}
/**
* Edit a project
*
* @param id - Project ID or slug
* @param data - Project update data
*
* @example
* ```typescript
* await client.labrinth.projects_v2.edit('sodium', {
* description: 'Updated description'
* })
* ```
*/
public async edit(id: string, data: Partial<Labrinth.Projects.v2.Project>): Promise<void> {
return this.client.request(`/project/${id}`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
body: data,
})
}
/**
* Delete a project
*
* @param id - Project ID or slug
*
* @example
* ```typescript
* await client.labrinth.projects_v2.delete('my-project')
* ```
*/
public async delete(id: string): Promise<void> {
return this.client.request(`/project/${id}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
/**
* Get dependencies for a project
*
* @param id - Project ID or slug
* @returns Promise resolving to dependency info (projects and versions)
*
* @example
* ```typescript
* const deps = await client.labrinth.projects_v2.getDependencies('sodium')
* console.log(deps.projects) // dependent projects
* console.log(deps.versions) // dependent versions
* ```
*/
public async getDependencies(id: string): Promise<Labrinth.Projects.v2.DependencyInfo> {
return this.client.request<Labrinth.Projects.v2.DependencyInfo>(`/project/${id}/dependencies`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
/**
* Create a gallery image for a project
*
* @param id - Project ID or slug
* @param file - Image file to upload
* @param options - Gallery image options
*
* @example
* ```typescript
* await client.labrinth.projects_v2.createGalleryImage('sodium', imageFile, {
* featured: true,
* title: 'Screenshot 1',
* description: 'Main menu with Sodium enabled'
* })
* ```
*/
public async createGalleryImage(
id: string,
file: Blob,
options: {
ext: string
featured: boolean
title?: string
description?: string
ordering?: number
},
): Promise<void> {
const params: Record<string, string> = {
ext: options.ext,
featured: String(options.featured),
}
if (options.title) params.title = options.title
if (options.description) params.description = options.description
if (options.ordering !== undefined) params.ordering = String(options.ordering)
return this.client.request(`/project/${id}/gallery`, {
api: 'labrinth',
version: 2,
method: 'POST',
params,
body: file,
})
}
/**
* Edit a gallery image for a project
*
* @param id - Project ID or slug
* @param url - URL of the existing gallery image to edit
* @param options - Gallery image options to update
*
* @example
* ```typescript
* await client.labrinth.projects_v2.editGalleryImage('sodium', 'https://cdn.modrinth.com/...', {
* featured: false,
* title: 'Updated title'
* })
* ```
*/
public async editGalleryImage(
id: string,
url: string,
options: {
featured: boolean
title?: string
description?: string
ordering?: number
},
): Promise<void> {
const params: Record<string, string> = {
url,
featured: String(options.featured),
}
if (options.title) params.title = options.title
if (options.description) params.description = options.description
if (options.ordering !== undefined) params.ordering = String(options.ordering)
return this.client.request(`/project/${id}/gallery`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
params,
})
}
/**
* Delete a gallery image from a project
*
* @param id - Project ID or slug
* @param url - URL of the gallery image to delete
*
* @example
* ```typescript
* await client.labrinth.projects_v2.deleteGalleryImage('sodium', 'https://cdn.modrinth.com/...')
* ```
*/
public async deleteGalleryImage(id: string, url: string): Promise<void> {
return this.client.request(`/project/${id}/gallery`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
params: { url },
})
}
/**
* Get random projects
*
* @param count - Number of random projects to return
* @returns Promise resolving to an array of random projects
*/
public async getRandom(count: number): Promise<Labrinth.Projects.v2.Project[]> {
return this.client.request<Labrinth.Projects.v2.Project[]>('/projects_random', {
api: 'labrinth',
version: 2,
method: 'GET',
params: { count: String(count) },
})
}
/**
* Bulk edit multiple projects at once
*
* @param ids - Array of project IDs to edit
* @param data - Fields to update across all specified projects
*
* @example
* ```typescript
* await client.labrinth.projects_v2.bulkEdit(['id1', 'id2'], {
* issues_url: 'https://github.com/issues',
* source_url: null,
* })
* ```
*/
public async bulkEdit(
ids: string[],
data: Labrinth.Projects.v2.BulkEditProjectRequest,
): Promise<void> {
return this.client.request(`/projects`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
params: { ids: JSON.stringify(ids) },
body: data,
})
}
}

View File

@ -0,0 +1,201 @@
import { AbstractModule } from '../../../core/abstract-module'
import { ModrinthApiError } from '../../../core/errors'
import type { Labrinth } from '../types'
export class LabrinthProjectsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_projects_v3'
}
/**
* Get a project by ID or slug (v3)
*
* @param id - Project ID or slug (e.g., 'sodium' or 'AANobbMI')
* @returns Promise resolving to the v3 project data
*
* @example
* ```typescript
* const project = await client.labrinth.projects_v3.get('sodium')
* console.log(project.project_types) // v3 field
* ```
*/
public async get(id: string): Promise<Labrinth.Projects.v3.Project> {
return this.client.request<Labrinth.Projects.v3.Project>(`/project/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get a project's dependencies (v3)
*
* Returns all projects and versions that are dependencies of this project's versions.
*
* @param id - Project ID or slug
* @returns Promise resolving to dependency data with projects and versions
*
* @example
* ```typescript
* const deps = await client.labrinth.projects_v3.getDependencies('sodium')
* console.log(deps.projects) // Array of project objects
* console.log(deps.versions) // Array of version objects
* ```
*/
public async getDependencies(id: string): Promise<Labrinth.Projects.v3.ProjectDependencies> {
return this.client.request<Labrinth.Projects.v3.ProjectDependencies>(
`/project/${id}/dependencies`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Get multiple projects by IDs (v3)
*
* @param ids - Array of project IDs or slugs
* @returns Promise resolving to array of v3 projects
*
* @example
* ```typescript
* const projects = await client.labrinth.projects_v3.getMultiple(['sodium', 'lithium'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Projects.v3.Project[]> {
return this.client.request<Labrinth.Projects.v3.Project[]>(`/projects`, {
api: 'labrinth',
version: 3,
method: 'GET',
params: { ids: JSON.stringify(ids) },
})
}
/**
* Edit a project (v3)
*
* @param id - Project ID or slug
* @param data - Project update data (v3 fields)
*
* @example
* ```typescript
* await client.labrinth.projects_v3.edit('sodium', {
* environment: 'client_and_server'
* })
* ```
*/
public async edit(id: string, data: Labrinth.Projects.v3.EditProjectRequest): Promise<void> {
return this.client.request(`/project/${id}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Get the organization that owns a project
*
* @param id - Project ID or slug
* @returns Promise resolving to the organization data, or null if the project is not owned by an organization
*/
public async getOrganization(id: string): Promise<Labrinth.Projects.v3.Organization | null> {
try {
return await this.client.request<Labrinth.Projects.v3.Organization>(
`/project/${id}/organization`,
{ api: 'labrinth', version: 3, method: 'GET' },
)
} catch (error) {
// 404 means the project is not owned by an organization
if (error instanceof ModrinthApiError && error.statusCode === 404) {
return null
}
throw error
}
}
/**
* Get the team members of a project
*
* @param id - Project ID or slug
* @returns Promise resolving to an array of team members
*/
public async getMembers(id: string): Promise<Labrinth.Projects.v3.TeamMember[]> {
return this.client.request<Labrinth.Projects.v3.TeamMember[]>(`/project/${id}/members`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
public async createServerProject(
data: Labrinth.Projects.v3.CreateServerProjectRequest,
): Promise<Labrinth.Projects.v3.Project> {
return this.client.request<Labrinth.Projects.v3.Project>(`/project`, {
api: 'labrinth',
version: 3,
method: 'PUT',
body: data,
})
}
/**
* Delete a project
*
* @param id - Project ID or slug
*
* @example
* ```typescript
* await client.labrinth.projects_v3.deleteProject('my-project')
* ```
*/
public async deleteProject(id: string): Promise<void> {
return this.client.request(`/project/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
/**
* Change the icon of a project
*
* @param id - Project ID or slug
* @param file - Image file to upload
* @param ext - File extension (e.g., 'png', 'jpeg', 'gif', 'webp')
*
* @example
* ```typescript
* await client.labrinth.projects_v3.changeIcon('sodium', imageFile, 'png')
* ```
*/
public async changeIcon(id: string, file: Blob, ext: string): Promise<void> {
return this.client.request(`/project/${id}/icon`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
params: { ext },
body: file,
})
}
/**
* Delete the icon of a project
*
* @param id - Project ID or slug
*
* @example
* ```typescript
* await client.labrinth.projects_v3.deleteIcon('sodium')
* ```
*/
public async deleteIcon(id: string): Promise<void> {
return this.client.request(`/project/${id}/icon`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}

View File

@ -0,0 +1,141 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthReportsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_reports_v3'
}
/**
* Get a report by ID
*
* @param id - Report ID
* @returns Promise resolving to the report data
*
* @example
* ```typescript
* const report = await client.labrinth.reports_v3.get('abc123')
* ```
*/
public async get(id: string): Promise<Labrinth.Reports.v3.Report> {
return this.client.request<Labrinth.Reports.v3.Report>(`/report/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* List reports for the current user (or all reports if moderator)
*
* @param params - Optional query parameters for count, offset, and whether to show all reports
* @returns Promise resolving to an array of reports
*
* @example
* ```typescript
* const reports = await client.labrinth.reports_v3.list({ count: 100 })
* ```
*/
public async list(
params?: Labrinth.Reports.v3.ListReportsParams,
): Promise<Labrinth.Reports.v3.Report[]> {
const queryParams: Record<string, string> = {}
if (params?.count != null) queryParams.count = String(params.count)
if (params?.offset != null) queryParams.offset = String(params.offset)
if (params?.all != null) queryParams.all = String(params.all)
return this.client.request<Labrinth.Reports.v3.Report[]>(`/report`, {
api: 'labrinth',
version: 3,
method: 'GET',
params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
})
}
/**
* Get multiple reports by IDs
*
* @param ids - Array of report IDs
* @returns Promise resolving to an array of reports
*
* @example
* ```typescript
* const reports = await client.labrinth.reports_v3.getMultiple(['id1', 'id2'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Reports.v3.Report[]> {
return this.client.request<Labrinth.Reports.v3.Report[]>(
`/reports?ids=${encodeURIComponent(JSON.stringify(ids))}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Create a new report
*
* @param data - Report creation data
* @returns Promise resolving to the created report
*
* @example
* ```typescript
* const report = await client.labrinth.reports_v3.create({
* report_type: 'spam',
* item_id: 'project123',
* item_type: 'project',
* body: 'This project is spam',
* })
* ```
*/
public async create(
data: Labrinth.Reports.v3.CreateReportRequest,
): Promise<Labrinth.Reports.v3.Report> {
return this.client.request<Labrinth.Reports.v3.Report>(`/report`, {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
})
}
/**
* Edit a report
*
* @param id - Report ID
* @param data - Report edit data
*
* @example
* ```typescript
* await client.labrinth.reports_v3.edit('abc123', { closed: true })
* ```
*/
public async edit(id: string, data: Labrinth.Reports.v3.EditReportRequest): Promise<void> {
return this.client.request(`/report/${id}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete a report (moderator only)
*
* @param id - Report ID
*
* @example
* ```typescript
* await client.labrinth.reports_v3.delete('abc123')
* ```
*/
public async delete(id: string): Promise<void> {
return this.client.request(`/report/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}

View File

@ -0,0 +1,23 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthServerPingInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_server_ping_internal'
}
/**
* Ping a Minecraft Java server
* POST /_internal/server-ping/minecraft-java
*/
public async pingMinecraftJava(
request: Labrinth.ServerPing.Internal.MinecraftJavaPingRequest,
): Promise<void> {
return this.client.request<void>('/server-ping/minecraft-java', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: request,
})
}
}

View File

@ -0,0 +1,34 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthSessionsV2Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_sessions_v2'
}
/**
* List all sessions for the authenticated user
*
* @returns Promise resolving to an array of sessions
*/
public async list(): Promise<Labrinth.Sessions.v2.Session[]> {
return this.client.request<Labrinth.Sessions.v2.Session[]>('/session/list', {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
/**
* Delete (revoke) a session
*
* @param id - The session ID
*/
public async delete(id: string): Promise<void> {
return this.client.request(`/session/${id}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
}

View File

@ -0,0 +1,160 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthStateModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_state'
}
/**
* Build the complete generated state by fetching from multiple endpoints
*
* @returns Promise resolving to the generated state containing categories, loaders, etc.
*
* @example
* ```typescript
* const state = await client.labrinth.state.build()
* console.log(state.categories) // Available categories
* ```
*/
public async build(): Promise<Labrinth.State.GeneratedState> {
const errors: unknown[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleError = (err: any, defaultValue: any, endpoint: string) => {
console.error('Error fetching state data:', err)
errors.push({ endpoint, error: err })
return defaultValue
}
// TODO: as we add new modules, move these raw requests to actual
// abstractions
const [
categories,
loaders,
gameVersions,
donationPlatforms,
reportTypes,
homePageProjects,
homePageSearch,
homePageNotifs,
muralBankDetails,
iso3166Data,
payoutMethods,
globals,
] = await Promise.all([
// Tag endpoints
this.client
.request<Labrinth.Tags.v2.Category[]>('/tag/category', {
api: 'labrinth',
version: 2,
method: 'GET',
})
.catch((err) => handleError(err, [], '/v2/tag/category')),
this.client
.request<Labrinth.Tags.v2.Loader[]>('/tag/loader', {
api: 'labrinth',
version: 2,
method: 'GET',
})
.catch((err) => handleError(err, [], '/v2/tag/loader')),
this.client
.request<Labrinth.Tags.v2.GameVersion[]>('/tag/game_version', {
api: 'labrinth',
version: 2,
method: 'GET',
})
.catch((err) => handleError(err, [], '/v2/tag/game_version')),
this.client
.request<Labrinth.Tags.v2.DonationPlatform[]>('/tag/donation_platform', {
api: 'labrinth',
version: 2,
method: 'GET',
})
.catch((err) => handleError(err, [], '/v2/tag/donation_platform')),
this.client
.request<string[]>('/tag/report_type', { api: 'labrinth', version: 2, method: 'GET' })
.catch((err) => handleError(err, [], '/v2/tag/report_type')),
// Homepage data
this.client
.request<Labrinth.Projects.v2.Project[]>('/projects_random', {
api: 'labrinth',
version: 2,
method: 'GET',
params: { count: '60' },
})
.catch((err) => handleError(err, [], '/v2/projects_random')),
this.client
.request<Labrinth.Search.v2.SearchResults>('/search', {
api: 'labrinth',
version: 2,
method: 'GET',
params: { limit: '3', query: 'leave', index: 'relevance' },
})
.catch((err) => handleError(err, {} as Labrinth.Search.v2.SearchResults, '/v2/search')),
this.client
.request<Labrinth.Search.v2.SearchResults>('/search', {
api: 'labrinth',
version: 2,
method: 'GET',
params: { limit: '3', query: '', index: 'updated' },
})
.catch((err) => handleError(err, {} as Labrinth.Search.v2.SearchResults, '/v2/search')),
// Internal mural endpoints
this.client
.request<{ bankDetails: Record<string, { bankNames: string[] }> }>('/mural/bank-details', {
api: 'labrinth',
version: 'internal',
method: 'GET',
})
.catch((err) => handleError(err, null, '/_internal/mural/bank-details')),
// ISO3166 country and subdivision data
this.client.iso3166.data
.build()
.catch((err) => handleError(err, { countries: [], subdivisions: {} }, 'iso3166/data')),
// Payout methods for tremendous ID mapping
this.client
.request<Labrinth.State.PayoutMethodInfo[]>('/payout/methods', {
api: 'labrinth',
version: 3,
method: 'GET',
})
.catch((err) => handleError(err, [], '/v3/payout/methods')),
// Global configuration
this.client
.request<{ tax_compliance_thresholds: Record<string, number> }>('/globals', {
api: 'labrinth',
version: 'internal',
method: 'GET',
})
.catch((err) => handleError(err, null, '/_internal/globals')),
])
const tremendousIdMap = Object.fromEntries(
(payoutMethods as Labrinth.State.PayoutMethodInfo[])
.filter((m) => m.type === 'tremendous')
.map((m) => [m.id, { name: m.name, image_url: m.image_logo_url }]),
)
return {
categories,
loaders,
gameVersions,
donationPlatforms,
reportTypes,
homePageProjects,
homePageSearch,
homePageNotifs,
muralBankDetails: muralBankDetails?.bankDetails,
tremendousIdMap,
countries: iso3166Data.countries,
subdivisions: iso3166Data.subdivisions,
taxComplianceThresholds: globals?.tax_compliance_thresholds,
errors,
}
}
}

View File

@ -0,0 +1,29 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthTagsV2Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_tags_v2'
}
/**
* Get license text by SPDX identifier
*
* @param licenseId - SPDX license identifier (e.g., 'MIT', 'Apache-2.0')
* @returns Promise resolving to the license title and body text
*
* @example
* ```typescript
* const license = await client.labrinth.tags_v2.getLicenseText('MIT')
* console.log(license.title) // "MIT License"
* console.log(license.body) // full license text
* ```
*/
public async getLicenseText(licenseId: string): Promise<Labrinth.Tags.v2.LicenseText> {
return this.client.request<Labrinth.Tags.v2.LicenseText>(`/tag/license/${licenseId}`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
}

View File

@ -0,0 +1,101 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthTeamsV2Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_teams_v2'
}
/**
* Add a member to a team
*
* @param teamId - Team ID
* @param data - New member data including user_id
*
* @example
* ```typescript
* await client.labrinth.teams_v2.addMember('team123', { user_id: 'user456' })
* ```
*/
public async addMember(
teamId: string,
data: Labrinth.Teams.v2.AddTeamMemberRequest,
): Promise<void> {
return this.client.request(`/team/${teamId}/members`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Edit a team member
*
* @param teamId - Team ID
* @param userId - User ID of the member to edit
* @param data - Member update data
*
* @example
* ```typescript
* await client.labrinth.teams_v2.editMember('team123', 'user456', {
* role: 'Developer',
* permissions: 0b111,
* })
* ```
*/
public async editMember(
teamId: string,
userId: string,
data: Labrinth.Teams.v2.EditTeamMemberRequest,
): Promise<void> {
return this.client.request(`/team/${teamId}/members/${userId}`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
body: data,
})
}
/**
* Remove a member from a team
*
* @param teamId - Team ID
* @param userId - User ID of the member to remove
*
* @example
* ```typescript
* await client.labrinth.teams_v2.removeMember('team123', 'user456')
* ```
*/
public async removeMember(teamId: string, userId: string): Promise<void> {
return this.client.request(`/team/${teamId}/members/${userId}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
/**
* Transfer team ownership to another member
*
* @param teamId - Team ID
* @param data - Transfer data including the new owner's user_id
*
* @example
* ```typescript
* await client.labrinth.teams_v2.transferOwnership('team123', { user_id: 'user456' })
* ```
*/
public async transferOwnership(
teamId: string,
data: Labrinth.Teams.v2.TransferOwnershipRequest,
): Promise<void> {
return this.client.request(`/team/${teamId}/owner`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
body: data,
})
}
}

View File

@ -0,0 +1,31 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthTeamsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_teams_v3'
}
/**
* Get multiple teams by their IDs
*
* @param ids - Array of team IDs
* @returns Promise resolving to an array of team member arrays (one per team)
*
* @example
* ```typescript
* const teams = await client.labrinth.teams_v3.getMultiple(['team1', 'team2'])
* // teams[0] = members of team1, teams[1] = members of team2
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Projects.v3.TeamMember[][]> {
return this.client.request<Labrinth.Projects.v3.TeamMember[][]>(
`/teams?ids=${encodeURIComponent(JSON.stringify(ids))}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
}

View File

@ -0,0 +1,188 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthTechReviewInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_tech_review_internal'
}
/**
* Search for projects awaiting technical review.
*
* Returns a flat list of file reports with associated project data, ownership
* information, and moderation threads provided as lookup maps.
*
* @param params - Search parameters including pagination, filters, and sorting
* @returns Response object containing reports array and lookup maps for projects, threads, and ownership
*
* @example
* ```typescript
* const response = await client.labrinth.tech_review_internal.searchProjects({
* limit: 20,
* page: 0,
* sort_by: 'created_asc',
* filter: {
* project_type: ['mod', 'modpack']
* }
* })
* // Access reports: response.reports
* // Access project by ID: response.projects[projectId]
* ```
*/
public async searchProjects(
params: Labrinth.TechReview.Internal.SearchProjectsRequest,
): Promise<Labrinth.TechReview.Internal.SearchResponse> {
return this.client.request<Labrinth.TechReview.Internal.SearchResponse>(
'/moderation/tech-review/search',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: params,
},
)
}
/**
* Get detailed information about a specific file report.
*
* @param reportId - The Delphi report ID
* @returns Full report with all issues and details
*
* @example
* ```typescript
* const report = await client.labrinth.tech_review_internal.getReport('report-123')
* console.log(report.file_name, report.issues.length)
* ```
*/
public async getReport(reportId: string): Promise<Labrinth.TechReview.Internal.FileReport> {
return this.client.request<Labrinth.TechReview.Internal.FileReport>(
`/moderation/tech-review/report/${reportId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Get detailed information about a specific issue.
*
* @param issueId - The issue ID
* @returns Issue with all its details
*
* @example
* ```typescript
* const issue = await client.labrinth.tech_review_internal.getIssue('issue-123')
* console.log(issue.issue_type, issue.status)
* ```
*/
public async getIssue(issueId: string): Promise<Labrinth.TechReview.Internal.FileIssue> {
return this.client.request<Labrinth.TechReview.Internal.FileIssue>(
`/moderation/tech-review/issue/${issueId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Update the status of a technical review issue detail.
*
* Allows moderators to mark an individual issue detail as safe (false positive) or unsafe (malicious).
*
* @param detailId - The ID of the issue detail to update
* @param data - The verdict for the detail
* @returns Promise that resolves when the update is complete
*/
public async updateIssueDetail(
detailId: string,
data: Labrinth.TechReview.Internal.UpdateIssueDetailRequest,
): Promise<void> {
return this.updateIssueDetails([{ detail_id: detailId, verdict: data.verdict }])
}
public async updateIssueDetails(
data: Labrinth.TechReview.Internal.UpdateIssueRequest[],
): Promise<void> {
return this.client.request<void>('/moderation/tech-review/issue-detail', {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
})
}
public async updateGlobalIssueDetails(
data: Labrinth.TechReview.Internal.UpdateGlobalIssueRequest[],
): Promise<void> {
return this.client.request<void>('/moderation/tech-review/global-issue-detail', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
})
}
public async searchGlobalIssueDetails(
params: Labrinth.TechReview.Internal.SearchGlobalIssueDetailsRequest,
): Promise<Labrinth.TechReview.Internal.SearchGlobalIssueDetailsResponse> {
return this.client.request<Labrinth.TechReview.Internal.SearchGlobalIssueDetailsResponse>(
'/moderation/tech-review/global-issue-detail/search',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: params,
},
)
}
public async getGlobalIssueDetail(
params: Labrinth.TechReview.Internal.GetGlobalIssueDetailRequest,
): Promise<Labrinth.TechReview.Internal.GetGlobalIssueDetailResponse> {
return this.client.request<Labrinth.TechReview.Internal.GetGlobalIssueDetailResponse>(
'/moderation/tech-review/global-issue-detail/local-traces',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: params,
},
)
}
public async submitProject(
projectId: string,
data: Labrinth.TechReview.Internal.SubmitProjectRequest,
): Promise<void> {
return this.client.request<void>(`/moderation/tech-review/submit/${projectId}`, {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
})
}
/**
* Get the project report and thread for a specific project.
*
* @param projectId - The project ID
* @returns The project report (may be null if no reports exist) and the moderation thread
*/
public async getProjectReport(
projectId: string,
): Promise<Labrinth.TechReview.Internal.ProjectReportResponse> {
return this.client.request<Labrinth.TechReview.Internal.ProjectReportResponse>(
`/moderation/tech-review/project/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
}

View File

@ -0,0 +1,95 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthThreadsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_threads_v3'
}
/**
* Get a thread by ID (v3)
*
* @param id - Thread ID
* @returns Promise resolving to the thread data
*
* @example
* ```typescript
* const thread = await client.labrinth.threads_v3.getThread('abc123')
* console.log(thread.messages)
* ```
*/
public async getThread(id: string): Promise<Labrinth.Threads.v3.Thread> {
return this.client.request<Labrinth.Threads.v3.Thread>(`/thread/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get multiple threads by IDs (v3)
*
* @param ids - Array of thread IDs
* @returns Promise resolving to an array of threads
*
* @example
* ```typescript
* const threads = await client.labrinth.threads_v3.getMultiple(['id1', 'id2'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Threads.v3.Thread[]> {
return this.client.request<Labrinth.Threads.v3.Thread[]>(
`/threads?ids=${encodeURIComponent(JSON.stringify(ids))}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Send a message to a thread (v3)
*
* @param id - Thread ID
* @param message - Message body to send
* @returns Promise resolving when message is sent
*
* @example
* ```typescript
* await client.labrinth.threads_v3.sendMessage('abc123', {
* body: { type: 'text', body: 'Hello!' }
* })
* ```
*/
public async sendMessage(
id: string,
message: Labrinth.Threads.v3.SendMessageRequest,
): Promise<void> {
return this.client.request(`/thread/${id}`, {
api: 'labrinth',
version: 3,
method: 'POST',
body: message,
})
}
/**
* Delete a message from a thread (v3)
*
* @param messageId - Message ID
* @returns Promise resolving when message is deleted
*
* @example
* ```typescript
* await client.labrinth.threads_v3.deleteMessage('msg123')
* ```
*/
public async deleteMessage(messageId: string): Promise<void> {
return this.client.request(`/message/${messageId}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,180 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthUsersV2Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_users_v2'
}
/**
* Get a user by ID or username
*
* @param idOrUsername - The user's ID or username
* @returns Promise resolving to the user data
*
* @example
* ```typescript
* const user = await client.labrinth.users_v2.get('my_user')
* ```
*/
public async get(idOrUsername: string): Promise<Labrinth.Users.v2.User> {
return this.client.request<Labrinth.Users.v2.User>(`/user/${idOrUsername}`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
/**
* Get multiple users by their IDs
*
* @param ids - Array of user IDs
* @returns Promise resolving to an array of users
*
* @example
* ```typescript
* const users = await client.labrinth.users_v2.getMultiple(['id1', 'id2'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Users.v2.User[]> {
return this.client.request<Labrinth.Users.v2.User[]>(
`/users?ids=${encodeURIComponent(JSON.stringify(ids))}`,
{
api: 'labrinth',
version: 2,
method: 'GET',
},
)
}
/**
* Get a user's projects
*
* @param idOrUsername - The user's ID or username
* @returns Promise resolving to an array of the user's projects
*
* @example
* ```typescript
* const projects = await client.labrinth.users_v2.getProjects('my_user')
* ```
*/
public async getProjects(idOrUsername: string): Promise<Labrinth.Projects.v2.Project[]> {
return this.client.request<Labrinth.Projects.v2.Project[]>(`/user/${idOrUsername}/projects`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
/**
* Get a user's organizations
*
* @param idOrUsername - The user's ID or username
* @returns Promise resolving to an array of the user's organizations
*
* @example
* ```typescript
* const orgs = await client.labrinth.users_v2.getOrganizations('my_user')
* ```
*/
public async getOrganizations(
idOrUsername: string,
): Promise<Labrinth.Organizations.v3.Organization[]> {
return this.client.request<Labrinth.Organizations.v3.Organization[]>(
`/user/${idOrUsername}/organizations`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Get a user's collections
*
* @param idOrUsername - The user's ID or username
* @returns Promise resolving to an array of the user's collections
*
* @example
* ```typescript
* const collections = await client.labrinth.users_v2.getCollections('my_user')
* ```
*/
public async getCollections(idOrUsername: string): Promise<Labrinth.Collections.Collection[]> {
return this.client.request<Labrinth.Collections.Collection[]>(
`/user/${idOrUsername}/collections`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Get a user's notifications
*
* @param idOrUsername - The user's ID or username
* @returns Promise resolving to an array of the user's notifications
*
* @example
* ```typescript
* const notifications = await client.labrinth.users_v2.getNotifications('my_user')
* ```
*/
public async getNotifications(
idOrUsername: string,
): Promise<Labrinth.Notifications.v2.Notification[]> {
return this.client.request<Labrinth.Notifications.v2.Notification[]>(
`/user/${idOrUsername}/notifications`,
{
api: 'labrinth',
version: 2,
method: 'GET',
},
)
}
/**
* Get projects a user follows
*
* @param idOrUsername - The user's ID or username
* @returns Promise resolving to an array of followed projects
*
* @example
* ```typescript
* const projects = await client.labrinth.users_v2.getFollowedProjects('my_user')
* ```
*/
public async getFollowedProjects(idOrUsername: string): Promise<Labrinth.Projects.v2.Project[]> {
return this.client.request<Labrinth.Projects.v2.Project[]>(`/user/${idOrUsername}/follows`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
/**
* Update a user
*
* @param idOrUsername - The user's ID or username
* @param data - Fields to update
*
* @example
* ```typescript
* await client.labrinth.users_v2.patch('my_user', { role: 'admin' })
* ```
*/
public async patch(
idOrUsername: string,
data: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'role'>>,
): Promise<void> {
return this.client.request(`/user/${idOrUsername}`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
body: data,
})
}
}

View File

@ -0,0 +1,79 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthUsersV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_users_v3'
}
/**
* Get the authenticated user.
* GET /v3/user
*/
public async getAuthenticated(): Promise<Labrinth.Users.v3.User> {
return this.client.request<Labrinth.Users.v3.User>('/user', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get a user by ID or username
*
* @param idOrUsername - The user's ID or username
* @returns Promise resolving to the user data
*
* GET /v3/user/{id}
*/
public async get(idOrUsername: string): Promise<Labrinth.Users.v3.User> {
return this.client.request<Labrinth.Users.v3.User>(
`/user/${encodeURIComponent(idOrUsername)}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* 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.
*
* @param idOrUsername - User ID or username. Must be the authenticated user.
*
* GET /v3/user/{id}/all-projects
*/
public async getAllProjects(
idOrUsername: string,
): Promise<Labrinth.Users.v3.AllProjectsResponse> {
return this.client.request<Labrinth.Users.v3.AllProjectsResponse>(
`/user/${encodeURIComponent(idOrUsername)}/all-projects`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
}

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

@ -0,0 +1,292 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle } from '../../../types/upload'
import type { Labrinth } from '../types'
const VERSION_UPLOAD_TIMEOUT_MS = 30 * 60 * 1000
export class LabrinthVersionsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_versions_v3'
}
/**
* Get versions for a project (v3)
*
* @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 v3 versions
*
* @example
* ```typescript
* const versions = await client.labrinth.versions_v3.getProjectVersions('sodium')
* const filteredVersions = await client.labrinth.versions_v3.getProjectVersions('sodium', {
* game_versions: ['1.20.1'],
* loaders: ['fabric']
* })
* console.log(versions[0].version_number)
* ```
*/
public async getProjectVersions(
id: string,
options?: Labrinth.Versions.v3.GetProjectVersionsParams,
): Promise<Labrinth.Versions.v3.Version[]> {
const params: Record<string, string | boolean> = {}
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.v3.Version[]>(`/project/${id}/version`, {
api: 'labrinth',
version: options?.apiVersion ?? 2,
method: 'GET',
params: Object.keys(params).length > 0 ? params : undefined,
})
}
/**
* Get a specific version by ID (v3)
*
* @param id - Version ID
* @returns Promise resolving to the v3 version data
*
* @example
* ```typescript
* const version = await client.labrinth.versions_v3.getVersion('DXtmvS8i')
* console.log(version.version_number)
* ```
*/
public async getVersion(id: string): Promise<Labrinth.Versions.v3.Version> {
return this.client.request<Labrinth.Versions.v3.Version>(`/version/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get multiple versions by IDs (v3)
*
* @param ids - Array of version IDs
* @returns Promise resolving to an array of v3 versions
*
* @example
* ```typescript
* const versions = await client.labrinth.versions_v3.getVersions(['DXtmvS8i', 'abc123'])
* console.log(versions[0].version_number)
* ```
*/
public async getVersions(ids: string[]): Promise<Labrinth.Versions.v3.Version[]> {
return this.client.request<Labrinth.Versions.v3.Version[]>(`/versions`, {
api: 'labrinth',
version: 3,
method: 'GET',
params: { ids: JSON.stringify(ids) },
})
}
/**
* Get a version from a project by version ID or number (v3)
*
* @param projectId - Project ID or slug
* @param versionId - Version ID or version number
* @returns Promise resolving to the v3 version data
*
* @example
* ```typescript
* const version = await client.labrinth.versions_v3.getVersionFromIdOrNumber('sodium', 'DXtmvS8i')
* const versionByNumber = await client.labrinth.versions_v3.getVersionFromIdOrNumber('sodium', '0.4.12')
* ```
*/
public async getVersionFromIdOrNumber(
projectId: string,
versionId: string,
): Promise<Labrinth.Versions.v3.Version> {
return this.client.request<Labrinth.Versions.v3.Version>(
`/project/${projectId}/version/${versionId}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Create a new version for a project (v3)
*
* Creates a new version on an existing project. At least one file must be
* attached unless the version is created as a draft.
*
* @param data - JSON metadata payload for the version (must include file_parts)
* @param files - Array of uploaded files, in the same order as `data.file_parts`
*
* @returns A promise resolving to the newly created version data
*
* @example
* ```ts
* const version = await client.labrinth.versions_v3.createVersion('sodium', {
* name: 'v0.5.0',
* version_number: '0.5.0',
* version_type: 'release',
* loaders: ['fabric'],
* game_versions: ['1.20.1'],
* project_id: 'sodium',
* file_parts: ['primary']
* }, [fileObject])
* ```
*/
public createVersion(
draftVersion: Labrinth.Versions.v3.DraftVersion,
versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
projectType: Labrinth.Projects.v2.ProjectType | null = null,
): UploadHandle<Labrinth.Versions.v3.Version> {
const formData = new FormData()
const files = versionFiles.map((vf) => vf.file)
const fileTypes = versionFiles.map((vf) => vf.fileType || null)
const fileParts = files.map((file, i) => {
return `${file.name}-${i === 0 ? 'primary' : i}`
})
const fileTypeMap = fileParts.reduce<Record<string, Labrinth.Versions.v3.FileType | null>>(
(acc, key, i) => {
acc[key] = fileTypes[i]
return acc
},
{},
)
const data: Labrinth.Versions.v3.CreateVersionRequest = {
project_id: draftVersion.project_id,
version_number: draftVersion.version_number,
name: draftVersion.name || draftVersion.version_number,
changelog: draftVersion.changelog,
dependencies: draftVersion.dependencies || [],
game_versions: draftVersion.game_versions,
version_type: draftVersion.version_type,
featured: !!draftVersion.featured,
file_parts: fileParts,
file_types: fileTypeMap,
primary_file: fileParts[0],
environment: draftVersion.environment,
loaders: draftVersion.loaders,
}
if (projectType === 'modpack') {
data.mrpack_loaders = draftVersion.loaders
data.loaders = ['mrpack']
}
formData.append('data', JSON.stringify(data))
files.forEach((file, i) => {
formData.append(fileParts[i], file, file.name)
})
return this.client.upload<Labrinth.Versions.v3.Version>(`/version`, {
api: 'labrinth',
version: 3,
formData,
timeout: VERSION_UPLOAD_TIMEOUT_MS,
})
}
/**
* Modify an existing version by ID (v3)
*
* Partially updates a versions metadata. Only JSON fields may be modified.
* To update files, use the separate "Add files to version" endpoint.
*
* @param versionId - The version ID to update
* @param data - PATCH metadata for this version (all fields optional)
*
* @returns A promise resolving to the updated version data
*
* @example
* ```ts
* const updated = await client.labrinth.versions_v3.modifyVersion('DXtmvS8i', {
* name: 'v1.0.1',
* changelog: 'Updated changelog',
* featured: true,
* status: 'listed'
* })
* ```
*/
public async modifyVersion(
versionId: string,
data: Labrinth.Versions.v3.ModifyVersionRequest,
): Promise<Labrinth.Versions.v3.Version> {
return this.client.request<Labrinth.Versions.v3.Version>(`/version/${versionId}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete a version by ID (v3)
*
* @param versionId - Version ID
*
* @example
* ```typescript
* await client.labrinth.versions_v3.deleteVersion('DXtmvS8i')
* ```
*/
public async deleteVersion(versionId: string): Promise<void> {
return this.client.request(`/version/${versionId}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
public addFilesToVersion(
versionId: string,
versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
): UploadHandle<Labrinth.Versions.v3.Version> {
const formData = new FormData()
const files = versionFiles.map((vf) => vf.file)
const fileTypes = versionFiles.map((vf) => vf.fileType || null)
const fileParts = files.map((file, i) => `${file.name}-${i}`)
const fileTypeMap = fileParts.reduce<Record<string, Labrinth.Versions.v3.FileType | null>>(
(acc, key, i) => {
acc[key] = fileTypes[i]
return acc
},
{},
)
formData.append('data', JSON.stringify({ file_types: fileTypeMap }))
files.forEach((file, i) => {
formData.append(fileParts[i], file, file.name)
})
return this.client.upload<Labrinth.Versions.v3.Version>(`/version/${versionId}/file`, {
api: 'labrinth',
version: 2,
formData,
timeout: VERSION_UPLOAD_TIMEOUT_MS,
})
}
}

View File

@ -0,0 +1,28 @@
export namespace LauncherMeta {
export namespace Manifest {
export namespace v0 {
export type LoaderVersion = {
id: string
url: string
stable: boolean
}
export type GameVersionEntry = {
id: string
stable: boolean
versionGroup?: string
loaders: LoaderVersion[]
}
export type VersionGroup = {
id: string
loaders: LoaderVersion[]
}
export type Manifest = {
gameVersions: GameVersionEntry[]
versionGroups?: VersionGroup[]
}
}
}
}

View File

@ -0,0 +1,46 @@
import { AbstractModule } from '../../core/abstract-module'
import type { LauncherMeta } from './types'
export type { LauncherMeta } from './types'
const LAUNCHER_META_BASE_URL = 'https://launcher-meta.modrinth.com'
export const LAUNCHER_META_FORMAT_VERSIONS = {
fabric: 0,
forge: 0,
quilt: 1,
neo: 0,
} as const
export function getLauncherMetaFormatVersion(loader: string): number {
return LAUNCHER_META_FORMAT_VERSIONS[loader as keyof typeof LAUNCHER_META_FORMAT_VERSIONS] ?? 0
}
export class LauncherMetaManifestV0Module extends AbstractModule {
public getModuleID(): string {
return 'launchermeta_manifest_v0'
}
/**
* Get the loader manifest for a given loader platform.
*
* launcher-meta refuses CORS preflights that ask for the `Content-Type`
* header (returns 403), so we strip the default `Content-Type: application/json`
* the abstract client sets — these are body-less GETs and don't need it.
* Without this the browser preflight is rejected and the GET never fires.
*
* @param loader - Loader platform (fabric, forge, quilt, neo)
*/
public async getManifest(
loader: string,
formatVersion = getLauncherMetaFormatVersion(loader),
): Promise<LauncherMeta.Manifest.v0.Manifest> {
return this.client.request<LauncherMeta.Manifest.v0.Manifest>('/manifest.json', {
api: LAUNCHER_META_BASE_URL,
version: `${loader}/v${formatVersion}`,
method: 'GET',
skipAuth: true,
headers: { 'Content-Type': '' },
})
}
}

View File

@ -0,0 +1,19 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Mclogs } from '../types'
export class MclogsLogsV1Module extends AbstractModule {
public getModuleID(): string {
return 'mclogs_logs_v1'
}
public async create(content: string): Promise<Mclogs.Logs.v1.CreateResponse> {
return this.client.request<Mclogs.Logs.v1.CreateResponse>('/log', {
api: 'https://api.mclo.gs',
version: '1',
method: 'POST',
body: new URLSearchParams({ content }),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
skipAuth: true,
})
}
}

View File

@ -0,0 +1,19 @@
export namespace Mclogs {
export namespace Logs {
export namespace v1 {
export type CreateResponse = {
success: boolean
id: string
source: string | null
created: number
expires: number
size: number
lines: number
errors: number
url: string
raw: string
token: string
}
}
}
}

View File

@ -0,0 +1,22 @@
export namespace Paper {
export namespace Versions {
export namespace v3 {
export type Project = {
project: { id: string; name: string }
versions: Record<string, string[]>
}
export type BuildChannel = 'STABLE' | 'BETA' | 'ALPHA'
export type Build = {
id: number
time: string
channel: BuildChannel | string
}
export type VersionBuilds = {
builds: Build[]
}
}
}
}

View File

@ -0,0 +1,40 @@
import { AbstractModule } from '../../core/abstract-module'
import type { Paper } from './types'
export type { Paper } from './types'
const PAPER_BASE_URL = 'https://fill.papermc.io'
export class PaperVersionsV3Module extends AbstractModule {
public getModuleID(): string {
return 'paper_versions_v3'
}
/**
* Get the Paper project info including all supported Minecraft versions.
*/
public async getProject(): Promise<Paper.Versions.v3.Project> {
return this.client.request<Paper.Versions.v3.Project>('/projects/paper', {
api: PAPER_BASE_URL,
version: 'v3',
method: 'GET',
skipAuth: true,
})
}
/**
* Get available Paper builds for a Minecraft version (includes channel per build).
*
* Fill (`fill.papermc.io`) returns a JSON array of builds at this path — not a `{ builds }`
* wrapper like some other Paper API shapes — so we normalize to `VersionBuilds`.
*
* @param mcVersion - Minecraft version (e.g. "1.21.4")
*/
public async getBuilds(mcVersion: string): Promise<Paper.Versions.v3.VersionBuilds> {
const builds = await this.client.request<Paper.Versions.v3.Build[]>(
`/projects/paper/versions/${mcVersion}/builds`,
{ api: PAPER_BASE_URL, version: 'v3', method: 'GET', skipAuth: true },
)
return { builds }
}
}

View File

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

View File

@ -0,0 +1,38 @@
import { AbstractModule } from '../../core/abstract-module'
import type { Purpur } from './types'
export type { Purpur } from './types'
const PURPUR_BASE_URL = 'https://api.purpurmc.org'
export class PurpurVersionsV2Module extends AbstractModule {
public getModuleID(): string {
return 'purpur_versions_v2'
}
/**
* Get the Purpur project info including all supported Minecraft versions.
*/
public async getProject(): Promise<Purpur.Versions.v2.Project> {
return this.client.request<Purpur.Versions.v2.Project>('/purpur', {
api: PURPUR_BASE_URL,
version: 'v2',
method: 'GET',
skipAuth: true,
})
}
/**
* 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 this.client.request<Purpur.Versions.v2.VersionBuilds>(`/purpur/${mcVersion}`, {
api: PURPUR_BASE_URL,
version: 'v2',
method: 'GET',
skipAuth: true,
})
}
}

View File

@ -0,0 +1,8 @@
export * from './archon/types'
export * from './iso3166/types'
export * from './kyros/types'
export * from './labrinth/types'
export * from './launcher-meta/types'
export * from './mclogs/types'
export * from './paper/types'
export * from './purpur/types'

View File

@ -0,0 +1,104 @@
import { $fetch, FetchError } from 'ofetch'
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'
/**
* Generic platform client using ofetch
*
* This client works in any JavaScript environment (Node.js, browser, workers, etc).
*
* @example
* ```typescript
* const client = new GenericModrinthClient({
* userAgent: 'my-app/1.0.0',
* features: [
* new AuthFeature({ token: async () => getOAuthToken() }),
* new RetryFeature({ maxAttempts: 3 })
* ]
* })
*
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class GenericModrinthClient extends XHRUploadClient {
constructor(config: ClientConfig) {
super(config)
Object.defineProperty(this.archon, 'sockets', {
value: new GenericWebSocketClient(this),
writable: false,
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> {
try {
const response = await $fetch<T>(url, {
method: options.method ?? 'GET',
headers: options.headers,
body: options.body as BodyInit,
params: options.params as Record<string, string>,
timeout: options.timeout,
signal: options.signal,
})
return response
} catch (error) {
// ofetch throws FetchError for HTTP errors
throw this.normalizeError(error)
}
}
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)
}
return super.normalizeError(error)
}
}

View File

@ -0,0 +1,233 @@
import { FetchError } from 'ofetch'
import { ModrinthApiError } from '../core/errors'
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
import { GenericSyncClient } from './sync-generic'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Circuit breaker storage using Nuxt's useState
*
* 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 state: Map<string, CircuitBreakerState>
constructor() {
// @ts-expect-error - useState is provided by Nuxt runtime
const stateRef = useState<Map<string, CircuitBreakerState>>(
'circuit-breaker-state',
() => new Map(),
)
this.state = stateRef.value
}
get(key: string): CircuitBreakerState | undefined {
return this.state.get(key)
}
set(key: string, state: CircuitBreakerState): void {
this.state.set(key, state)
}
clear(key: string): void {
this.state.delete(key)
}
}
/**
* Nuxt-specific configuration
*/
export interface NuxtClientConfig extends ClientConfig {
// TODO: do we want to provide this for tauri+base as well? its not used on app
/**
* Rate limit key for server-side requests.
* This is injected as x-ratelimit-key header on server-side.
* Can be a string (for env var) or async function (for CF Secrets Store).
*/
rateLimitKey?: string | (() => Promise<string | undefined>)
}
/**
* Nuxt platform client using Nuxt's $fetch
*
* This client is optimized for Nuxt applications and handles SSR/CSR automatically.
*
* Note: upload() is only available in browser context (CSR). It will throw during SSR.
*
* @example
* ```typescript
* // In a Nuxt composable
* const config = useRuntimeConfig()
*
* const client = new NuxtModrinthClient({
* userAgent: 'my-nuxt-app/1.0.0',
* rateLimitKey: import.meta.server ? config.rateLimitKey : undefined,
* features: [
* new AuthFeature({
* token: async () => getOAuthToken()
* }),
* new CircuitBreakerFeature({
* storage: new NuxtCircuitBreakerStorage()
* })
* ]
* })
*
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class NuxtModrinthClient extends XHRUploadClient {
declare protected config: NuxtClientConfig
private rateLimitKeyResolved: string | undefined
private rateLimitKeyPromise: Promise<string | undefined> | undefined
constructor(config: NuxtClientConfig) {
super(config)
Object.defineProperty(this.archon, 'sockets', {
value: new GenericWebSocketClient(this),
writable: false,
enumerable: true,
configurable: false,
})
Object.defineProperty(this.archon, 'sync', {
value: new GenericSyncClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
/**
* Resolve the rate limit key, handling both string and async function values.
* Results are cached for subsequent calls.
*/
private async resolveRateLimitKey(): Promise<string | undefined> {
if (this.rateLimitKeyResolved !== undefined) {
return this.rateLimitKeyResolved
}
const key = this.config.rateLimitKey
if (typeof key === 'string') {
this.rateLimitKeyResolved = key
} else if (typeof key === 'function') {
if (!this.rateLimitKeyPromise) {
this.rateLimitKeyPromise = key()
}
this.rateLimitKeyResolved = await this.rateLimitKeyPromise
}
return this.rateLimitKeyResolved
}
/**
* Override request to resolve rate limit key before calling super.
* This allows async fetching of the key from CF Secrets Store.
*/
async request<T>(path: string, options: RequestOptions): Promise<T> {
// @ts-expect-error - import.meta is provided by Nuxt
if (import.meta.server) {
await this.resolveRateLimitKey()
}
return super.request(path, options)
}
/**
* Upload a file with progress tracking
*
* Note: This method is only available in browser context (CSR).
* Calling during SSR will throw an error.
*/
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
// @ts-expect-error - import.meta is provided by Nuxt
if (import.meta.server) {
throw new ModrinthApiError('upload() is not supported during SSR')
}
return super.upload(path, options)
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
// @ts-expect-error - $fetch is provided by Nuxt
const response = await $fetch<T>(url, {
method: options.method ?? 'GET',
headers: options.headers,
body: options.body,
params: options.params,
timeout: options.timeout,
signal: options.signal,
// @ts-expect-error - import.meta is provided by Nuxt
cache: import.meta.server ? undefined : 'no-store',
})
return response
} catch (error) {
throw this.normalizeError(error)
}
}
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)
}
return super.normalizeError(error)
}
protected async buildDefaultHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {
...(await super.buildDefaultHeaders()),
}
// Use the resolved key (populated by resolveRateLimitKey in request())
// @ts-expect-error - import.meta is provided by Nuxt
if (import.meta.server && this.rateLimitKeyResolved) {
headers['x-ratelimit-key'] = this.rateLimitKeyResolved
}
return headers
}
}

View File

@ -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')
}
}

View File

@ -0,0 +1,176 @@
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'
/**
* Tauri-specific configuration
* TODO: extend into interface if needed.
*/
export type TauriClientConfig = ClientConfig
/**
* Extended error type with HTTP response metadata
*/
interface HttpError extends Error {
statusCode?: number
responseData?: unknown
}
/**
* Tauri platform client using Tauri v2 HTTP plugin
*
* Extends XHRUploadClient to provide upload with progress tracking.
*
* @example
* ```typescript
* import { getVersion } from '@tauri-apps/api/app'
*
* const client = new TauriModrinthClient({
* userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`,
* features: [
* new AuthFeature({ token: async () => getOAuthToken() })
* ]
* })
*
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class TauriModrinthClient extends XHRUploadClient {
declare protected config: TauriClientConfig
constructor(config: TauriClientConfig) {
super(config)
Object.defineProperty(this.archon, 'sockets', {
value: new GenericWebSocketClient(this),
writable: false,
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> {
try {
// Dynamically import Tauri HTTP plugin
// This allows the package to be used in non-Tauri environments
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
const body = toFetchBody(options.body)
const fullUrl = appendRequestParams(url, options.params)
const response = await tauriFetch(fullUrl, {
method: options.method ?? 'GET',
headers: options.headers,
body,
})
if (!response.ok) {
let responseData: unknown
try {
responseData = await response.json()
} catch {
responseData = undefined
}
const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as HttpError
error.statusCode = response.status
error.responseData = responseData
throw error
}
// Handle binary downloads (e.g. kyros fs files) before JSON parsing.
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''
if (fullUrl.includes('/fs/download')) {
return (await response.blob()) as T
}
if (
contentType.startsWith('image/') ||
contentType.startsWith('audio/') ||
contentType.startsWith('video/') ||
contentType.includes('application/octet-stream')
) {
return (await response.blob()) as T
}
if (response.status === 204 || response.status === 205) {
return undefined as T
}
if (contentType.includes('application/json') || contentType.includes('+json')) {
return (await response.json()) as T
}
const text = await response.text()
if (!text) {
return undefined as T
}
try {
return JSON.parse(text) as T
} catch {
return text as T
}
} catch (error) {
throw this.normalizeError(error)
}
}
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
const statusCode = httpError.statusCode
const responseData = httpError.responseData
return this.createNormalizedError(error, statusCode, responseData)
}
return super.normalizeError(error)
}
}

View File

@ -0,0 +1,170 @@
import mitt from 'mitt'
import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket'
import type { Archon } from '../modules/archon/types'
import { getNodeWebSocketUrl } from '../utils/node-url'
type WSEventMap = {
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
}
const NORMAL_CLOSURE = 1000
export class GenericWebSocketClient extends AbstractWebSocketClient {
protected emitter = mitt<WSEventMap>()
async connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void> {
if (this.connections.has(serverId)) {
this.closeConnection(serverId)
}
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(getNodeWebSocketUrl(auth.url))
const connection: WebSocketConnection = {
serverId,
socket: ws,
reconnectAttempts: 0,
reconnectTimer: undefined,
isReconnecting: false,
}
this.connections.set(serverId, connection)
ws.onopen = () => {
ws.send(JSON.stringify({ event: 'auth', jwt: auth.token }))
connection.reconnectAttempts = 0
connection.isReconnecting = false
resolve()
}
ws.onmessage = (messageEvent) => {
try {
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
const eventKey = `${serverId}:${data.event}` as keyof WSEventMap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.emitter.emit(eventKey, data as any)
if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') {
this.handleAuthExpiring(serverId).catch(console.error)
}
} catch (error) {
console.error('[WebSocket] Failed to parse message:', error)
}
}
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 = (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)
}
})
}
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
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
if (
connection.socket.readyState === WebSocket.OPEN ||
connection.socket.readyState === WebSocket.CONNECTING
) {
connection.socket.close(NORMAL_CLOSURE, 'Client disconnecting')
}
this.connections.delete(serverId)
}
disconnectAll(): void {
for (const serverId of this.connections.keys()) {
this.disconnect(serverId)
}
}
send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void {
const connection = this.connections.get(serverId)
if (!connection || connection.socket.readyState !== WebSocket.OPEN) {
console.warn(`Cannot send message: WebSocket not connected for server ${serverId}`)
return
}
connection.socket.send(JSON.stringify(message))
}
private scheduleReconnect(serverId: string, auth: Archon.Websocket.v0.WSAuth): void {
const connection = this.connections.get(serverId)
if (!connection) return
if (connection.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
this.disconnect(serverId)
return
}
connection.isReconnecting = true
connection.reconnectAttempts++
const delay = this.getReconnectDelay(connection.reconnectAttempts)
connection.reconnectTimer = setTimeout(() => {
this.connect(serverId, auth).catch((error) => {
console.error(`[WebSocket] Reconnection failed for server ${serverId}:`, error)
})
}, delay)
}
private async handleAuthExpiring(serverId: string): Promise<void> {
try {
const newAuth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
const connection = this.connections.get(serverId)
if (connection && connection.socket.readyState === WebSocket.OPEN) {
connection.socket.send(JSON.stringify({ event: 'auth', jwt: newAuth.token }))
}
} catch (error) {
console.error(`[WebSocket] Failed to refresh auth for server ${serverId}:`, error)
this.disconnect(serverId)
}
}
}

View File

@ -0,0 +1,167 @@
import { AbstractModrinthClient } from '../core/abstract-client'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
import type {
UploadHandle,
UploadMetadata,
UploadProgress,
UploadRequestOptions,
} from '../types/upload'
/**
* Abstract client with XHR-based upload implementation
*
* Platform-specific clients should extend this instead of AbstractModrinthClient
* to inherit the XHR upload implementation.
*/
export abstract class XHRUploadClient extends AbstractModrinthClient {
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.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 progressCallbacks: Array<(p: UploadProgress) => void> = []
if (options.onProgress) {
progressCallbacks.push(options.onProgress)
}
const abortController = new AbortController()
if (options.signal) {
options.signal.addEventListener('abort', () => abortController.abort())
}
let context: RequestContext | undefined
const handle: UploadHandle<T> = {
promise: (async () => {
const isFormData = 'formData' in options && options.formData instanceof FormData
const baseHeaders = await this.buildDefaultHeaders()
if (isFormData) {
delete baseHeaders['Content-Type']
} else {
baseHeaders['Content-Type'] = 'application/octet-stream'
}
const mergedOptions: UploadRequestOptions = {
retry: false,
...options,
headers: {
...baseHeaders,
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const uploadContext = this.buildUploadContext(url, path, mergedOptions)
context = uploadContext
if (abortController.signal.aborted) {
throw new ModrinthApiError('Upload cancelled')
}
const result = await this.executeUploadFeatureChain<T>(
uploadContext,
progressCallbacks,
abortController,
)
await this.config.hooks?.onResponse?.(result, uploadContext)
return result
})().catch(async (error) => {
const apiError = this.normalizeError(error, context)
if (context) {
await this.config.hooks?.onError?.(apiError, context)
}
throw apiError
}),
onProgress: (callback) => {
progressCallbacks.push(callback)
return handle
},
cancel: () => abortController.abort(),
}
return handle
}
protected executeXHRUpload<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest()
const metadata = context.metadata as UploadMetadata
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress: UploadProgress = {
loaded: e.loaded,
total: e.total,
progress: e.loaded / e.total,
}
progressCallbacks.forEach((cb) => cb(progress))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(xhr.response ? JSON.parse(xhr.response) : (undefined as T))
} catch {
resolve(undefined as T)
}
} else {
reject(this.createUploadError(xhr))
}
})
xhr.addEventListener('error', () => reject(new ModrinthApiError('Upload failed')))
xhr.addEventListener('abort', () => reject(new ModrinthApiError('Upload cancelled')))
xhr.addEventListener('timeout', () => reject(new ModrinthApiError('Upload timed out')))
if (context.options.timeout !== undefined) {
xhr.timeout = context.options.timeout
}
// build URL with params (unlike $fetch, XHR doesn't handle params automatically)
let url = context.url
if (context.options.params) {
const queryString = new URLSearchParams(
Object.entries(context.options.params).map(([k, v]) => [k, String(v)]),
).toString()
url += (url.includes('?') ? '&' : '?') + queryString
}
xhr.open('POST', url)
// apply headers from context (features may have modified them)
for (const [key, value] of Object.entries(context.options.headers ?? {})) {
xhr.setRequestHeader(key, value)
}
// Send either FormData or file depending on what was provided
const data = 'formData' in metadata ? metadata.formData : metadata.file
xhr.send(data)
abortController.signal.addEventListener('abort', () => xhr.abort())
})
}
protected createUploadError(xhr: XMLHttpRequest): ModrinthApiError {
let responseData: unknown
try {
responseData = xhr.response ? JSON.parse(xhr.response) : undefined
} catch {
responseData = xhr.responseText
}
return this.createNormalizedError(
new Error(`Upload failed with status ${xhr.status}`),
xhr.status,
responseData,
)
}
}

View File

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

View File

View File

@ -0,0 +1,82 @@
import type { AbstractFeature } from '../core/abstract-feature'
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
*/
export type RequestHooks = {
/**
* Called before request is sent (after all features have processed)
*/
onRequest?: (context: RequestContext) => void | Promise<void>
/**
* Called after successful response (before features process response)
*/
onResponse?: <T>(data: T, context: RequestContext) => void | Promise<void>
/**
* Called when request fails (after all features have processed error)
*/
onError?: (error: Error, context: RequestContext) => void | Promise<void>
}
/**
* Client configuration
*/
export interface ClientConfig {
/**
* User agent string or provider for requests
* Should identify your application (e.g., 'my-app/1.0.0')
* If not provided, the platform's default user agent will be used
*/
userAgent?: UserAgentProvider
/**
* Base URL for Labrinth API (main Modrinth API)
* @default 'https://api.modrinth.com'
*/
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?: BaseUrlConfig
/**
* Default request timeout in milliseconds
* @default 10000
*/
timeout?: number
/**
* Additional default headers to include in all requests
*/
headers?: Record<string, string>
/**
* Whether to attach `modrinth-sentry-capture: 1` to Archon requests.
* Can be a callback so apps can drive this from runtime feature flags.
*
* @default false
*/
archonSentryCapture?: boolean | (() => boolean)
/**
* Features to enable for this client
* Features are applied in the order they appear in this array
*/
features?: AbstractFeature[]
/**
* Request lifecycle hooks
*/
hooks?: RequestHooks
}

View File

@ -0,0 +1,56 @@
/**
* Data for API errors
*/
export type ApiErrorData = {
/**
* HTTP status code (if available)
*/
statusCode?: number
/**
* Original error that was caught
*/
originalError?: Error
/**
* Response data from the API (if available)
*/
responseData?: unknown
/**
* Error context (e.g., module name, operation being performed)
*/
context?: string
}
/**
* Modrinth V1 error response format
* Used by kyros + archon APIs
*/
export type ModrinthErrorResponse = {
/**
* Error code/identifier
*/
error: string
/**
* Human-readable error description
*/
description: string
/**
* Optional context about where the error occurred
*/
context?: string
}
/**
* Type guard to check if an object is a ModrinthErrorResponse
*/
export function isModrinthErrorResponse(obj: unknown): obj is ModrinthErrorResponse {
if (typeof obj !== 'object' || obj === null) {
return false
}
const record = obj as Record<string, unknown>
return typeof record.error === 'string' && typeof record.description === 'string'
}

Some files were not shown because too many files have changed in this diff Show More