diff --git a/apps/app-frontend/src/providers/setup/server-install-content.ts b/apps/app-frontend/src/providers/setup/server-install-content.ts index 284ed294ea..fb59d2e5ec 100644 --- a/apps/app-frontend/src/providers/setup/server-install-content.ts +++ b/apps/app-frontend/src/providers/setup/server-install-content.ts @@ -1,6 +1,5 @@ -import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client' +import type { Archon, Labrinth } from '@modrinth/api-client' import { - addPendingServerContentInstalls, type BrowseInstallPlan, type BrowseSelectedProject, createContext, @@ -10,12 +9,9 @@ import { injectModrinthClient, injectNotificationManager, type ModpackSearchResult, - type PendingServerContentInstall, - type PendingServerContentInstallType, - readPendingServerContentInstalls, readStoredServerInstallQueue, - removePendingServerContentInstall, - writePendingServerContentInstallBaseline, + useServerContextRuntime, + waitForServerContextRuntimeReady, writeStoredServerInstallQueue, } from '@modrinth/ui' import { useQueryClient } from '@tanstack/vue-query' @@ -39,7 +35,6 @@ type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & { installing?: boolean installed?: boolean } -type PendingServerContentInstallInput = Omit export interface ServerModpackSelectionRequest { projectId: string @@ -105,114 +100,6 @@ function readQueryString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null } -function getQueuedInstallOwnerFallback(project: InstallableSearchResult) { - if (project.organization) { - const ownerId = project.organization_id ?? project.organization - return { - id: ownerId, - name: project.organization, - type: 'organization' as const, - link: `https://modrinth.com/organization/${ownerId}`, - } - } - - if (!project.author) return null - - const ownerId = project.author_id ?? project.author - return { - id: ownerId, - name: project.author, - type: 'user' as const, - link: `/user/${encodeURIComponent(ownerId)}`, - } -} - -async function getQueuedInstallOwner( - client: AbstractModrinthClient, - project: InstallableSearchResult, -) { - const fallback = getQueuedInstallOwnerFallback(project) - - try { - if (project.organization) { - const organization = await client.labrinth.projects_v3.getOrganization(project.project_id) - if (organization) { - return { - id: organization.id, - name: organization.name, - type: 'organization' as const, - avatar_url: organization.icon_url ?? undefined, - link: `https://modrinth.com/organization/${organization.slug}`, - } - } - } - - const members = await client.labrinth.projects_v3.getMembers(project.project_id) - const owner = - members.find((member) => member.user.id === project.author_id)?.user ?? - members.find((member) => member.is_owner || member.role === 'Owner')?.user ?? - members[0]?.user - - if (owner) { - return { - id: owner.id, - name: owner.username, - type: 'user' as const, - avatar_url: owner.avatar_url, - link: `/user/${encodeURIComponent(owner.username)}`, - } - } - } catch { - return fallback - } - - return fallback -} - -function getQueuedAddonInstallPlans( - plans: Map>, -) { - return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack') -} - -function getQueuedInstallPlaceholder( - plan: BrowseInstallPlan, - owner: PendingServerContentInstallInput['owner'], -): PendingServerContentInstallInput { - const project = plan.project as InstallableSearchResult & { slug?: string | null } - return { - projectId: plan.projectId, - versionId: plan.versionId, - contentType: plan.contentType as PendingServerContentInstallType, - title: project.name ?? 'Project', - versionName: plan.versionName ?? null, - versionNumber: plan.versionNumber ?? null, - fileName: plan.fileName ?? null, - owner, - slug: project.slug ?? plan.projectId, - iconUrl: project.icon_url ?? null, - } -} - -function getQueuedInstallPlaceholderFallbacks( - plans: Map>, -) { - return getQueuedAddonInstallPlans(plans).map((plan) => - getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)), - ) -} - -async function getQueuedInstallPlaceholders( - client: AbstractModrinthClient, - plans: Map>, -) { - return Promise.all( - getQueuedAddonInstallPlans(plans).map(async (plan) => - getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)), - ), - ) -} - export function createServerInstallContent(opts: { serverSetupModalRef: Ref isRouteInContext?: (route: RouteLocationNormalizedLoaded) => boolean @@ -249,12 +136,12 @@ export function createServerInstallContent(opts: { const isFromWorlds = computed(() => browseFrom.value === 'worlds') const isServerContext = computed(() => !!serverIdQuery.value) const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value) + useServerContextRuntime(serverIdQuery) const serverContextWorldId = ref(worldIdQuery.value) const serverContextServerData = ref(null) const serverContextServerFull = ref(null) const serverContentProjectIds = ref>(new Set()) - const serverContentInstallKeys = ref>(new Set()) const queuedServerInstalls = ref>>( new Map(), ) @@ -358,11 +245,7 @@ export function createServerInstallContent(opts: { .map((addon) => addon.project_id) .filter((projectId): projectId is string => !!projectId), ) - const keys = new Set( - (content.addons ?? []).map((addon) => addon.project_id ?? addon.filename), - ) serverContentProjectIds.value = ids - serverContentInstallKeys.value = keys } catch (err) { handleError(err as Error) } @@ -407,7 +290,6 @@ export function createServerInstallContent(opts: { serverContextServerData.value = null serverContextServerFull.value = null serverContentProjectIds.value = new Set() - serverContentInstallKeys.value = new Set() setQueuedServerInstallPlans(new Map()) return } @@ -431,7 +313,6 @@ export function createServerInstallContent(opts: { serverContextServerFull.value = null } serverContentProjectIds.value = new Set() - serverContentInstallKeys.value = new Set() } if (!hasServerDataForRoute) { @@ -559,6 +440,13 @@ export function createServerInstallContent(opts: { const queuedPlans = getStoredServerAddonInstallQueue(serverId, worldId) if (queuedPlans.size === 0) return true + try { + await waitForServerContextRuntimeReady(client, serverId) + } catch (error) { + handleError(error as Error) + return false + } + isInstallingQueuedServerInstalls.value = true queuedInstallProgress.value = { completed: 0, @@ -583,9 +471,6 @@ export function createServerInstallContent(opts: { }) if (!result.ok) { - for (const plan of result.attemptedPlans) { - removePendingServerContentInstall(serverId, worldId, plan.projectId) - } handleError(result.error as Error) return false } @@ -598,10 +483,6 @@ export function createServerInstallContent(opts: { ...serverContentProjectIds.value, ...result.flushedPlans.map((plan) => plan.projectId), ]) - serverContentInstallKeys.value = new Set([ - ...serverContentInstallKeys.value, - ...result.flushedPlans.map((plan) => plan.projectId), - ]) if (result.flushedPlans.length > 0) { await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }) } @@ -626,20 +507,6 @@ export function createServerInstallContent(opts: { if (sid && wid) { writeStoredServerInstallQueue(sid, wid, plans) - writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value) - addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans)) - void getQueuedInstallPlaceholders(client, plans) - .then((items) => { - const pendingProjectIds = new Set( - readPendingServerContentInstalls(sid, wid).map((item) => item.projectId), - ) - addPendingServerContentInstalls( - sid, - wid, - items.filter((item) => pendingProjectIds.has(item.projectId)), - ) - }) - .catch((err) => handleError(err as Error)) } await router.push(backUrl) void flushQueuedServerInstalls(sid, wid) diff --git a/apps/frontend/src/composables/use-server-install-content.ts b/apps/frontend/src/composables/use-server-install-content.ts index fe45b39938..44dd8a40d3 100644 --- a/apps/frontend/src/composables/use-server-install-content.ts +++ b/apps/frontend/src/composables/use-server-install-content.ts @@ -6,11 +6,8 @@ import type { CreationFlowContextValue, EnvironmentSearchOverride, FilterValue, - PendingServerContentInstall, - PendingServerContentInstallType, } from '@modrinth/ui' import { - addPendingServerContentInstalls, commonMessages, defineMessages, flushStoredServerAddonInstallQueue, @@ -18,14 +15,13 @@ import { getTargetInstallPreferences, injectModrinthClient, injectNotificationManager, - readPendingServerContentInstalls, readStoredServerInstallQueue, - removePendingServerContentInstall, requestInstall, stripServerRuntimeInstallFilters, stripServerRuntimeInstallOverrides, + useServerContextRuntime, useVIntl, - writePendingServerContentInstallBaseline, + waitForServerContextRuntimeReady, writeStoredServerInstallQueue, } from '@modrinth/ui' import { useQuery, useQueryClient } from '@tanstack/vue-query' @@ -35,7 +31,6 @@ import { computed, nextTick, ref, watch } from 'vue' import { navigateTo, useRoute } from '#app' import { queryAsString } from '~/utils/router' -type PendingServerContentInstallInput = Omit type ServerInstallBrowseSearchState = Pick< BrowseSearchState, 'currentFilters' | 'overriddenProvidedFilterTypes' @@ -100,34 +95,6 @@ const messages = defineMessages({ }, }) -function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) { - if (project.organization) { - const ownerId = project.organization_id ?? project.organization - return { - id: ownerId, - name: project.organization, - type: 'organization' as const, - link: `/organization/${ownerId}`, - } - } - - if (!project.author) return null - - const ownerId = project.author_id ?? project.author - return { - id: ownerId, - name: project.author, - type: 'user' as const, - link: `/user/${ownerId}`, - } -} - -function getQueuedAddonInstallPlans( - plans: Map>, -) { - return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack') -} - export function useServerInstallContent({ projectType, onboardingModalRef, @@ -148,6 +115,7 @@ export function useServerInstallContent({ const currentServerId = computed(() => queryAsString(route.query.sid) || null) const fromContext = computed(() => queryAsString(route.query.from) || null) const currentWorldId = computed(() => queryAsString(route.query.wid) || null) + useServerContextRuntime(currentServerId) const { data: serverData, @@ -277,81 +245,6 @@ export function useServerInstallContent({ writeStoredServerInstallQueue(serverId, worldId, plans) } - async function getQueuedInstallOwner(project: ServerInstallSearchResult) { - const fallback = getQueuedInstallOwnerFallback(project) - - try { - if (project.organization) { - const organization = await client.labrinth.projects_v3.getOrganization(project.project_id) - if (organization) { - return { - id: organization.id, - name: organization.name, - type: 'organization' as const, - avatar_url: organization.icon_url ?? undefined, - link: `/organization/${organization.slug}`, - } - } - } - - const members = await client.labrinth.projects_v3.getMembers(project.project_id) - const owner = - members.find((member) => member.user.id === project.author_id)?.user ?? - members.find((member) => member.is_owner || member.role === 'Owner')?.user ?? - members[0]?.user - - if (owner) { - return { - id: owner.id, - name: owner.username, - type: 'user' as const, - avatar_url: owner.avatar_url, - link: `/user/${owner.username}`, - } - } - } catch { - return fallback - } - - return fallback - } - - function getQueuedInstallPlaceholder( - plan: BrowseInstallPlan, - owner: PendingServerContentInstallInput['owner'], - ): PendingServerContentInstallInput { - return { - projectId: plan.projectId, - versionId: plan.versionId, - contentType: plan.contentType as PendingServerContentInstallType, - title: getInstallProjectName(plan.project), - versionName: plan.versionName ?? null, - versionNumber: plan.versionNumber ?? null, - fileName: plan.fileName ?? null, - owner, - slug: plan.project.slug ?? plan.projectId, - iconUrl: plan.project.icon_url ?? null, - } - } - - function getQueuedInstallPlaceholderFallbacks( - plans: Map>, - ) { - return getQueuedAddonInstallPlans(plans).map((plan) => - getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)), - ) - } - - async function getQueuedInstallPlaceholders( - plans: Map>, - ) { - return Promise.all( - getQueuedAddonInstallPlans(plans).map(async (plan) => - getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)), - ), - ) - } - function setProjectInstalling(projectId: string, installing: boolean) { const next = new Set(installingProjectIds.value) if (installing) { @@ -377,10 +270,6 @@ export function useServerInstallContent({ ) } - function getServerInstalledContentKeys(data = serverContentData.value) { - return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename)) - } - function syncHiddenInstalledProjectIds() { hiddenInstalledProjectIds.value = new Set([ ...getServerInstalledProjectIds(), @@ -561,6 +450,13 @@ export function useServerInstallContent({ ) if (queuedPlans.size === 0) return true + try { + await waitForServerContextRuntimeReady(client, serverId) + } catch (error) { + handleError(error as Error) + return false + } + isInstallingQueuedServerInstalls.value = true queuedInstallProgress.value = { completed: 0, @@ -581,9 +477,6 @@ export function useServerInstallContent({ }) if (!result.ok) { - for (const plan of result.attemptedPlans) { - removePendingServerContentInstall(serverId, worldId, plan.projectId) - } handleError(result.error as Error) return false } @@ -622,23 +515,6 @@ export function useServerInstallContent({ if (sid && wid) { writeStoredServerInstallQueue(sid, wid, plans) - writePendingServerContentInstallBaseline(sid, wid, [ - ...getServerInstalledContentKeys(), - ...optimisticallyInstalledProjectIds.value, - ]) - addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans)) - void getQueuedInstallPlaceholders(plans) - .then((items) => { - const pendingProjectIds = new Set( - readPendingServerContentInstalls(sid, wid).map((item) => item.projectId), - ) - addPendingServerContentInstalls( - sid, - wid, - items.filter((item) => pendingProjectIds.has(item.projectId)), - ) - }) - .catch((err) => handleError(err as Error)) } await navigateTo(backUrl) void flushQueuedServerInstalls(sid, wid) diff --git a/packages/api-client/src/core/abstract-websocket.ts b/packages/api-client/src/core/abstract-websocket.ts index 8d3e54ee29..784bf50a40 100644 --- a/packages/api-client/src/core/abstract-websocket.ts +++ b/packages/api-client/src/core/abstract-websocket.ts @@ -9,6 +9,7 @@ export type WebSocketEventHandler< export interface WebSocketConnection { serverId: string socket: WebSocket + authenticated: boolean reconnectAttempts: number reconnectTimer?: ReturnType isReconnecting: boolean @@ -31,6 +32,7 @@ export abstract class AbstractWebSocketClient { protected readonly MAX_RECONNECT_ATTEMPTS = 10 protected readonly RECONNECT_BASE_DELAY = 1000 protected readonly RECONNECT_MAX_DELAY = 30000 + protected readonly AUTHENTICATION_TIMEOUT = 30000 constructor( protected client: { @@ -58,6 +60,7 @@ export abstract class AbstractWebSocketClient { } if (status && !status.connected && !options?.force) { + await this.waitForAuthentication(serverId) return } @@ -69,6 +72,28 @@ export abstract class AbstractWebSocketClient { await this.connect(serverId, auth) } + protected async waitForAuthentication(serverId: string): Promise { + await new Promise((resolve, reject) => { + let unsubscribe = () => {} + const timeout = setTimeout(() => { + unsubscribe() + reject(new Error(`WebSocket authentication timed out for server ${serverId}`)) + }, this.AUTHENTICATION_TIMEOUT) + + unsubscribe = this.on(serverId, 'auth-ok', () => { + clearTimeout(timeout) + unsubscribe() + resolve() + }) + + if (this.getStatus(serverId)?.connected) { + clearTimeout(timeout) + unsubscribe() + resolve() + } + }) + } + on( serverId: string, eventType: E, @@ -88,7 +113,7 @@ export abstract class AbstractWebSocketClient { if (!connection) return null return { - connected: connection.socket.readyState === WebSocket.OPEN, + connected: connection.socket.readyState === WebSocket.OPEN && connection.authenticated, reconnecting: connection.isReconnecting, reconnectAttempts: connection.reconnectAttempts, } diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index 93bf7ee934..b281f42620 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -1152,9 +1152,12 @@ export namespace Archon { export type InstallProgressFileKey = { type: 'file' - parent_directory: string - filename: string install_type: 'install' | 'update' + project_id: string + version_id: string + parent_directory: string + source_filename: string | null + target_filename?: string | null } export type InstallProgressModrinthModpackKey = { diff --git a/packages/api-client/src/platform/websocket-generic.ts b/packages/api-client/src/platform/websocket-generic.ts index 19aa098f45..e66bc05095 100644 --- a/packages/api-client/src/platform/websocket-generic.ts +++ b/packages/api-client/src/platform/websocket-generic.ts @@ -19,12 +19,14 @@ export class GenericWebSocketClient extends AbstractWebSocketClient { } return new Promise((resolve, reject) => { + let settled = false try { const ws = new WebSocket(getNodeWebSocketUrl(auth.url)) const connection: WebSocketConnection = { serverId, socket: ws, + authenticated: false, reconnectAttempts: 0, reconnectTimer: undefined, isReconnecting: false, @@ -37,18 +39,26 @@ export class GenericWebSocketClient extends AbstractWebSocketClient { connection.reconnectAttempts = 0 connection.isReconnecting = false - - resolve() } ws.onmessage = (messageEvent) => { try { const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent + if (data.event === 'auth-ok') { + connection.authenticated = true + } else if (data.event === 'auth-incorrect') { + connection.authenticated = false + } 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-ok' && !settled) { + settled = true + resolve() + } + if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') { this.handleAuthExpiring(serverId).catch(console.error) } @@ -58,11 +68,20 @@ export class GenericWebSocketClient extends AbstractWebSocketClient { } ws.onclose = (event) => { + connection.authenticated = false console.debug(`[WebSocket] Closed for server ${serverId}:`, { code: event.code, reason: event.reason, wasClean: event.wasClean, }) + if (!settled) { + settled = true + reject( + new Error( + `WebSocket closed before authentication for server ${serverId} (code: ${event.code})`, + ), + ) + } if (event.code !== NORMAL_CLOSURE) { this.scheduleReconnect(serverId, auth) } @@ -77,13 +96,17 @@ export class GenericWebSocketClient extends AbstractWebSocketClient { readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState], type: (event as Event).type, }) - reject( - new Error( - `WebSocket connection failed for server ${serverId} (readyState: ${readyState})`, - ), - ) + if (!settled) { + settled = true + reject( + new Error( + `WebSocket connection failed for server ${serverId} (readyState: ${readyState})`, + ), + ) + } } } catch (error) { + settled = true reject(error) } }) diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index 88e5f8ce7c..dc1359fcab 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -11,6 +11,7 @@ export * from './i18n' export * from './i18n-debug' export * from './page-leave-safety' export * from './scroll-indicator' +export * from './server-context-runtime' export * from './server-permissions' export * from './servers/server-backup' export * from './servers/server-backups-queue' diff --git a/packages/ui/src/composables/server-context-runtime.ts b/packages/ui/src/composables/server-context-runtime.ts new file mode 100644 index 0000000000..7c5616051b --- /dev/null +++ b/packages/ui/src/composables/server-context-runtime.ts @@ -0,0 +1,350 @@ +import type { AbstractModrinthClient, Archon } from '@modrinth/api-client' +import type { ComputedRef, Ref } from 'vue' +import { onUnmounted, ref, watch } from 'vue' + +import { injectModrinthClient } from '../providers' + +type ReadableRef = Ref | ComputedRef +type RuntimeUnsubscriber = () => void + +type RuntimeReadyWaiter = { + resolve: () => void + reject: (error: Error) => void + timeout: ReturnType +} + +type ServerContextRuntime = { + client: AbstractModrinthClient + serverId: string + leases: number + socketLeases: number + syncLeases: number + releaseTimer: ReturnType | null + socketReleaseTimer: ReturnType | null + syncReleaseTimer: ReturnType | null + connectPromise: Promise | null + socketUnsubscribers: RuntimeUnsubscriber[] + installProgressItems: Ref + isSocketAuthenticated: Ref + isSocketAuthIncorrect: Ref + hasAuthoritativeInstallProgress: Ref + readyWaiters: Set + destroyed: boolean +} + +export type ServerContextRuntimeLease = { + serverId: string + installProgressItems: Ref + isSocketAuthenticated: Ref + isSocketAuthIncorrect: Ref + hasAuthoritativeInstallProgress: Ref + waitUntilReady: () => Promise + release: () => void +} + +type RetainServerContextRuntimeOptions = { + connect?: boolean + socket?: boolean + sync?: boolean +} + +const runtimeReleaseDelay = 1000 +const authoritativeReadinessTimeout = 30000 +const runtimesByClient = new WeakMap>() + +function getClientRuntimes(client: AbstractModrinthClient) { + let runtimes = runtimesByClient.get(client) + if (!runtimes) { + runtimes = new Map() + runtimesByClient.set(client, runtimes) + } + return runtimes +} + +function isRuntimeReady(runtime: ServerContextRuntime) { + return runtime.isSocketAuthenticated.value && runtime.hasAuthoritativeInstallProgress.value +} + +function resolveReadyWaiters(runtime: ServerContextRuntime) { + if (!isRuntimeReady(runtime)) return + + for (const waiter of runtime.readyWaiters) { + clearTimeout(waiter.timeout) + waiter.resolve() + } + runtime.readyWaiters.clear() +} + +function createServerContextRuntime( + client: AbstractModrinthClient, + serverId: string, +): ServerContextRuntime { + const runtime: ServerContextRuntime = { + client, + serverId, + leases: 0, + socketLeases: 0, + syncLeases: 0, + releaseTimer: null, + socketReleaseTimer: null, + syncReleaseTimer: null, + connectPromise: null, + socketUnsubscribers: [], + installProgressItems: ref([]), + isSocketAuthenticated: ref(false), + isSocketAuthIncorrect: ref(false), + hasAuthoritativeInstallProgress: ref(false), + readyWaiters: new Set(), + destroyed: false, + } + + return runtime +} + +function attachRuntimeSocketListeners(runtime: ServerContextRuntime) { + if (runtime.socketUnsubscribers.length > 0) return + + runtime.socketUnsubscribers = [ + runtime.client.archon.sockets.on(runtime.serverId, 'auth-ok', () => { + runtime.isSocketAuthenticated.value = true + runtime.isSocketAuthIncorrect.value = false + runtime.hasAuthoritativeInstallProgress.value = false + }), + runtime.client.archon.sockets.on(runtime.serverId, 'auth-incorrect', () => { + runtime.isSocketAuthenticated.value = false + runtime.isSocketAuthIncorrect.value = true + runtime.hasAuthoritativeInstallProgress.value = false + }), + runtime.client.archon.sockets.on(runtime.serverId, 'install-progress', (event) => { + runtime.installProgressItems.value = event.items + runtime.hasAuthoritativeInstallProgress.value = true + resolveReadyWaiters(runtime) + }), + ] +} + +function disconnectRuntimeSocket(runtime: ServerContextRuntime) { + for (const unsubscribe of runtime.socketUnsubscribers) unsubscribe() + runtime.socketUnsubscribers = [] + runtime.client.archon.sockets.disconnect(runtime.serverId) + runtime.connectPromise = null + runtime.isSocketAuthenticated.value = false + runtime.isSocketAuthIncorrect.value = false + runtime.hasAuthoritativeInstallProgress.value = false + for (const waiter of runtime.readyWaiters) { + clearTimeout(waiter.timeout) + waiter.reject(new Error(`Node socket for server ${runtime.serverId} was released`)) + } + runtime.readyWaiters.clear() +} + +function disconnectRuntimeSync(runtime: ServerContextRuntime) { + runtime.client.archon.sync.disconnect(runtime.serverId) +} + +async function ensureRuntimeConnections( + runtime: ServerContextRuntime, + options: RetainServerContextRuntimeOptions = {}, +) { + if (runtime.destroyed) { + throw new Error(`Server context runtime for ${runtime.serverId} has been released`) + } + + const shouldConnectSocket = options.socket !== false + const shouldConnectSync = options.sync !== false + const socketStatus = runtime.client.archon.sockets.getStatus(runtime.serverId) + if (shouldConnectSocket && !socketStatus?.connected) { + attachRuntimeSocketListeners(runtime) + runtime.isSocketAuthenticated.value = false + runtime.hasAuthoritativeInstallProgress.value = false + } + + if (shouldConnectSync) { + void runtime.client.archon.sync + .safeConnectServer(runtime.serverId, { intent: 'all' }) + .catch((error) => { + console.warn( + `[server-context-runtime] Failed to connect sync stream for ${runtime.serverId}:`, + error, + ) + }) + } + + if (shouldConnectSocket && !runtime.connectPromise) { + const connectPromise = runtime.client.archon.sockets + .safeConnect(runtime.serverId) + .then(() => { + runtime.isSocketAuthenticated.value = true + }) + .finally(() => { + if (runtime.connectPromise === connectPromise) { + runtime.connectPromise = null + } + }) + runtime.connectPromise = connectPromise + } + + if (runtime.connectPromise) await runtime.connectPromise +} + +async function waitUntilRuntimeReady(runtime: ServerContextRuntime) { + await ensureRuntimeConnections(runtime) + if (isRuntimeReady(runtime)) return + + await new Promise((resolve, reject) => { + const waiter: RuntimeReadyWaiter = { + resolve, + reject, + timeout: setTimeout(() => { + runtime.readyWaiters.delete(waiter) + reject( + new Error( + `Timed out waiting for authoritative install progress for server ${runtime.serverId}`, + ), + ) + }, authoritativeReadinessTimeout), + } + runtime.readyWaiters.add(waiter) + resolveReadyWaiters(runtime) + }) +} + +function destroyRuntime(runtime: ServerContextRuntime) { + if (runtime.destroyed || runtime.leases > 0) return + runtime.destroyed = true + + if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer) + if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer) + disconnectRuntimeSocket(runtime) + disconnectRuntimeSync(runtime) + + getClientRuntimes(runtime.client).delete(runtime.serverId) +} + +export function retainServerContextRuntime( + client: AbstractModrinthClient, + serverId: string, + options: RetainServerContextRuntimeOptions = {}, +): ServerContextRuntimeLease { + const runtimes = getClientRuntimes(client) + let runtime = runtimes.get(serverId) + if (!runtime) { + runtime = createServerContextRuntime(client, serverId) + runtimes.set(serverId, runtime) + } + + if (runtime.releaseTimer) { + clearTimeout(runtime.releaseTimer) + runtime.releaseTimer = null + } + const retainSocket = options.socket !== false + const retainSync = options.sync !== false + if (retainSocket) { + if (runtime.socketReleaseTimer) { + clearTimeout(runtime.socketReleaseTimer) + runtime.socketReleaseTimer = null + } + attachRuntimeSocketListeners(runtime) + runtime.socketLeases += 1 + } + if (retainSync) { + if (runtime.syncReleaseTimer) { + clearTimeout(runtime.syncReleaseTimer) + runtime.syncReleaseTimer = null + } + runtime.syncLeases += 1 + } + runtime.leases += 1 + if (options.connect !== false) { + void ensureRuntimeConnections(runtime, options).catch((error) => { + if (runtime && runtime.leases > 0) { + console.warn( + `[server-context-runtime] Failed to connect node socket for ${serverId}:`, + error, + ) + } + }) + } + + let released = false + return { + serverId, + installProgressItems: runtime.installProgressItems, + isSocketAuthenticated: runtime.isSocketAuthenticated, + isSocketAuthIncorrect: runtime.isSocketAuthIncorrect, + hasAuthoritativeInstallProgress: runtime.hasAuthoritativeInstallProgress, + waitUntilReady: () => waitUntilRuntimeReady(runtime), + release: () => { + if (released) return + released = true + runtime.leases = Math.max(0, runtime.leases - 1) + if (retainSocket) { + runtime.socketLeases = Math.max(0, runtime.socketLeases - 1) + } + if (retainSync) { + runtime.syncLeases = Math.max(0, runtime.syncLeases - 1) + } + + if (runtime.leases === 0) { + if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer) + if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer) + runtime.socketReleaseTimer = null + runtime.syncReleaseTimer = null + runtime.releaseTimer = setTimeout(() => { + runtime.releaseTimer = null + destroyRuntime(runtime) + }, runtimeReleaseDelay) + return + } + + if (retainSocket && runtime.socketLeases === 0) { + runtime.socketReleaseTimer = setTimeout(() => { + runtime.socketReleaseTimer = null + if (runtime.socketLeases === 0) disconnectRuntimeSocket(runtime) + }, runtimeReleaseDelay) + } + if (retainSync && runtime.syncLeases === 0) { + runtime.syncReleaseTimer = setTimeout(() => { + runtime.syncReleaseTimer = null + if (runtime.syncLeases === 0) disconnectRuntimeSync(runtime) + }, runtimeReleaseDelay) + } + }, + } +} + +export function useServerContextRuntime(serverId: ReadableRef) { + const client = injectModrinthClient() + let lease: ServerContextRuntimeLease | null = null + + const stop = watch( + () => serverId.value, + (nextServerId) => { + lease?.release() + lease = null + + if (typeof window !== 'undefined' && nextServerId) { + lease = retainServerContextRuntime(client, nextServerId) + } + }, + { immediate: true }, + ) + + onUnmounted(() => { + stop() + lease?.release() + lease = null + }) +} + +export async function waitForServerContextRuntimeReady( + client: AbstractModrinthClient, + serverId: string, +) { + const lease = retainServerContextRuntime(client, serverId) + try { + await lease.waitUntilReady() + } finally { + lease.release() + } +} diff --git a/packages/ui/src/composables/server-panel-sync.ts b/packages/ui/src/composables/server-panel-sync.ts index fa9d7f9ef3..9ec5e07789 100644 --- a/packages/ui/src/composables/server-panel-sync.ts +++ b/packages/ui/src/composables/server-panel-sync.ts @@ -5,6 +5,11 @@ import { onMounted, onUnmounted, watch } from 'vue' import { injectModrinthClient } from '#ui/providers' +import { + retainServerContextRuntime, + type ServerContextRuntimeLease, +} from './server-context-runtime' + type ReadableRef = Ref | ComputedRef type SyncUnsubscriber = () => void @@ -20,6 +25,7 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) { const queryClient = useQueryClient() let activeServerId: string | null = null + let runtimeLease: ServerContextRuntimeLease | null = null let unsubscribers: SyncUnsubscriber[] = [] let mounted = false let actionLogInvalidateTimer: ReturnType | null = null @@ -43,12 +49,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) { unsubscribers = [ client.archon.sync.onAny(targetServerId, (event) => handleSyncEvent(targetServerId, event)), ] - - void client.archon.sync.safeConnectServer(targetServerId, { intent: 'all' }).catch((error) => { - console.warn( - `[server-panel-sync] Failed to connect sync stream for ${targetServerId}:`, - error, - ) + runtimeLease = retainServerContextRuntime(client, targetServerId, { + socket: false, + sync: true, }) } @@ -61,10 +64,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) { for (const unsubscribe of unsubscribers) unsubscribe() unsubscribers = [] - if (activeServerId) { - client.archon.sync.disconnect(activeServerId) - activeServerId = null - } + runtimeLease?.release() + runtimeLease = null + activeServerId = null } function handleSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent) { diff --git a/packages/ui/src/composables/servers/server-manage-core-runtime.ts b/packages/ui/src/composables/servers/server-manage-core-runtime.ts index 213624df7a..3ab5f8c81e 100644 --- a/packages/ui/src/composables/servers/server-manage-core-runtime.ts +++ b/packages/ui/src/composables/servers/server-manage-core-runtime.ts @@ -5,20 +5,23 @@ import { type UploadState, } from '@modrinth/api-client' import type { ComputedRef, Ref } from 'vue' -import { computed, ref } from 'vue' +import { computed, ref, watch } from 'vue' import type { FileOperation } from '../../layouts/shared/files-tab/types' import { injectModrinthClient, provideModrinthServerContext } from '../../providers' import type { BusyReason, CancelUploadHandler, ServerStats } from '../../providers/server-context' import { defineMessage } from '../i18n' -import { useModrinthServersConsole } from './server-console' +import { + retainServerContextRuntime, + type ServerContextRuntimeLease, +} from '../server-context-runtime' import { useServerInstallationTracker } from '../server-installation-tracker' +import { useModrinthServersConsole } from './server-console' type ReadableRef = Ref | ComputedRef type SocketUnsubscriber = () => void type ConnectSocketOptions = { - force?: boolean extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[] } @@ -113,6 +116,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp const socketUnsubscribers = ref([]) const cpuData = ref([]) const ramData = ref([]) + let serverContextRuntimeLease: ServerContextRuntimeLease | null = null let uptimeIntervalId: ReturnType | null = null let staleStatsTimeoutId: ReturnType | null = null @@ -270,25 +274,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp startUptimeTicker() } - const handleInstallProgressEvent = (data: Archon.Websocket.v0.WSInstallProgressEvent) => { - if (!shouldProcessEvent()) return - handleInstallProgress(data.items) - } - - const handleAuthIncorrect = () => { - if (!shouldProcessEvent()) return - isWsAuthIncorrect.value = true - if (options.setDisconnectedOnAuthIncorrect) { - isConnected.value = false - } - } - - const handleAuthOk = () => { - if (!shouldProcessEvent()) return - isWsAuthIncorrect.value = false - isConnected.value = true - } - const clearSocketListeners = () => { for (const unsub of socketUnsubscribers.value) unsub() socketUnsubscribers.value = [] @@ -298,10 +283,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp if (!targetServerId && !connectedSocketServerId.value) return clearSocketListeners() - - if (targetServerId) { - client.archon.sockets.disconnect(targetServerId) - } + serverContextRuntimeLease?.release() + serverContextRuntimeLease = null stopUptimeTicker() clearStaleStatsTimers() @@ -328,6 +311,12 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp disconnectSocket(connectedSocketServerId.value ?? undefined) try { + const runtimeLease = retainServerContextRuntime(client, targetServerId, { + connect: false, + }) + serverContextRuntimeLease = runtimeLease + connectedSocketServerId.value = targetServerId + const baseSubscriptions: SocketUnsubscriber[] = [ client.archon.sockets.on(targetServerId, 'log', handleLog), client.archon.sockets.on(targetServerId, 'log4j', handleLog4j), @@ -335,27 +324,45 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp client.archon.sockets.on(targetServerId, 'state', handleState), client.archon.sockets.on(targetServerId, 'power-state', handlePowerState), client.archon.sockets.on(targetServerId, 'uptime', handleUptime), - client.archon.sockets.on(targetServerId, 'install-progress', handleInstallProgressEvent), - client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect), - client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk), + watch( + runtimeLease.installProgressItems, + (items) => { + if (shouldProcessEvent()) handleInstallProgress(items) + }, + { immediate: true }, + ), + watch( + runtimeLease.isSocketAuthenticated, + (authenticated) => { + if (!shouldProcessEvent()) return + if (authenticated || options.setDisconnectedOnAuthIncorrect) { + isConnected.value = authenticated + } + }, + { immediate: true }, + ), + watch( + runtimeLease.isSocketAuthIncorrect, + (authIncorrect) => { + if (shouldProcessEvent()) isWsAuthIncorrect.value = authIncorrect + }, + { immediate: true }, + ), ] const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? [] socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions] - const safeConnectOptions = connectOptions.force ? { force: true } : undefined - await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions) - connectedSocketServerId.value = targetServerId - isConnected.value = true - isWsAuthIncorrect.value = false - modrinthServersConsole.clear() modrinthServersConsole.beginInitialLogHydration() + await runtimeLease.waitUntilReady() + isConnected.value = true + isWsAuthIncorrect.value = false + return true } catch (error) { console.error('[hosting/manage] Failed to connect server socket:', error) - clearSocketListeners() - isConnected.value = false + disconnectSocket(targetServerId) return false } } diff --git a/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts b/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts index fe39fb2e2b..359a8c8d82 100644 --- a/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts +++ b/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts @@ -53,9 +53,6 @@ export interface BrowseInstallPlan( const version = getLatestMatchingInstallVersion(versions, candidate.preferences) if (version) { - const fileName = - version.files.find((file) => file.primary)?.filename ?? version.files[0]?.filename return { project: options.project, projectId, versionId: version.id, - versionName: version.name, - versionNumber: version.version_number, - fileName, contentType: options.contentType, preferences: candidate.preferences, source: candidate.source, diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/index.vue index 5bf475537d..94cf71f152 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/index.vue @@ -323,7 +323,7 @@