mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 01:54:47 +00:00
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,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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user