forked from AxTps/Starlight_Lancher
feat:移除了弹窗,服务器添加sls
This commit is contained in:
2
packages/api-client/.prettierignore
Normal file
2
packages/api-client/.prettierignore
Normal file
@ -0,0 +1,2 @@
|
||||
# Autogenerated files
|
||||
dist
|
||||
0
packages/api-client/AGENTS.md
Normal file
0
packages/api-client/AGENTS.md
Normal file
208
packages/api-client/CLAUDE.md
Normal file
208
packages/api-client/CLAUDE.md
Normal 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
165
packages/api-client/LICENSE
Normal 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.
|
||||
177
packages/api-client/README.md
Normal file
177
packages/api-client/README.md
Normal file
@ -0,0 +1,177 @@
|
||||
# @modrinth/api-client
|
||||
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](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).
|
||||
7
packages/api-client/eslint.config.mjs
Normal file
7
packages/api-client/eslint.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
|
||||
|
||||
export default config.append([
|
||||
{
|
||||
ignores: ['dist/'],
|
||||
},
|
||||
])
|
||||
60
packages/api-client/package.json
Normal file
60
packages/api-client/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
504
packages/api-client/src/core/abstract-client.ts
Normal file
504
packages/api-client/src/core/abstract-client.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
91
packages/api-client/src/core/abstract-feature.ts
Normal file
91
packages/api-client/src/core/abstract-feature.ts
Normal 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
|
||||
}
|
||||
}
|
||||
15
packages/api-client/src/core/abstract-module.ts
Normal file
15
packages/api-client/src/core/abstract-module.ts
Normal 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
|
||||
}
|
||||
167
packages/api-client/src/core/abstract-sync.ts
Normal file
167
packages/api-client/src/core/abstract-sync.ts
Normal 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`
|
||||
}
|
||||
}
|
||||
21
packages/api-client/src/core/abstract-upload-client.ts
Normal file
21
packages/api-client/src/core/abstract-upload-client.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
|
||||
|
||||
/**
|
||||
* Abstract base class defining upload capability
|
||||
*
|
||||
* All clients that support file uploads must extend this class.
|
||||
* Platform-specific implementations should provide the actual upload mechanism
|
||||
* (e.g., XHR for browser environments).
|
||||
*
|
||||
* Upload goes through the feature chain (auth, retry, circuit-breaker, etc.)
|
||||
* just like regular requests.
|
||||
*/
|
||||
export abstract class AbstractUploadClient {
|
||||
/**
|
||||
* Upload a file 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>
|
||||
}
|
||||
104
packages/api-client/src/core/abstract-websocket.ts
Normal file
104
packages/api-client/src/core/abstract-websocket.ts
Normal 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
|
||||
}
|
||||
}
|
||||
142
packages/api-client/src/core/errors.ts
Normal file
142
packages/api-client/src/core/errors.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
89
packages/api-client/src/features/auth.ts
Normal file
89
packages/api-client/src/features/auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
269
packages/api-client/src/features/circuit-breaker.ts
Normal file
269
packages/api-client/src/features/circuit-breaker.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
152
packages/api-client/src/features/node-auth.ts
Normal file
152
packages/api-client/src/features/node-auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/api-client/src/features/panel-version.ts
Normal file
18
packages/api-client/src/features/panel-version.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
220
packages/api-client/src/features/retry.ts
Normal file
220
packages/api-client/src/features/retry.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
72
packages/api-client/src/features/verbose-logging.ts
Normal file
72
packages/api-client/src/features/verbose-logging.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
55
packages/api-client/src/index.ts
Normal file
55
packages/api-client/src/index.ts
Normal 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'
|
||||
0
packages/api-client/src/modules/archon/.gitkeep
Normal file
0
packages/api-client/src/modules/archon/.gitkeep
Normal file
35
packages/api-client/src/modules/archon/actions/v1.ts
Normal file
35
packages/api-client/src/modules/archon/actions/v1.ts
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
113
packages/api-client/src/modules/archon/backups-queue/v1.ts
Normal file
113
packages/api-client/src/modules/archon/backups-queue/v1.ts
Normal 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' },
|
||||
)
|
||||
}
|
||||
}
|
||||
113
packages/api-client/src/modules/archon/backups/v1.ts
Normal file
113
packages/api-client/src/modules/archon/backups/v1.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
290
packages/api-client/src/modules/archon/content/v1.ts
Normal file
290
packages/api-client/src/modules/archon/content/v1.ts
Normal 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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
8
packages/api-client/src/modules/archon/index.ts
Normal file
8
packages/api-client/src/modules/archon/index.ts
Normal 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'
|
||||
20
packages/api-client/src/modules/archon/nodes/internal.ts
Normal file
20
packages/api-client/src/modules/archon/nodes/internal.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
98
packages/api-client/src/modules/archon/notices/v0.ts
Normal file
98
packages/api-client/src/modules/archon/notices/v0.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
37
packages/api-client/src/modules/archon/options/v1.ts
Normal file
37
packages/api-client/src/modules/archon/options/v1.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
40
packages/api-client/src/modules/archon/properties/v1.ts
Normal file
40
packages/api-client/src/modules/archon/properties/v1.ts
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
83
packages/api-client/src/modules/archon/server-users/v1.ts
Normal file
83
packages/api-client/src/modules/archon/server-users/v1.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
321
packages/api-client/src/modules/archon/servers/v0.ts
Normal file
321
packages/api-client/src/modules/archon/servers/v0.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
69
packages/api-client/src/modules/archon/servers/v1.ts
Normal file
69
packages/api-client/src/modules/archon/servers/v1.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
84
packages/api-client/src/modules/archon/transfers/internal.ts
Normal file
84
packages/api-client/src/modules/archon/transfers/internal.ts
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
1216
packages/api-client/src/modules/archon/types.ts
Normal file
1216
packages/api-client/src/modules/archon/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
211
packages/api-client/src/modules/index.ts
Normal file
211
packages/api-client/src/modules/index.ts
Normal 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>
|
||||
121
packages/api-client/src/modules/iso3166/index.ts
Normal file
121
packages/api-client/src/modules/iso3166/index.ts
Normal 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: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
packages/api-client/src/modules/iso3166/types.ts
Normal file
23
packages/api-client/src/modules/iso3166/types.ts
Normal 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[]>
|
||||
}
|
||||
}
|
||||
0
packages/api-client/src/modules/kyros/.gitkeep
Normal file
0
packages/api-client/src/modules/kyros/.gitkeep
Normal file
66
packages/api-client/src/modules/kyros/content/v1.ts
Normal file
66
packages/api-client/src/modules/kyros/content/v1.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
275
packages/api-client/src/modules/kyros/files/v0.ts
Normal file
275
packages/api-client/src/modules/kyros/files/v0.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
17
packages/api-client/src/modules/kyros/logs/v1.ts
Normal file
17
packages/api-client/src/modules/kyros/logs/v1.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
55
packages/api-client/src/modules/kyros/types.ts
Normal file
55
packages/api-client/src/modules/kyros/types.ts
Normal 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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
packages/api-client/src/modules/kyros/upload-sessions/v1.ts
Normal file
104
packages/api-client/src/modules/kyros/upload-sessions/v1.ts
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
116
packages/api-client/src/modules/labrinth/analytics/v3.ts
Normal file
116
packages/api-client/src/modules/labrinth/analytics/v3.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
135
packages/api-client/src/modules/labrinth/attribution/internal.ts
Normal file
135
packages/api-client/src/modules/labrinth/attribution/internal.ts
Normal 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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
46
packages/api-client/src/modules/labrinth/auth/internal.ts
Normal file
46
packages/api-client/src/modules/labrinth/auth/internal.ts
Normal 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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
232
packages/api-client/src/modules/labrinth/auth/v2.ts
Normal file
232
packages/api-client/src/modules/labrinth/auth/v2.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
128
packages/api-client/src/modules/labrinth/collections.ts
Normal file
128
packages/api-client/src/modules/labrinth/collections.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
19
packages/api-client/src/modules/labrinth/content/v3.ts
Normal file
19
packages/api-client/src/modules/labrinth/content/v3.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
47
packages/api-client/src/modules/labrinth/friends/v3.ts
Normal file
47
packages/api-client/src/modules/labrinth/friends/v3.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
22
packages/api-client/src/modules/labrinth/globals/internal.ts
Normal file
22
packages/api-client/src/modules/labrinth/globals/internal.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
47
packages/api-client/src/modules/labrinth/images/v3.ts
Normal file
47
packages/api-client/src/modules/labrinth/images/v3.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
30
packages/api-client/src/modules/labrinth/index.ts
Normal file
30
packages/api-client/src/modules/labrinth/index.ts
Normal 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'
|
||||
41
packages/api-client/src/modules/labrinth/limits/v3.ts
Normal file
41
packages/api-client/src/modules/labrinth/limits/v3.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
128
packages/api-client/src/modules/labrinth/notifications/v2.ts
Normal file
128
packages/api-client/src/modules/labrinth/notifications/v2.ts
Normal 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)]) },
|
||||
})
|
||||
}
|
||||
}
|
||||
208
packages/api-client/src/modules/labrinth/oauth/internal.ts
Normal file
208
packages/api-client/src/modules/labrinth/oauth/internal.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
122
packages/api-client/src/modules/labrinth/organizations/v3.ts
Normal file
122
packages/api-client/src/modules/labrinth/organizations/v3.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
66
packages/api-client/src/modules/labrinth/pats/v2.ts
Normal file
66
packages/api-client/src/modules/labrinth/pats/v2.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
115
packages/api-client/src/modules/labrinth/payout/v3.ts
Normal file
115
packages/api-client/src/modules/labrinth/payout/v3.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
26
packages/api-client/src/modules/labrinth/payouts/v3.ts
Normal file
26
packages/api-client/src/modules/labrinth/payouts/v3.ts
Normal 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>,
|
||||
})
|
||||
}
|
||||
}
|
||||
303
packages/api-client/src/modules/labrinth/projects/v2.ts
Normal file
303
packages/api-client/src/modules/labrinth/projects/v2.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
201
packages/api-client/src/modules/labrinth/projects/v3.ts
Normal file
201
packages/api-client/src/modules/labrinth/projects/v3.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
141
packages/api-client/src/modules/labrinth/reports/v3.ts
Normal file
141
packages/api-client/src/modules/labrinth/reports/v3.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
34
packages/api-client/src/modules/labrinth/sessions/v2.ts
Normal file
34
packages/api-client/src/modules/labrinth/sessions/v2.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
160
packages/api-client/src/modules/labrinth/state/index.ts
Normal file
160
packages/api-client/src/modules/labrinth/state/index.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
29
packages/api-client/src/modules/labrinth/tags/v2.ts
Normal file
29
packages/api-client/src/modules/labrinth/tags/v2.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
101
packages/api-client/src/modules/labrinth/teams/v2.ts
Normal file
101
packages/api-client/src/modules/labrinth/teams/v2.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
31
packages/api-client/src/modules/labrinth/teams/v3.ts
Normal file
31
packages/api-client/src/modules/labrinth/teams/v3.ts
Normal 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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
188
packages/api-client/src/modules/labrinth/tech-review/internal.ts
Normal file
188
packages/api-client/src/modules/labrinth/tech-review/internal.ts
Normal 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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
95
packages/api-client/src/modules/labrinth/threads/v3.ts
Normal file
95
packages/api-client/src/modules/labrinth/threads/v3.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
2507
packages/api-client/src/modules/labrinth/types.ts
Normal file
2507
packages/api-client/src/modules/labrinth/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
180
packages/api-client/src/modules/labrinth/users/v2.ts
Normal file
180
packages/api-client/src/modules/labrinth/users/v2.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
79
packages/api-client/src/modules/labrinth/users/v3.ts
Normal file
79
packages/api-client/src/modules/labrinth/users/v3.ts
Normal 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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
141
packages/api-client/src/modules/labrinth/versions/v2.ts
Normal file
141
packages/api-client/src/modules/labrinth/versions/v2.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
292
packages/api-client/src/modules/labrinth/versions/v3.ts
Normal file
292
packages/api-client/src/modules/labrinth/versions/v3.ts
Normal 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 version’s 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
28
packages/api-client/src/modules/launcher-meta/types.ts
Normal file
28
packages/api-client/src/modules/launcher-meta/types.ts
Normal 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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
packages/api-client/src/modules/launcher-meta/v0.ts
Normal file
46
packages/api-client/src/modules/launcher-meta/v0.ts
Normal 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': '' },
|
||||
})
|
||||
}
|
||||
}
|
||||
19
packages/api-client/src/modules/mclogs/logs/v1.ts
Normal file
19
packages/api-client/src/modules/mclogs/logs/v1.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
19
packages/api-client/src/modules/mclogs/types.ts
Normal file
19
packages/api-client/src/modules/mclogs/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
packages/api-client/src/modules/paper/types.ts
Normal file
22
packages/api-client/src/modules/paper/types.ts
Normal 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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/api-client/src/modules/paper/v3.ts
Normal file
40
packages/api-client/src/modules/paper/v3.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
16
packages/api-client/src/modules/purpur/types.ts
Normal file
16
packages/api-client/src/modules/purpur/types.ts
Normal 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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
packages/api-client/src/modules/purpur/v2.ts
Normal file
38
packages/api-client/src/modules/purpur/v2.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
8
packages/api-client/src/modules/types.ts
Normal file
8
packages/api-client/src/modules/types.ts
Normal 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'
|
||||
104
packages/api-client/src/platform/generic.ts
Normal file
104
packages/api-client/src/platform/generic.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
233
packages/api-client/src/platform/nuxt.ts
Normal file
233
packages/api-client/src/platform/nuxt.ts
Normal 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
|
||||
}
|
||||
}
|
||||
229
packages/api-client/src/platform/sync-generic.ts
Normal file
229
packages/api-client/src/platform/sync-generic.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
176
packages/api-client/src/platform/tauri.ts
Normal file
176
packages/api-client/src/platform/tauri.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
170
packages/api-client/src/platform/websocket-generic.ts
Normal file
170
packages/api-client/src/platform/websocket-generic.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
167
packages/api-client/src/platform/xhr-upload-client.ts
Normal file
167
packages/api-client/src/platform/xhr-upload-client.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
45
packages/api-client/src/state/node-auth.ts
Normal file
45
packages/api-client/src/state/node-auth.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { NodeAuth } from '../features/node-auth'
|
||||
|
||||
/**
|
||||
* Global node auth state.
|
||||
* Set by server management pages, read by NodeAuthFeature.
|
||||
*/
|
||||
export const nodeAuthState = {
|
||||
getAuth: null as (() => NodeAuth | null) | null,
|
||||
refreshAuth: null as (() => Promise<void>) | null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the node auth state. Call this when entering server management.
|
||||
*
|
||||
* @param getAuth - Function that returns current auth or null
|
||||
* @param refreshAuth - Function to refresh the auth token
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In server management page setup
|
||||
* setNodeAuthState(
|
||||
* () => fsAuth.value,
|
||||
* refreshFsAuth,
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function setNodeAuthState(getAuth: () => NodeAuth | null, refreshAuth: () => Promise<void>) {
|
||||
nodeAuthState.getAuth = getAuth
|
||||
nodeAuthState.refreshAuth = refreshAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the node auth state. Call this when leaving server management.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* onUnmounted(() => {
|
||||
* clearNodeAuthState()
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function clearNodeAuthState() {
|
||||
nodeAuthState.getAuth = null
|
||||
nodeAuthState.refreshAuth = null
|
||||
}
|
||||
0
packages/api-client/src/tests/.gitkeep
Normal file
0
packages/api-client/src/tests/.gitkeep
Normal file
82
packages/api-client/src/types/client.ts
Normal file
82
packages/api-client/src/types/client.ts
Normal 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
|
||||
}
|
||||
56
packages/api-client/src/types/errors.ts
Normal file
56
packages/api-client/src/types/errors.ts
Normal 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
Reference in New Issue
Block a user