You've already forked AstralRinth
feat: hosting access tab (#5995)
* feat: implement access tab with dummy data * fix: spacing * feat: qa * feat: implement backend * qa: qa pass * feat: fix user "search" * fix: lint * feat: change to bitfield * feat: fix fields * fix: lint * fix: lint * feat: hook up api * feat: fix permissions * feat: audit log table event start * feat: better mobile mode for audit log table * feat: i18n * feat: qa * feat: enforce permissions * feat: email template start * feat: qa * fix: tooltip bug * feat: qa * impl: sse support in api-client * feat: sse impl * fix: desync path * feat: time frame picker from analytics * feat: QA * fix: spacing * fix: permisison audit log entries * fix: hosting manage page shared server detection * fix: lint * feat: qa + lint * feat: audit log table sort by time * feat: finish frontend panel stuff * fix: lint * fix: backend alignment * fix: lint * fix: supress friend errors * feat: qa * fix: qa * fix: lint * fix: utils barrel * fix: safari cookies in dev * fix: pin nuxt * feat: fixes + notif fix * fix: notifications * feat: qa * fix: notification sync not happening immediately * fix: qa * fix: qa * feat: qa * blog + prepr * feat: toast shit * blog images * thumbnail update one last time * prepr * feat: use reinvite route * update images * fix: reinvite stuff * fix: lint * fix: alignment of save bar * fix: notif sizing * fix: split up access * fix: lint * fix: lint * fix: link --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import type { InferredClientModules } from '../modules'
|
||||
import { buildModuleStructure } from '../modules'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { BaseUrlConfig, ClientConfig } from '../types/client'
|
||||
import type { RequestContext, RequestOptions } from '../types/request'
|
||||
import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload'
|
||||
import type { AbstractFeature } from './abstract-feature'
|
||||
import type { AbstractModule } from './abstract-module'
|
||||
import type { AbstractSyncClient } from './abstract-sync'
|
||||
import { AbstractUploadClient } from './abstract-upload-client'
|
||||
import type { AbstractWebSocketClient } from './abstract-websocket'
|
||||
import { ModrinthApiError, ModrinthServerError } from './errors'
|
||||
@@ -32,7 +33,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
|
||||
|
||||
public readonly labrinth!: InferredClientModules['labrinth']
|
||||
public readonly archon!: ArchonClientModules & { sockets: AbstractWebSocketClient }
|
||||
public readonly archon!: ArchonClientModules & {
|
||||
sockets: AbstractWebSocketClient
|
||||
sync: AbstractSyncClient
|
||||
}
|
||||
public readonly kyros!: InferredClientModules['kyros']
|
||||
public readonly iso3166!: InferredClientModules['iso3166']
|
||||
public readonly mclogs!: InferredClientModules['mclogs']
|
||||
@@ -116,9 +120,9 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||
async request<T>(path: string, options: RequestOptions): Promise<T> {
|
||||
let baseUrl: string
|
||||
if (options.api === 'labrinth') {
|
||||
baseUrl = this.config.labrinthBaseUrl!
|
||||
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
|
||||
} else if (options.api === 'archon') {
|
||||
baseUrl = this.config.archonBaseUrl!
|
||||
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
|
||||
} else {
|
||||
baseUrl = options.api
|
||||
}
|
||||
@@ -160,13 +164,55 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||
}
|
||||
}
|
||||
|
||||
async stream(path: string, options: RequestOptions): Promise<ReadableStream<Uint8Array>> {
|
||||
let baseUrl: string
|
||||
if (options.api === 'labrinth') {
|
||||
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
|
||||
} else if (options.api === 'archon') {
|
||||
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
|
||||
} else {
|
||||
baseUrl = options.api
|
||||
}
|
||||
|
||||
const url = this.buildUrl(path, baseUrl, options.version)
|
||||
const defaultHeaders = await this.buildDefaultHeaders()
|
||||
const mergedOptions: RequestOptions = {
|
||||
method: 'GET',
|
||||
retry: false,
|
||||
circuitBreaker: false,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
Accept: 'text/event-stream',
|
||||
...options.headers,
|
||||
},
|
||||
}
|
||||
this.attachArchonSentryCaptureHeader(mergedOptions)
|
||||
|
||||
const context = this.buildContext(url, path, mergedOptions)
|
||||
|
||||
try {
|
||||
return await this.executeFeatureChain<ReadableStream<Uint8Array>>(context, () =>
|
||||
this.executeStreamRequest(context.url, context.options),
|
||||
)
|
||||
} catch (error) {
|
||||
const apiError = this.normalizeError(error, context)
|
||||
await this.config.hooks?.onError?.(apiError, context)
|
||||
|
||||
throw apiError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the feature chain and the actual request
|
||||
*
|
||||
* Features are executed in order, with each feature calling next() to continue.
|
||||
* The last "feature" in the chain is the actual request execution.
|
||||
*/
|
||||
protected async executeFeatureChain<T>(context: RequestContext): Promise<T> {
|
||||
protected async executeFeatureChain<T>(
|
||||
context: RequestContext,
|
||||
executeTerminal: () => Promise<T> = () => this.executeRequest<T>(context.url, context.options),
|
||||
): Promise<T> {
|
||||
// Filter to only features that should apply
|
||||
const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context))
|
||||
|
||||
@@ -184,7 +230,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||
} else {
|
||||
// We've reached the end of the chain, execute the actual request
|
||||
await this.config.hooks?.onRequest?.(context)
|
||||
return this.executeRequest<T>(context.url, context.options)
|
||||
return executeTerminal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +289,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||
return `${base}${versionPath}${cleanPath}`
|
||||
}
|
||||
|
||||
protected resolveBaseUrl(baseUrl: BaseUrlConfig): string {
|
||||
return typeof baseUrl === 'function' ? baseUrl() : baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request context
|
||||
*/
|
||||
@@ -354,6 +404,11 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||
*/
|
||||
protected abstract executeRequest<T>(url: string, options: RequestOptions): Promise<T>
|
||||
|
||||
protected abstract executeStreamRequest(
|
||||
url: string,
|
||||
options: RequestOptions,
|
||||
): Promise<ReadableStream<Uint8Array>>
|
||||
|
||||
/**
|
||||
* Execute the actual XHR upload
|
||||
*
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type mitt from 'mitt'
|
||||
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
|
||||
export type SyncEventType = Archon.Sync.v1.SyncEvent['type']
|
||||
|
||||
export type SyncEventOfType<E extends SyncEventType> = Extract<
|
||||
Archon.Sync.v1.SyncEvent,
|
||||
{ type: E }
|
||||
>
|
||||
|
||||
export type SyncEventHandler<E extends Archon.Sync.v1.SyncEvent = Archon.Sync.v1.SyncEvent> = (
|
||||
event: E,
|
||||
) => void
|
||||
|
||||
export type SyncStatusState =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'disconnected'
|
||||
| 'error'
|
||||
|
||||
export type SyncStatus = {
|
||||
state: SyncStatusState
|
||||
connected: boolean
|
||||
reconnecting: boolean
|
||||
reconnectAttempts: number
|
||||
retryDelay: number
|
||||
lastEventId?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export type SyncStatusHandler = (status: SyncStatus) => void
|
||||
|
||||
export type SyncConnectOptions = {
|
||||
intent?: Archon.Sync.v1.SyncIntent
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export type SyncConnection = {
|
||||
serverId: string
|
||||
intent: Archon.Sync.v1.SyncIntent
|
||||
controller?: AbortController
|
||||
reconnectAttempts: number
|
||||
reconnectTimer?: ReturnType<typeof setTimeout>
|
||||
reconnectResolve?: () => void
|
||||
retryDelay: number
|
||||
lastEventId?: string
|
||||
stopped: boolean
|
||||
status: SyncStatusState
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export type SyncEmitterEvents = Record<string, unknown>
|
||||
|
||||
export abstract class AbstractSyncClient {
|
||||
protected connections = new Map<string, SyncConnection>()
|
||||
protected abstract emitter: ReturnType<typeof mitt<SyncEmitterEvents>>
|
||||
|
||||
constructor(
|
||||
protected client: {
|
||||
stream: (path: string, options: RequestOptions) => Promise<ReadableStream<Uint8Array>>
|
||||
},
|
||||
) {}
|
||||
|
||||
abstract safeConnectServer(serverId: string, options?: SyncConnectOptions): Promise<void>
|
||||
|
||||
abstract disconnect(serverId: string): void
|
||||
|
||||
abstract disconnectAll(): void
|
||||
|
||||
on<E extends SyncEventType>(
|
||||
serverId: string,
|
||||
eventType: E,
|
||||
handler: SyncEventHandler<SyncEventOfType<E>>,
|
||||
): () => void {
|
||||
const eventKey = this.getEventKey(serverId, eventType)
|
||||
const wrapped = handler as (event: unknown) => void
|
||||
|
||||
this.emitter.on(eventKey, wrapped)
|
||||
|
||||
return () => {
|
||||
this.emitter.off(eventKey, wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
onAny(serverId: string, handler: SyncEventHandler): () => void {
|
||||
const eventKey = this.getAnyEventKey(serverId)
|
||||
const wrapped = handler as (event: unknown) => void
|
||||
|
||||
this.emitter.on(eventKey, wrapped)
|
||||
|
||||
return () => {
|
||||
this.emitter.off(eventKey, wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
onStatus(serverId: string, handler: SyncStatusHandler): () => void {
|
||||
const eventKey = this.getStatusEventKey(serverId)
|
||||
const wrapped = handler as (event: unknown) => void
|
||||
|
||||
this.emitter.on(eventKey, wrapped)
|
||||
|
||||
return () => {
|
||||
this.emitter.off(eventKey, wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(serverId: string): SyncStatus | null {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return null
|
||||
|
||||
return this.connectionToStatus(connection)
|
||||
}
|
||||
|
||||
protected emitSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent): void {
|
||||
this.emitter.emit(this.getEventKey(serverId, event.type), event)
|
||||
this.emitter.emit(this.getAnyEventKey(serverId), event)
|
||||
}
|
||||
|
||||
protected updateStatus(
|
||||
connection: SyncConnection,
|
||||
status: SyncStatusState,
|
||||
error?: unknown,
|
||||
): void {
|
||||
connection.status = status
|
||||
connection.error = error
|
||||
this.emitter.emit(
|
||||
this.getStatusEventKey(connection.serverId),
|
||||
this.connectionToStatus(connection),
|
||||
)
|
||||
}
|
||||
|
||||
protected clearListeners(serverId: string): void {
|
||||
this.emitter.all.forEach((_handlers, type) => {
|
||||
if (type.toString().startsWith(`${serverId}:`)) {
|
||||
this.emitter.all.delete(type)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected connectionToStatus(connection: SyncConnection): SyncStatus {
|
||||
return {
|
||||
state: connection.status,
|
||||
connected: connection.status === 'connected',
|
||||
reconnecting: connection.status === 'reconnecting',
|
||||
reconnectAttempts: connection.reconnectAttempts,
|
||||
retryDelay: connection.retryDelay,
|
||||
lastEventId: connection.lastEventId,
|
||||
error: connection.error,
|
||||
}
|
||||
}
|
||||
|
||||
private getEventKey(serverId: string, eventType: string): string {
|
||||
return `${serverId}:${eventType}`
|
||||
}
|
||||
|
||||
private getAnyEventKey(serverId: string): string {
|
||||
return `${serverId}:*`
|
||||
}
|
||||
|
||||
private getStatusEventKey(serverId: string): string {
|
||||
return `${serverId}:__status`
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
export { AbstractModrinthClient } from './core/abstract-client'
|
||||
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
|
||||
export {
|
||||
AbstractSyncClient,
|
||||
type SyncConnection,
|
||||
type SyncConnectOptions,
|
||||
type SyncEventHandler,
|
||||
type SyncEventOfType,
|
||||
type SyncEventType,
|
||||
type SyncStatus,
|
||||
type SyncStatusHandler,
|
||||
type SyncStatusState,
|
||||
} from './core/abstract-sync'
|
||||
export { AbstractUploadClient } from './core/abstract-upload-client'
|
||||
export {
|
||||
AbstractWebSocketClient,
|
||||
@@ -25,10 +36,18 @@ export * from './modules/types'
|
||||
export { GenericModrinthClient } from './platform/generic'
|
||||
export type { NuxtClientConfig } from './platform/nuxt'
|
||||
export { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt'
|
||||
export { GenericSyncClient } from './platform/sync-generic'
|
||||
export type { TauriClientConfig } from './platform/tauri'
|
||||
export { TauriModrinthClient } from './platform/tauri'
|
||||
export { XHRUploadClient } from './platform/xhr-upload-client'
|
||||
export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/node-auth'
|
||||
export * from './types'
|
||||
export { withJWTRetry } from './utils/jwt-retry'
|
||||
export {
|
||||
type ParsedSseEvent,
|
||||
type ParsedSseItem,
|
||||
type ParsedSseRetry,
|
||||
parseSyncEventData,
|
||||
SseParser,
|
||||
} from './utils/sse'
|
||||
export type { Override, RawDecimal } from './utils/types'
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
export class ArchonActionsV1Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_actions_v1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server action log entries.
|
||||
* GET /v1/servers/:server_id/action-log
|
||||
*/
|
||||
public async list(
|
||||
serverId: string,
|
||||
options: Archon.Actions.v1.ListActionLogOptions = {},
|
||||
): Promise<Archon.Actions.v1.ActionLogResponse> {
|
||||
const params: Record<string, string | number> = {}
|
||||
if (options.filter) params.filter = JSON.stringify(options.filter)
|
||||
if (options.limit !== undefined) params.limit = options.limit
|
||||
if (options.offset !== undefined) params.offset = options.offset
|
||||
if (options.order !== undefined) params.order = options.order
|
||||
if (options.min_datetime !== undefined) params.min_datetime = options.min_datetime
|
||||
if (options.max_datetime !== undefined) params.max_datetime = options.max_datetime
|
||||
|
||||
return this.client.request<Archon.Actions.v1.ActionLogResponse>(
|
||||
`/servers/${serverId}/action-log`,
|
||||
{
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'GET',
|
||||
params: Object.keys(params).length > 0 ? params : undefined,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './actions/v1'
|
||||
export * from './backups/v1'
|
||||
export * from './backups-queue/v1'
|
||||
export * from './content/v1'
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
export class ArchonNodesInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_nodes_internal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node hostnames and region summary for admin tooling.
|
||||
* GET /_internal/nodes/overview
|
||||
*/
|
||||
public async overview(): Promise<Archon.Nodes.Internal.Overview> {
|
||||
return this.client.request<Archon.Nodes.Internal.Overview>('/nodes/overview', {
|
||||
api: 'archon',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
export class ArchonNoticesV0Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_notices_v0'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all server notices.
|
||||
* GET /modrinth/v0/notices
|
||||
*/
|
||||
public async list(): Promise<Archon.Notices.v0.ListedNotice[]> {
|
||||
return this.client.request<Archon.Notices.v0.ListedNotice[]>('/notices', {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a server notice.
|
||||
* POST /modrinth/v0/notices
|
||||
*/
|
||||
public async create(
|
||||
request: Archon.Notices.v0.Announce,
|
||||
): Promise<Archon.Notices.v0.PostNoticeResponseBody> {
|
||||
return this.client.request<Archon.Notices.v0.PostNoticeResponseBody>('/notices', {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'POST',
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a server notice.
|
||||
* PATCH /modrinth/v0/notices/:id
|
||||
*/
|
||||
public async update(id: number, request: Archon.Notices.v0.AnnouncePatch): Promise<void> {
|
||||
await this.client.request(`/notices/${id}`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'PATCH',
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a server notice.
|
||||
* DELETE /modrinth/v0/notices/:id
|
||||
*/
|
||||
public async delete(id: number): Promise<void> {
|
||||
await this.client.request(`/notices/${id}`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a notice to a server or node.
|
||||
* PUT /modrinth/v0/notices/:id/assign?server=:serverId
|
||||
* PUT /modrinth/v0/notices/:id/assign?node=:nodeId
|
||||
*/
|
||||
public async assign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise<void> {
|
||||
await this.client.request(`/notices/${id}/assign`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'PUT',
|
||||
params: this.assignmentTargetToParams(target),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassign a notice from a server or node.
|
||||
* PUT /modrinth/v0/notices/:id/unassign?server=:serverId
|
||||
* PUT /modrinth/v0/notices/:id/unassign?node=:nodeId
|
||||
*/
|
||||
public async unassign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise<void> {
|
||||
await this.client.request(`/notices/${id}/unassign`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'PUT',
|
||||
params: this.assignmentTargetToParams(target),
|
||||
})
|
||||
}
|
||||
|
||||
private assignmentTargetToParams(
|
||||
target: Archon.Notices.v0.AssignmentTarget,
|
||||
): Record<string, string> {
|
||||
if ('server' in target) {
|
||||
return { server: target.server }
|
||||
}
|
||||
|
||||
return { node: target.node }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
export class ArchonServerUsersV1Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_server_users_v1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of users with access to a server
|
||||
* GET /v1/servers/:server_id/users
|
||||
*/
|
||||
public async list(serverId: string): Promise<Archon.ServerUsers.v1.ServerUser[]> {
|
||||
return this.client.request<Archon.ServerUsers.v1.ServerUser[]>(`/servers/${serverId}/users`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user to a server
|
||||
* POST /v1/servers/:server_id/users
|
||||
*/
|
||||
public async add(
|
||||
serverId: string,
|
||||
user: Archon.ServerUsers.v1.AddServerUserRequest,
|
||||
): Promise<void> {
|
||||
await this.client.request(`/servers/${serverId}/users`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
body: user,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-send an invite to a pending server user.
|
||||
* POST /v1/servers/:server_id/users/:user_id/reinvite
|
||||
*/
|
||||
public async reinvite(
|
||||
serverId: string,
|
||||
userId: string,
|
||||
): Promise<Archon.ServerUsers.v1.ReinviteResponse> {
|
||||
return this.client.request<Archon.ServerUsers.v1.ReinviteResponse>(
|
||||
`/servers/${serverId}/users/${userId}/reinvite`,
|
||||
{
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user from a server
|
||||
* DELETE /v1/servers/:server_id/users/:user_id
|
||||
*/
|
||||
public async delete(serverId: string, userId: string): Promise<void> {
|
||||
await this.client.request(`/servers/${serverId}/users/${userId}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's server role
|
||||
* PATCH /v1/servers/:server_id/users/:user_id
|
||||
*/
|
||||
public async update(
|
||||
serverId: string,
|
||||
userId: string,
|
||||
role: Archon.ServerUsers.v1.AssignableServerUserRole,
|
||||
): Promise<void> {
|
||||
await this.client.request(`/servers/${serverId}/users/${userId}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(role),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
export class ArchonTransfersInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_transfers_internal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule transfers for specific servers.
|
||||
* POST /_internal/transfers/schedule/servers
|
||||
*/
|
||||
public async scheduleServers(
|
||||
request: Archon.Transfers.Internal.ScheduleServerTransfersRequest,
|
||||
): Promise<Archon.Transfers.Internal.ScheduleTransfersResponse> {
|
||||
return this.client.request<Archon.Transfers.Internal.ScheduleTransfersResponse>(
|
||||
'/transfers/schedule/servers',
|
||||
{
|
||||
api: 'archon',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: request,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule transfers for all servers on specific nodes.
|
||||
* POST /_internal/transfers/schedule/nodes
|
||||
*/
|
||||
public async scheduleNodes(
|
||||
request: Archon.Transfers.Internal.ScheduleNodeTransfersRequest,
|
||||
): Promise<Archon.Transfers.Internal.ScheduleTransfersResponse> {
|
||||
return this.client.request<Archon.Transfers.Internal.ScheduleTransfersResponse>(
|
||||
'/transfers/schedule/nodes',
|
||||
{
|
||||
api: 'archon',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: request,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transfer batch history.
|
||||
* GET /_internal/transfers/history
|
||||
*/
|
||||
public async history(
|
||||
options?: Archon.Transfers.Internal.TransferHistoryQuery,
|
||||
): Promise<Archon.Transfers.Internal.TransferHistoryResponse> {
|
||||
const params: Record<string, number> = {}
|
||||
if (options?.page !== undefined) params.page = options.page
|
||||
if (options?.page_size !== undefined) params.page_size = options.page_size
|
||||
|
||||
return this.client.request<Archon.Transfers.Internal.TransferHistoryResponse>(
|
||||
'/transfers/history',
|
||||
{
|
||||
api: 'archon',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
params,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending transfer batches.
|
||||
* POST /_internal/transfers/cancel
|
||||
*/
|
||||
public async cancel(
|
||||
request: Archon.Transfers.Internal.CancelTransfersRequest,
|
||||
): Promise<Archon.Transfers.Internal.CancelTransfersResponse> {
|
||||
return this.client.request<Archon.Transfers.Internal.CancelTransfersResponse>(
|
||||
'/transfers/cancel',
|
||||
{
|
||||
api: 'archon',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: request,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,287 @@
|
||||
import type { Labrinth } from '../labrinth/types'
|
||||
|
||||
export namespace Archon {
|
||||
export namespace Nodes {
|
||||
export namespace Internal {
|
||||
export type Node = {
|
||||
id: string
|
||||
hostname: string
|
||||
region: string
|
||||
created_at: string | null
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export type Server = {
|
||||
id: string
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export type NodeFull = Node & {
|
||||
servers: Server[]
|
||||
}
|
||||
|
||||
export type Overview = {
|
||||
node_hostnames: string[]
|
||||
regions: Region[]
|
||||
total_servers_active: number
|
||||
}
|
||||
|
||||
export type Region = {
|
||||
display_name: string
|
||||
country_code: string
|
||||
key: string
|
||||
server_count: number
|
||||
node_count: number
|
||||
}
|
||||
|
||||
export type RegionWithStatistics = {
|
||||
region: Region
|
||||
active_servers: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Notices {
|
||||
export namespace v0 {
|
||||
export type Notice = {
|
||||
id: number
|
||||
dismissable: boolean
|
||||
title: string | null
|
||||
message: string
|
||||
level: string
|
||||
announced: string
|
||||
}
|
||||
|
||||
export type ListedNotice = {
|
||||
id: number
|
||||
dismissable: boolean
|
||||
message: string
|
||||
title: string | null
|
||||
level: string
|
||||
announce_at: string
|
||||
expires: string | null
|
||||
assigned: Assignment[]
|
||||
dismissed_by: Dismisser[]
|
||||
}
|
||||
|
||||
export type Dismisser = {
|
||||
server: string
|
||||
dismissed_on: string
|
||||
}
|
||||
|
||||
export type Assignment = {
|
||||
kind: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AssignmentTarget = { server: string } | { node: string }
|
||||
|
||||
export type Announce = {
|
||||
message: string
|
||||
title?: string | null
|
||||
level: string
|
||||
dismissable: boolean
|
||||
announce_at: string
|
||||
expires?: string | null
|
||||
}
|
||||
|
||||
export type AnnouncePatch = {
|
||||
message?: string
|
||||
title?: string | null
|
||||
level?: string
|
||||
dismissable?: boolean
|
||||
announce_at?: string
|
||||
expires?: string | null
|
||||
}
|
||||
|
||||
export type PostNoticeResponseBody = {
|
||||
id: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Actions {
|
||||
export namespace v1 {
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export type ActionName =
|
||||
| 'server_created'
|
||||
| 'changed_server_name'
|
||||
| 'changed_server_subdomain'
|
||||
| 'server_reallocated'
|
||||
| 'server_plan_changed'
|
||||
| 'user_invited'
|
||||
| 'user_invite_revoked'
|
||||
| 'user_permission_modified'
|
||||
| 'user_removed'
|
||||
| 'addon_added'
|
||||
| 'addon_uploaded'
|
||||
| 'addon_disabled'
|
||||
| 'addon_enabled'
|
||||
| 'addon_deleted'
|
||||
| 'addon_updated'
|
||||
| 'modpack_changed'
|
||||
| 'modpack_unlinked'
|
||||
| 'server_repaired'
|
||||
| 'server_reset'
|
||||
| 'server_started'
|
||||
| 'server_stopped'
|
||||
| 'server_restarted'
|
||||
| 'server_killed'
|
||||
| 'port_allocation_added'
|
||||
| 'port_allocation_removed'
|
||||
| 'loader_version_edited'
|
||||
| 'game_version_edited'
|
||||
| 'server_properties_modified'
|
||||
| 'file_uploaded'
|
||||
| 'file_deleted'
|
||||
| 'file_renamed'
|
||||
| 'file_edited'
|
||||
| 'sftp_login'
|
||||
| 'console_command_executed'
|
||||
| 'console_cleared'
|
||||
| 'backup_created'
|
||||
| 'backup_renamed'
|
||||
| 'backup_restored'
|
||||
| 'backup_deleted'
|
||||
| 'startup_command_modified'
|
||||
| 'java_runtime_modified'
|
||||
| 'java_version_modified'
|
||||
|
||||
export type Action = {
|
||||
action: ActionName | string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type UserPermissionsActionMetadata = {
|
||||
user_id: string
|
||||
permissions?: ServerUsers.v1.UserScope | null
|
||||
}
|
||||
|
||||
export type ActionUser =
|
||||
| {
|
||||
type: 'user'
|
||||
user_id: string
|
||||
}
|
||||
| {
|
||||
type: 'support'
|
||||
user_id?: string | null
|
||||
}
|
||||
|
||||
export type ActionEntry = {
|
||||
actor: ActionUser
|
||||
action: Action
|
||||
server_id: string
|
||||
world_id?: string | null
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export type UserResp = {
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
export type AddonResp = {
|
||||
title: string
|
||||
slug?: string | null
|
||||
icon_url?: string | null
|
||||
version?: string | null
|
||||
}
|
||||
|
||||
export type VersionResp = {
|
||||
name: string
|
||||
version_number?: string | null
|
||||
}
|
||||
|
||||
export type ActionLogResponse = {
|
||||
next_offset?: number | null
|
||||
data: ActionEntry[]
|
||||
users: Record<string, UserResp>
|
||||
addons: Record<string, AddonResp>
|
||||
versions: Record<string, VersionResp>
|
||||
}
|
||||
|
||||
export type ActionLogFilter = {
|
||||
users?: string[]
|
||||
worlds?: Array<string | null>
|
||||
actions?: ActionName[]
|
||||
}
|
||||
|
||||
export type ListActionLogOptions = {
|
||||
filter?: ActionLogFilter
|
||||
limit?: number
|
||||
offset?: number
|
||||
order?: SortOrder
|
||||
min_datetime?: string
|
||||
max_datetime?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Transfers {
|
||||
export namespace Internal {
|
||||
export type ProvisionOptions = {
|
||||
region?: string | null
|
||||
node_tags: string[]
|
||||
}
|
||||
|
||||
export type ScheduleServerTransfersRequest = {
|
||||
server_ids: string[]
|
||||
scheduled_at?: string | null
|
||||
target_region?: string | null
|
||||
node_tags?: string[]
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export type ScheduleNodeTransfersRequest = {
|
||||
node_hostnames: string[]
|
||||
scheduled_at?: string | null
|
||||
target_region?: string | null
|
||||
node_tags?: string[]
|
||||
reason?: string | null
|
||||
cordon_nodes?: boolean
|
||||
tag_nodes?: string | null
|
||||
}
|
||||
|
||||
export type ScheduleTransfersResponse = {
|
||||
batch_id: number
|
||||
scheduled_count: number
|
||||
}
|
||||
|
||||
export type CancelTransfersRequest = {
|
||||
batch_ids: number[]
|
||||
}
|
||||
|
||||
export type CancelTransfersResponse = {
|
||||
cancelled_count: number
|
||||
}
|
||||
|
||||
export type TransferLogBatchEntry = {
|
||||
id: number
|
||||
created_by: string
|
||||
created_at: string
|
||||
reason?: string | null
|
||||
scheduled_at: string
|
||||
cancelled: boolean
|
||||
log_count: number
|
||||
provision_options: ProvisionOptions
|
||||
}
|
||||
|
||||
export type TransferHistoryQuery = {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export type TransferHistoryResponse = {
|
||||
batches: TransferLogBatchEntry[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Content {
|
||||
export namespace v1 {
|
||||
export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack'
|
||||
@@ -222,11 +503,66 @@ export namespace Archon {
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ServerUsers {
|
||||
export namespace v1 {
|
||||
export type ServerUserRole = 'Owner' | 'Editor' | 'Viewer' | 'Unknown'
|
||||
|
||||
export type AssignableServerUserRole = Exclude<ServerUserRole, 'Owner' | 'Unknown'>
|
||||
|
||||
export const UserScope = {
|
||||
NONE: '',
|
||||
SERVER_ADMIN: 'SERVER_ADMIN',
|
||||
BASE_READ: 'BASE_READ',
|
||||
POWER_ACTIONS: 'POWER_ACTIONS',
|
||||
EXEC_COMMANDS: 'EXEC_COMMANDS',
|
||||
FILES_WRITE: 'FILES_WRITE',
|
||||
SETUP: 'SETUP',
|
||||
BACKUPS: 'BACKUPS',
|
||||
ADVANCED: 'ADVANCED',
|
||||
RESET_SERVER: 'RESET_SERVER',
|
||||
MANAGE_USERS: 'MANAGE_USERS',
|
||||
SUPPORT_AGENT: 'SUPPORT_AGENT',
|
||||
INFRA_MANAGER: 'INFRA_MANAGER',
|
||||
INFRA_MANAGER_READ: 'INFRA_MANAGER_READ',
|
||||
INFRA_SERVERS_XFER: 'INFRA_SERVERS_XFER',
|
||||
INFRA_USERS: 'INFRA_USERS',
|
||||
} as const
|
||||
|
||||
export type UserScope = string | number
|
||||
|
||||
export type UserResp = {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
export type ServerUser = {
|
||||
user: UserResp
|
||||
added_on?: string | null
|
||||
last_invite_sent?: string | null
|
||||
permissions: UserScope
|
||||
}
|
||||
|
||||
export type AddServerUserRequest = {
|
||||
server_id?: string | null
|
||||
user_id: string
|
||||
added_on?: string | null
|
||||
role: ServerUserRole
|
||||
}
|
||||
|
||||
export type ReinviteResponse = {
|
||||
sent: boolean
|
||||
cooldown_seconds: number | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Servers {
|
||||
export namespace v0 {
|
||||
export type ServerGetResponse = {
|
||||
servers: Server[]
|
||||
pagination: Pagination
|
||||
users: Record<string, ServerOwner>
|
||||
}
|
||||
|
||||
export type Pagination = {
|
||||
@@ -236,6 +572,12 @@ export namespace Archon {
|
||||
total_items: number
|
||||
}
|
||||
|
||||
export type ServerOwner = {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
export type Status = 'installing' | 'broken' | 'available' | 'suspended'
|
||||
|
||||
export type SuspensionReason =
|
||||
@@ -281,12 +623,15 @@ export namespace Archon {
|
||||
node: NodeInfo | null
|
||||
flows: Flows
|
||||
is_medal: boolean
|
||||
current_user_permissions: UserScope
|
||||
|
||||
medal_expires?: string
|
||||
}
|
||||
|
||||
export type UserScope = number
|
||||
|
||||
export type Net = {
|
||||
ip: string
|
||||
ip: string | null
|
||||
port: number
|
||||
domain: string
|
||||
}
|
||||
@@ -422,9 +767,9 @@ export namespace Archon {
|
||||
modloader: string
|
||||
modloader_version: string
|
||||
game_version: string
|
||||
java_version: number
|
||||
invocation: string
|
||||
original_invocation: string
|
||||
java_version: number | null
|
||||
invocation: string | null
|
||||
original_invocation: string | null
|
||||
}
|
||||
|
||||
export type Region = {
|
||||
@@ -555,6 +900,106 @@ export namespace Archon {
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Sync {
|
||||
export namespace v1 {
|
||||
export type SyncCategory = 'backup' | 'users' | 'server' | 'protocol' | 'world'
|
||||
export type SyncIntent = 'all' | SyncCategory | SyncCategory[]
|
||||
export type BackupOperationStatus = 'completed' | 'cancelled' | 'failed' | 'timed-out'
|
||||
export type ServerNetworkPort = { port: number; name: string }
|
||||
|
||||
export type ProtocolResetEvent = { type: 'protocol.reset' }
|
||||
export type ProtocolInvalidEvent = { type: 'protocol.invalid' }
|
||||
export type ProtocolErrorEvent = { type: 'protocol.error'; error: string }
|
||||
|
||||
export type BackupNewEvent = { type: 'backup.new'; id: string }
|
||||
export type BackupPatchEvent = {
|
||||
type: 'backup.patch'
|
||||
world_id: string
|
||||
backup_id: string
|
||||
name: string
|
||||
}
|
||||
export type BackupDeleteEvent = {
|
||||
type: 'backup.delete'
|
||||
world_id: string
|
||||
backup_id: string
|
||||
}
|
||||
export type BackupOperationStartEvent = {
|
||||
type:
|
||||
| 'backup.operation.create.init'
|
||||
| 'backup.operation.create.start'
|
||||
| 'backup.operation.restore.init'
|
||||
| 'backup.operation.restore.start'
|
||||
world_id: string
|
||||
backup_id: string
|
||||
operation_id: number
|
||||
}
|
||||
export type BackupOperationDoneEvent = {
|
||||
type: 'backup.operation.create.done' | 'backup.operation.restore.done'
|
||||
world_id: string
|
||||
backup_id: string
|
||||
operation_id: number
|
||||
status: BackupOperationStatus
|
||||
}
|
||||
|
||||
export type ServerPatchEvent = {
|
||||
type: 'server.patch'
|
||||
name: string
|
||||
subdomain: string
|
||||
}
|
||||
export type ServerNetworkPatchEvent = {
|
||||
type: 'server.network.patch'
|
||||
ports: ServerNetworkPort[]
|
||||
}
|
||||
export type ServerTransferEvent = {
|
||||
type: 'server.transfer.start' | 'server.transfer.done'
|
||||
target_node: string
|
||||
}
|
||||
|
||||
export type UsersPatchEvent = { type: 'users.patch' }
|
||||
|
||||
export type WorldPatchEvent = {
|
||||
type: 'world.patch'
|
||||
world_id: string
|
||||
name: string
|
||||
}
|
||||
export type WorldStartupPatchEvent = {
|
||||
type: 'world.startup.patch'
|
||||
world_id: string
|
||||
java_version: number | null
|
||||
invocation: string | null
|
||||
original_invocation: string | null
|
||||
}
|
||||
export type WorldContentAddonPatchEvent = {
|
||||
type: 'world.content.addon.patch'
|
||||
world_id: string
|
||||
specs: Archon.Content.v1.Addon[]
|
||||
}
|
||||
export type WorldContentBaseUpdateEvent = {
|
||||
type: 'world.content.base.update'
|
||||
world_id: string
|
||||
spec: Archon.Content.v1.Addons
|
||||
}
|
||||
|
||||
export type SyncEvent =
|
||||
| ProtocolResetEvent
|
||||
| ProtocolInvalidEvent
|
||||
| ProtocolErrorEvent
|
||||
| BackupNewEvent
|
||||
| BackupPatchEvent
|
||||
| BackupDeleteEvent
|
||||
| BackupOperationStartEvent
|
||||
| BackupOperationDoneEvent
|
||||
| ServerPatchEvent
|
||||
| ServerNetworkPatchEvent
|
||||
| ServerTransferEvent
|
||||
| UsersPatchEvent
|
||||
| WorldPatchEvent
|
||||
| WorldStartupPatchEvent
|
||||
| WorldContentAddonPatchEvent
|
||||
| WorldContentBaseUpdateEvent
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Websocket {
|
||||
export namespace v0 {
|
||||
export type WSAuth = {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { AbstractModrinthClient } from '../core/abstract-client'
|
||||
import type { AbstractModule } from '../core/abstract-module'
|
||||
import { ArchonActionsV1Module } from './archon/actions/v1'
|
||||
import { ArchonBackupsV1Module } from './archon/backups/v1'
|
||||
import { ArchonBackupsQueueV1Module } from './archon/backups-queue/v1'
|
||||
import { ArchonContentV1Module } from './archon/content/v1'
|
||||
import { ArchonNodesInternalModule } from './archon/nodes/internal'
|
||||
import { ArchonNoticesV0Module } from './archon/notices/v0'
|
||||
import { ArchonOptionsV1Module } from './archon/options/v1'
|
||||
import { ArchonPropertiesV1Module } from './archon/properties/v1'
|
||||
import { ArchonServerUsersV1Module } from './archon/server-users/v1'
|
||||
import { ArchonServersV0Module } from './archon/servers/v0'
|
||||
import { ArchonServersV1Module } from './archon/servers/v1'
|
||||
import { ArchonTransfersInternalModule } from './archon/transfers/internal'
|
||||
import { ISO3166Module } from './iso3166'
|
||||
import { KyrosContentV1Module } from './kyros/content/v1'
|
||||
import { KyrosFilesV0Module } from './kyros/files/v0'
|
||||
@@ -21,6 +26,7 @@ import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
|
||||
import { LabrinthCampaignInternalModule } from './labrinth/campaign/internal'
|
||||
import { LabrinthCollectionsModule } from './labrinth/collections'
|
||||
import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal'
|
||||
import { LabrinthFriendsV3Module } from './labrinth/friends/v3'
|
||||
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
|
||||
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
|
||||
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
|
||||
@@ -61,13 +67,18 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
|
||||
* TODO: Better way? Probably not
|
||||
*/
|
||||
export const MODULE_REGISTRY = {
|
||||
archon_actions_v1: ArchonActionsV1Module,
|
||||
archon_backups_queue_v1: ArchonBackupsQueueV1Module,
|
||||
archon_backups_v1: ArchonBackupsV1Module,
|
||||
archon_content_v1: ArchonContentV1Module,
|
||||
archon_nodes_internal: ArchonNodesInternalModule,
|
||||
archon_notices_v0: ArchonNoticesV0Module,
|
||||
archon_options_v1: ArchonOptionsV1Module,
|
||||
archon_properties_v1: ArchonPropertiesV1Module,
|
||||
archon_server_users_v1: ArchonServerUsersV1Module,
|
||||
archon_servers_v0: ArchonServersV0Module,
|
||||
archon_servers_v1: ArchonServersV1Module,
|
||||
archon_transfers_internal: ArchonTransfersInternalModule,
|
||||
iso3166_data: ISO3166Module,
|
||||
mclogs_insights_v1: MclogsInsightsV1Module,
|
||||
mclogs_logs_v1: MclogsLogsV1Module,
|
||||
@@ -84,6 +95,7 @@ export const MODULE_REGISTRY = {
|
||||
labrinth_campaign_internal: LabrinthCampaignInternalModule,
|
||||
labrinth_collections: LabrinthCollectionsModule,
|
||||
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
|
||||
labrinth_friends_v3: LabrinthFriendsV3Module,
|
||||
labrinth_globals_internal: LabrinthGlobalsInternalModule,
|
||||
labrinth_moderation_internal: LabrinthModerationInternalModule,
|
||||
labrinth_notifications_v2: LabrinthNotificationsV2Module,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthFriendsV3Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_friends_v3'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get friends and pending friend requests for the authenticated user
|
||||
*
|
||||
* @returns Promise resolving to friend relationships
|
||||
*/
|
||||
public async list(): Promise<Labrinth.Friends.v3.UserFriend[]> {
|
||||
return this.client.request<Labrinth.Friends.v3.UserFriend[]>('/friends', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send or accept a friend request
|
||||
*
|
||||
* @param idOrUsername - The target user's ID or username
|
||||
*/
|
||||
public async add(idOrUsername: string): Promise<void> {
|
||||
return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a friend or pending friend request
|
||||
*
|
||||
* @param idOrUsername - The target user's ID or username
|
||||
*/
|
||||
public async remove(idOrUsername: string): Promise<void> {
|
||||
return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from './auth/v2'
|
||||
export * from './billing/internal'
|
||||
export * from './collections'
|
||||
export * from './external-projects/internal'
|
||||
export * from './friends/v3'
|
||||
export * from './globals/internal'
|
||||
export * from './limits/v3'
|
||||
export * from './moderation/internal'
|
||||
|
||||
@@ -1350,6 +1350,12 @@ export namespace Labrinth {
|
||||
github_id?: number
|
||||
}
|
||||
|
||||
export type SearchUser = {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
export type AllProjectsResponse = {
|
||||
projects: Projects.v3.Project[]
|
||||
organizations: Record<string, Organizations.v3.Organization>
|
||||
@@ -1357,6 +1363,17 @@ export namespace Labrinth {
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Friends {
|
||||
export namespace v3 {
|
||||
export type UserFriend = {
|
||||
id: string
|
||||
friend_id: string
|
||||
accepted: boolean
|
||||
created: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ServerPing {
|
||||
export namespace Internal {
|
||||
export type MinecraftJavaPingRequest = {
|
||||
|
||||
@@ -37,6 +37,25 @@ export class LabrinthUsersV3Module extends AbstractModule {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users by username prefix.
|
||||
*
|
||||
* @param query - Username search query
|
||||
* @returns Promise resolving to compact user search results
|
||||
*
|
||||
* GET /v3/users/search?query=:query
|
||||
*/
|
||||
public async search(query: string): Promise<Labrinth.Users.v3.SearchUser[]> {
|
||||
return this.client.request<Labrinth.Users.v3.SearchUser[]>(
|
||||
`/users/search?query=${encodeURIComponent(query)}`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all projects the authenticated user can access directly or through
|
||||
* their organizations.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { $fetch, FetchError } from 'ofetch'
|
||||
|
||||
import type { ModrinthApiError } from '../core/errors'
|
||||
import { ModrinthApiError } from '../core/errors'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
|
||||
import { GenericSyncClient } from './sync-generic'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
import { XHRUploadClient } from './xhr-upload-client'
|
||||
|
||||
@@ -34,6 +36,12 @@ export class GenericModrinthClient extends XHRUploadClient {
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
Object.defineProperty(this.archon, 'sync', {
|
||||
value: new GenericSyncClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
@@ -54,6 +62,38 @@ export class GenericModrinthClient extends XHRUploadClient {
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeStreamRequest(
|
||||
url: string,
|
||||
options: RequestOptions,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
try {
|
||||
const response = await fetch(appendRequestParams(url, options.params), {
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers,
|
||||
body: toFetchBody(options.body),
|
||||
signal: options.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw this.createNormalizedError(
|
||||
new Error(`HTTP ${response.status}: ${response.statusText}`),
|
||||
response.status,
|
||||
await parseResponseErrorData(response),
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new ModrinthApiError('Streaming response has no readable body', {
|
||||
statusCode: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
return response.body
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
protected normalizeError(error: unknown): ModrinthApiError {
|
||||
if (error instanceof FetchError) {
|
||||
return this.createNormalizedError(error, error.response?.status, error.data)
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/cir
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
|
||||
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
|
||||
import { GenericSyncClient } from './sync-generic'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
import { XHRUploadClient } from './xhr-upload-client'
|
||||
|
||||
@@ -97,6 +99,12 @@ export class NuxtModrinthClient extends XHRUploadClient {
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
Object.defineProperty(this.archon, 'sync', {
|
||||
value: new GenericSyncClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +175,40 @@ export class NuxtModrinthClient extends XHRUploadClient {
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeStreamRequest(
|
||||
url: string,
|
||||
options: RequestOptions,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
try {
|
||||
const response = await fetch(appendRequestParams(url, options.params), {
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers,
|
||||
body: toFetchBody(options.body),
|
||||
signal: options.signal,
|
||||
// @ts-expect-error - import.meta is provided by Nuxt
|
||||
cache: import.meta.server ? undefined : 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw this.createNormalizedError(
|
||||
new Error(`HTTP ${response.status}: ${response.statusText}`),
|
||||
response.status,
|
||||
await parseResponseErrorData(response),
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new ModrinthApiError('Streaming response has no readable body', {
|
||||
statusCode: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
return response.body
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
protected normalizeError(error: unknown): ModrinthApiError {
|
||||
if (error instanceof FetchError) {
|
||||
return this.createNormalizedError(error, error.response?.status, error.data)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import mitt from 'mitt'
|
||||
|
||||
import {
|
||||
AbstractSyncClient,
|
||||
type SyncConnection,
|
||||
type SyncConnectOptions,
|
||||
type SyncEmitterEvents,
|
||||
} from '../core/abstract-sync'
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
import { type ParsedSseItem, parseSyncEventData, SseParser } from '../utils/sse'
|
||||
|
||||
type StreamReadResult = 'closed' | 'protocol-reconnect'
|
||||
|
||||
const DEFAULT_RETRY_DELAY = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const JITTER_MS = 1000
|
||||
|
||||
export class GenericSyncClient extends AbstractSyncClient {
|
||||
protected emitter = mitt<SyncEmitterEvents>()
|
||||
|
||||
async safeConnectServer(serverId: string, options: SyncConnectOptions = {}): Promise<void> {
|
||||
const existing = this.connections.get(serverId)
|
||||
if (existing && !options.force && !existing.stopped && existing.status !== 'disconnected') {
|
||||
return
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
this.closeConnection(serverId)
|
||||
}
|
||||
|
||||
const connection: SyncConnection = {
|
||||
serverId,
|
||||
intent: options.intent ?? 'all',
|
||||
reconnectAttempts: 0,
|
||||
retryDelay: DEFAULT_RETRY_DELAY,
|
||||
stopped: false,
|
||||
status: 'idle',
|
||||
}
|
||||
|
||||
this.connections.set(serverId, connection)
|
||||
void this.runConnection(connection)
|
||||
}
|
||||
|
||||
disconnect(serverId: string): void {
|
||||
this.closeConnection(serverId)
|
||||
this.clearListeners(serverId)
|
||||
}
|
||||
|
||||
disconnectAll(): void {
|
||||
for (const serverId of this.connections.keys()) {
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
private async runConnection(connection: SyncConnection): Promise<void> {
|
||||
while (!connection.stopped) {
|
||||
const hadConnected = connection.status === 'connected'
|
||||
this.updateStatus(connection, hadConnected ? 'reconnecting' : 'connecting')
|
||||
|
||||
const controller = new AbortController()
|
||||
connection.controller = controller
|
||||
|
||||
try {
|
||||
const stream = await this.client.stream('/sync', {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'GET',
|
||||
params: {
|
||||
scope: `server:${connection.serverId}`,
|
||||
intent: this.intentToParam(connection.intent),
|
||||
},
|
||||
headers: connection.lastEventId
|
||||
? {
|
||||
'Last-Event-Id': connection.lastEventId,
|
||||
}
|
||||
: undefined,
|
||||
signal: controller.signal,
|
||||
retry: false,
|
||||
circuitBreaker: false,
|
||||
})
|
||||
|
||||
if (connection.stopped) return
|
||||
|
||||
connection.reconnectAttempts = 0
|
||||
this.updateStatus(connection, 'connected')
|
||||
|
||||
const result = await this.consumeStream(connection, stream)
|
||||
connection.controller = undefined
|
||||
if (connection.stopped) return
|
||||
|
||||
if (result === 'protocol-reconnect') {
|
||||
connection.reconnectAttempts = 0
|
||||
continue
|
||||
}
|
||||
|
||||
await this.waitForReconnect(connection)
|
||||
} catch (error) {
|
||||
connection.controller = undefined
|
||||
if (connection.stopped || this.isAbortError(error)) return
|
||||
|
||||
connection.reconnectAttempts++
|
||||
this.updateStatus(connection, 'error', error)
|
||||
console.warn(`[Sync] Connection failed for server ${connection.serverId}:`, error)
|
||||
await this.waitForReconnect(connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async consumeStream(
|
||||
connection: SyncConnection,
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
): Promise<StreamReadResult> {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const parser = new SseParser()
|
||||
|
||||
try {
|
||||
while (!connection.stopped) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
const result = this.processParsedItems(connection, parser.feed(chunk))
|
||||
if (result === 'protocol-reconnect') {
|
||||
await reader.cancel()
|
||||
connection.controller?.abort()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
const finalChunk = decoder.decode()
|
||||
const finalItems = finalChunk ? parser.feed(finalChunk) : []
|
||||
const result = this.processParsedItems(connection, [...finalItems, ...parser.end()])
|
||||
if (result === 'protocol-reconnect') {
|
||||
await reader.cancel()
|
||||
connection.controller?.abort()
|
||||
return result
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
private processParsedItems(connection: SyncConnection, items: ParsedSseItem[]): StreamReadResult {
|
||||
for (const item of items) {
|
||||
if (item.kind === 'retry') {
|
||||
connection.retryDelay = Math.min(item.retry, MAX_RECONNECT_DELAY)
|
||||
continue
|
||||
}
|
||||
|
||||
this.updateLastEventId(connection, item.id)
|
||||
|
||||
const event = parseSyncEventData(item.data)
|
||||
if (!event) {
|
||||
console.warn('[Sync] Dropping malformed SSE payload:', {
|
||||
serverId: connection.serverId,
|
||||
event: item.event,
|
||||
data: item.data,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
this.emitSyncEvent(connection.serverId, event)
|
||||
|
||||
if (event.type === 'protocol.reset' || event.type === 'protocol.invalid') {
|
||||
connection.lastEventId = undefined
|
||||
return 'protocol-reconnect'
|
||||
}
|
||||
}
|
||||
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
private async waitForReconnect(connection: SyncConnection): Promise<void> {
|
||||
if (connection.stopped) return
|
||||
|
||||
this.updateStatus(connection, 'reconnecting')
|
||||
const delay = this.getReconnectDelay(connection)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
connection.reconnectResolve = resolve
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
connection.reconnectTimer = undefined
|
||||
connection.reconnectResolve = undefined
|
||||
resolve()
|
||||
}, delay)
|
||||
})
|
||||
}
|
||||
|
||||
private closeConnection(serverId: string): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return
|
||||
|
||||
connection.stopped = true
|
||||
connection.controller?.abort()
|
||||
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer)
|
||||
connection.reconnectTimer = undefined
|
||||
}
|
||||
connection.reconnectResolve?.()
|
||||
connection.reconnectResolve = undefined
|
||||
|
||||
this.updateStatus(connection, 'disconnected')
|
||||
this.connections.delete(serverId)
|
||||
}
|
||||
|
||||
private getReconnectDelay(connection: SyncConnection): number {
|
||||
const exponentialDelay =
|
||||
connection.retryDelay * Math.pow(2, Math.max(connection.reconnectAttempts - 1, 0))
|
||||
return Math.min(exponentialDelay, MAX_RECONNECT_DELAY) + Math.random() * JITTER_MS
|
||||
}
|
||||
|
||||
private updateLastEventId(connection: SyncConnection, id: string | undefined): void {
|
||||
if (id === undefined) return
|
||||
connection.lastEventId = id || undefined
|
||||
}
|
||||
|
||||
private intentToParam(intent: Archon.Sync.v1.SyncIntent): string {
|
||||
return Array.isArray(intent) ? intent.join(',') : intent
|
||||
}
|
||||
|
||||
private isAbortError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
return error.name === 'AbortError' || error.message.toLowerCase().includes('abort')
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ModrinthApiError } from '../core/errors'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { appendRequestParams, parseResponseErrorData, toFetchBody } from '../utils/fetch'
|
||||
import { GenericSyncClient } from './sync-generic'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
import { XHRUploadClient } from './xhr-upload-client'
|
||||
|
||||
@@ -49,6 +51,12 @@ export class TauriModrinthClient extends XHRUploadClient {
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
Object.defineProperty(this.archon, 'sync', {
|
||||
value: new GenericSyncClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
@@ -57,36 +65,8 @@ export class TauriModrinthClient extends XHRUploadClient {
|
||||
// This allows the package to be used in non-Tauri environments
|
||||
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
|
||||
|
||||
let body: BodyInit | null | undefined = undefined
|
||||
if (options.body) {
|
||||
const raw = options.body
|
||||
if (
|
||||
typeof raw === 'object' &&
|
||||
!(raw instanceof FormData) &&
|
||||
!(raw instanceof URLSearchParams) &&
|
||||
!(raw instanceof Blob) &&
|
||||
!(raw instanceof ArrayBuffer) &&
|
||||
!ArrayBuffer.isView(raw as ArrayBufferView)
|
||||
) {
|
||||
body = JSON.stringify(raw)
|
||||
} else {
|
||||
body = raw as BodyInit
|
||||
}
|
||||
}
|
||||
|
||||
let fullUrl = url
|
||||
if (options.params) {
|
||||
const filteredParams: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(options.params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
filteredParams[key] = String(value)
|
||||
}
|
||||
}
|
||||
const queryString = new URLSearchParams(filteredParams).toString()
|
||||
if (queryString) {
|
||||
fullUrl = `${url}?${queryString}`
|
||||
}
|
||||
}
|
||||
const body = toFetchBody(options.body)
|
||||
const fullUrl = appendRequestParams(url, options.params)
|
||||
|
||||
const response = await tauriFetch(fullUrl, {
|
||||
method: options.method ?? 'GET',
|
||||
@@ -147,6 +127,41 @@ export class TauriModrinthClient extends XHRUploadClient {
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeStreamRequest(
|
||||
url: string,
|
||||
options: RequestOptions,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
try {
|
||||
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
|
||||
const response = await tauriFetch(appendRequestParams(url, options.params), {
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers,
|
||||
body: toFetchBody(options.body),
|
||||
signal: options.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw this.createNormalizedError(
|
||||
new Error(`HTTP ${response.status}: ${response.statusText}`),
|
||||
response.status,
|
||||
await parseResponseErrorData(response),
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw this.createNormalizedError(
|
||||
new Error('Streaming response has no readable body'),
|
||||
response.status,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
return response.body
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
protected normalizeError(error: unknown): ModrinthApiError {
|
||||
if (error instanceof Error) {
|
||||
const httpError = error as HttpError
|
||||
|
||||
@@ -18,9 +18,9 @@ export abstract class XHRUploadClient extends AbstractModrinthClient {
|
||||
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
|
||||
let baseUrl: string
|
||||
if (options.api === 'labrinth') {
|
||||
baseUrl = this.config.labrinthBaseUrl!
|
||||
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
|
||||
} else if (options.api === 'archon') {
|
||||
baseUrl = this.config.archonBaseUrl!
|
||||
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
|
||||
} else {
|
||||
baseUrl = options.api
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestContext } from './request'
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>
|
||||
export type UserAgentProvider = string | (() => MaybePromise<string | undefined>)
|
||||
export type BaseUrlConfig = string | (() => string)
|
||||
|
||||
/**
|
||||
* Request lifecycle hooks
|
||||
@@ -39,13 +40,15 @@ export interface ClientConfig {
|
||||
* Base URL for Labrinth API (main Modrinth API)
|
||||
* @default 'https://api.modrinth.com'
|
||||
*/
|
||||
labrinthBaseUrl?: string
|
||||
labrinthBaseUrl?: BaseUrlConfig
|
||||
|
||||
/**
|
||||
* Base URL for Archon API (Modrinth Hosting API)
|
||||
* Can be a callback so apps can drive this from runtime feature flags.
|
||||
*
|
||||
* @default 'https://archon.modrinth.com'
|
||||
*/
|
||||
archonBaseUrl?: string
|
||||
archonBaseUrl?: BaseUrlConfig
|
||||
|
||||
/**
|
||||
* Default request timeout in milliseconds
|
||||
|
||||
@@ -7,7 +7,7 @@ export type {
|
||||
} from '../features/circuit-breaker'
|
||||
export type { BackoffStrategy, RetryConfig } from '../features/retry'
|
||||
export type { Archon } from '../modules/archon/types'
|
||||
export type { ClientConfig, RequestHooks } from './client'
|
||||
export type { BaseUrlConfig, ClientConfig, RequestHooks } from './client'
|
||||
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
|
||||
export { isModrinthErrorResponse } from './errors'
|
||||
export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request'
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { RequestOptions } from '../types/request'
|
||||
|
||||
export function appendRequestParams(url: string, params?: RequestOptions['params']): string {
|
||||
if (!params) return url
|
||||
|
||||
const filteredParams: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
filteredParams[key] = String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams(filteredParams).toString()
|
||||
if (!queryString) return url
|
||||
|
||||
return `${url}${url.includes('?') ? '&' : '?'}${queryString}`
|
||||
}
|
||||
|
||||
export function toFetchBody(body: unknown): BodyInit | null | undefined {
|
||||
if (!body) return undefined
|
||||
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
!(body instanceof FormData) &&
|
||||
!(body instanceof URLSearchParams) &&
|
||||
!(body instanceof Blob) &&
|
||||
!(body instanceof ArrayBuffer) &&
|
||||
!ArrayBuffer.isView(body as ArrayBufferView)
|
||||
) {
|
||||
return JSON.stringify(body)
|
||||
}
|
||||
|
||||
return body as BodyInit
|
||||
}
|
||||
|
||||
export async function parseResponseErrorData(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''
|
||||
|
||||
try {
|
||||
if (contentType.includes('application/json') || contentType.includes('+json')) {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
if (!text) return undefined
|
||||
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
|
||||
export type ParsedSseEvent = {
|
||||
kind: 'event'
|
||||
id?: string
|
||||
event?: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export type ParsedSseRetry = {
|
||||
kind: 'retry'
|
||||
retry: number
|
||||
}
|
||||
|
||||
export type ParsedSseItem = ParsedSseEvent | ParsedSseRetry
|
||||
|
||||
export class SseParser {
|
||||
private buffer = ''
|
||||
private eventName = ''
|
||||
private data = ''
|
||||
private id: string | undefined
|
||||
|
||||
feed(chunk: string): ParsedSseItem[] {
|
||||
this.buffer += chunk
|
||||
const items: ParsedSseItem[] = []
|
||||
|
||||
while (true) {
|
||||
const lineEnd = this.findLineEnd()
|
||||
if (!lineEnd) break
|
||||
|
||||
const { line, length } = lineEnd
|
||||
this.buffer = this.buffer.slice(length)
|
||||
this.processLine(line, items)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
end(): ParsedSseItem[] {
|
||||
const items: ParsedSseItem[] = []
|
||||
|
||||
if (this.buffer.length > 0) {
|
||||
this.processLine(this.buffer.endsWith('\r') ? this.buffer.slice(0, -1) : this.buffer, items)
|
||||
this.buffer = ''
|
||||
}
|
||||
|
||||
this.dispatch(items)
|
||||
return items
|
||||
}
|
||||
|
||||
private findLineEnd(): { line: string; length: number } | null {
|
||||
const lf = this.buffer.indexOf('\n')
|
||||
const cr = this.buffer.indexOf('\r')
|
||||
|
||||
if (lf === -1 && cr === -1) return null
|
||||
|
||||
if (cr !== -1 && (lf === -1 || cr < lf)) {
|
||||
if (cr === this.buffer.length - 1) return null
|
||||
const length = this.buffer[cr + 1] === '\n' ? cr + 2 : cr + 1
|
||||
return {
|
||||
line: this.buffer.slice(0, cr),
|
||||
length,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
line: this.buffer.slice(0, lf),
|
||||
length: lf + 1,
|
||||
}
|
||||
}
|
||||
|
||||
private processLine(line: string, items: ParsedSseItem[]): void {
|
||||
if (line === '') {
|
||||
this.dispatch(items)
|
||||
return
|
||||
}
|
||||
|
||||
if (line.startsWith(':')) return
|
||||
|
||||
const colon = line.indexOf(':')
|
||||
const field = colon === -1 ? line : line.slice(0, colon)
|
||||
let value = colon === -1 ? '' : line.slice(colon + 1)
|
||||
if (value.startsWith(' ')) value = value.slice(1)
|
||||
|
||||
switch (field) {
|
||||
case 'event':
|
||||
this.eventName = value
|
||||
break
|
||||
case 'data':
|
||||
this.data += `${value}\n`
|
||||
break
|
||||
case 'id':
|
||||
this.id = value
|
||||
break
|
||||
case 'retry': {
|
||||
const retry = Number(value)
|
||||
if (Number.isInteger(retry) && retry >= 0) {
|
||||
items.push({ kind: 'retry', retry })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(items: ParsedSseItem[]): void {
|
||||
if (!this.data) {
|
||||
this.eventName = ''
|
||||
this.id = undefined
|
||||
return
|
||||
}
|
||||
|
||||
items.push({
|
||||
kind: 'event',
|
||||
id: this.id,
|
||||
event: this.eventName || undefined,
|
||||
data: this.data.endsWith('\n') ? this.data.slice(0, -1) : this.data,
|
||||
})
|
||||
|
||||
this.eventName = ''
|
||||
this.data = ''
|
||||
this.id = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSyncEventData(data: string): Archon.Sync.v1.SyncEvent | null {
|
||||
let parsed: unknown
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(data)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
const event = parsed as { type?: unknown }
|
||||
if (typeof event.type !== 'string') return null
|
||||
|
||||
return parsed as Archon.Sync.v1.SyncEvent
|
||||
}
|
||||
Reference in New Issue
Block a user