fix: ws connection duplication + disconnecting during browse

This commit is contained in:
Calum H. (IMB11)
2026-07-31 09:48:55 +01:00
parent 54d9597b53
commit 30b60d36b6
14 changed files with 830 additions and 962 deletions
@@ -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'
@@ -28,7 +24,6 @@ type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
installing?: boolean
installed?: boolean
}
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
export interface ServerModpackSelectionRequest {
projectId: string
@@ -90,114 +85,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: `https://modrinth.com/user/${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: `https://modrinth.com/user/${owner.username}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<InstallableSearchResult>,
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<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
client: AbstractModrinthClient,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
),
)
}
export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
}) {
@@ -220,11 +107,11 @@ 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<string | null>(worldIdQuery.value)
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
const serverContentProjectIds = ref<Set<string>>(new Set())
const serverContentInstallKeys = ref<Set<string>>(new Set())
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
new Map(),
)
@@ -282,11 +169,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)
}
@@ -321,14 +204,12 @@ export function createServerInstallContent(opts: {
if (!sid) {
serverContextServerData.value = null
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
setQueuedServerInstallPlans(new Map())
return
}
if (sid !== prevSid) {
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
@@ -440,6 +321,13 @@ export function createServerInstallContent(opts: {
const queuedPlans = getStoredServerAddonInstallQueue<InstallableSearchResult>(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,
@@ -463,9 +351,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
}
@@ -478,10 +363,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] })
}
@@ -506,20 +387,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)
@@ -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<PendingServerContentInstall, 'createdAt'>
type ServerInstallBrowseSearchState = Pick<
BrowseSearchState,
'currentFilters' | 'overriddenProvidedFilterTypes'
@@ -88,34 +83,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<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
export function useServerInstallContent({
projectType,
onboardingModalRef,
@@ -136,6 +103,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,
@@ -219,81 +187,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<ServerInstallSearchResult>,
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<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
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) {
@@ -319,10 +212,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(),
@@ -498,6 +387,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,
@@ -518,9 +414,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
}
@@ -559,23 +452,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)
@@ -9,6 +9,7 @@ export type WebSocketEventHandler<
export interface WebSocketConnection {
serverId: string
socket: WebSocket
authenticated: boolean
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
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<void> {
await new Promise<void>((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<E extends Archon.Websocket.v0.WSEventType>(
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,
}
@@ -1113,9 +1113,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 = {
@@ -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)
}
})
+1
View File
@@ -14,6 +14,7 @@ export * from './scroll-indicator'
export * from './server-backup'
export * from './server-backups-queue'
export * from './server-console'
export * from './server-context-runtime'
export * from './server-manage-core-runtime'
export * from './server-permissions'
export { applyEarsMod, removeEarsMod } from './skin-rendering/use-ears-mod-features'
@@ -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<T> = Ref<T> | ComputedRef<T>
type RuntimeUnsubscriber = () => void
type RuntimeReadyWaiter = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
type ServerContextRuntime = {
client: AbstractModrinthClient
serverId: string
leases: number
socketLeases: number
syncLeases: number
releaseTimer: ReturnType<typeof setTimeout> | null
socketReleaseTimer: ReturnType<typeof setTimeout> | null
syncReleaseTimer: ReturnType<typeof setTimeout> | null
connectPromise: Promise<void> | null
socketUnsubscribers: RuntimeUnsubscriber[]
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
isSocketAuthenticated: Ref<boolean>
isSocketAuthIncorrect: Ref<boolean>
hasAuthoritativeInstallProgress: Ref<boolean>
readyWaiters: Set<RuntimeReadyWaiter>
destroyed: boolean
}
export type ServerContextRuntimeLease = {
serverId: string
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
isSocketAuthenticated: Ref<boolean>
isSocketAuthIncorrect: Ref<boolean>
hasAuthoritativeInstallProgress: Ref<boolean>
waitUntilReady: () => Promise<void>
release: () => void
}
type RetainServerContextRuntimeOptions = {
connect?: boolean
socket?: boolean
sync?: boolean
}
const runtimeReleaseDelay = 1000
const authoritativeReadinessTimeout = 30000
const runtimesByClient = new WeakMap<AbstractModrinthClient, Map<string, ServerContextRuntime>>()
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<void>((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<string | null>) {
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()
}
}
@@ -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'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SocketUnsubscriber = () => void
type ConnectSocketOptions = {
force?: boolean
extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[]
}
@@ -113,6 +116,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
const cpuData = ref<number[]>([])
const ramData = ref<number[]>([])
let serverContextRuntimeLease: ServerContextRuntimeLease | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | 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
}
}
@@ -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<T> = Ref<T> | ComputedRef<T>
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<typeof setTimeout> | 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) {
@@ -53,9 +53,6 @@ export interface BrowseInstallPlan<TProject extends BrowseInstallProject = Brows
project: TProject
projectId: string
versionId: string
versionName?: string
versionNumber?: string
fileName?: string
contentType: BrowseInstallContentType
preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource
@@ -567,15 +564,10 @@ export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
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,
@@ -2,14 +2,14 @@
import { type Archon, type Labrinth, ModrinthApiError } from '@modrinth/api-client'
import { ClipboardCopyIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useIntervalFn } from '@vueuse/core'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import UnknownFileWarningModal from '#ui/components/modal/UnknownFileWarningModal.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { waitForServerContextRuntimeReady } from '#ui/composables/server-context-runtime'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
@@ -18,13 +18,6 @@ import {
injectServerSettingsModal,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import {
type PendingServerContentInstall,
pendingServerContentInstallsEvent,
readPendingServerContentInstallBaseline,
readPendingServerContentInstalls,
removePendingServerContentInstall,
} from '#ui/utils/server-content-installing'
import { versionChangesGameVersion } from '#ui/utils/version-compatibility'
import type { BrowseInstallPlan } from '../../../shared/browse-tab/composables/install-logic'
@@ -193,16 +186,6 @@ const setupActionBusyMessage = computed(() => {
const currentWorldInstallProgressItems = computed(() =>
installProgressItems.value.filter((item) => item.world_id === worldId.value),
)
const hasActiveFileInstallProgress = computed(() =>
currentWorldInstallProgressItems.value.some(
(item) =>
item.key.type === 'file' &&
item.error == null &&
item.progress != null &&
item.progress < 100,
),
)
const contentActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
const contentActionBusyMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
@@ -342,44 +325,217 @@ const addonLookup = computed(() => {
})
function normalizeInstallFilename(filename: string) {
return filename.endsWith('.disabled') ? filename.slice(0, -'.disabled'.length) : filename
const normalized = filename.endsWith('.disabled')
? filename.slice(0, -'.disabled'.length)
: filename
return normalized.toLowerCase()
}
const fileInstallProgressByFilename = computed(() => {
const progressByFilename = new Map<string, Archon.Websocket.v0.InstallProgressItem>()
for (const item of currentWorldInstallProgressItems.value) {
if (item.key.type === 'file') {
progressByFilename.set(normalizeInstallFilename(item.key.filename), item)
type FileInstallProgressItem = Archon.Websocket.v0.InstallProgressItem & {
key: Archon.Websocket.v0.InstallProgressFileKey
}
type ServerContentItem = ContentItem & {
installIdentityFilenames?: string[]
}
const fileInstallProgressItems = computed<FileInstallProgressItem[]>(() =>
currentWorldInstallProgressItems.value.filter(
(item): item is FileInstallProgressItem => item.key.type === 'file',
),
)
function getFileInstallTargetFilename(key: Archon.Websocket.v0.InstallProgressFileKey) {
return key.target_filename ?? key.source_filename ?? key.project_id
}
function getFileInstallFilenames(key: Archon.Websocket.v0.InstallProgressFileKey) {
return [key.source_filename, key.target_filename]
.filter((filename): filename is string => !!filename)
.map(normalizeInstallFilename)
}
function isFileInstallActive(item: FileInstallProgressItem) {
return item.error == null && item.progress !== 100
}
const completedInstallIds = ref<Set<string>>(new Set())
const settlingFileInstallProgressItems = ref<Map<string, FileInstallProgressItem>>(new Map())
const displayedFileInstallProgressItems = computed(() => {
const displayed = new Map(settlingFileInstallProgressItems.value)
for (const item of fileInstallProgressItems.value) {
if (isFileInstallActive(item)) {
displayed.set(item.id, item)
}
}
return progressByFilename
return Array.from(displayed.values())
})
const completedInstallFilenames = ref<Set<string>>(new Set())
const isContentInstallActive = computed(
() =>
fileInstallProgressItems.value.some(isFileInstallActive) ||
settlingFileInstallProgressItems.value.size > 0,
)
function sortedUnique(values: Array<string | null | undefined>) {
return Array.from(new Set(values.filter((value): value is string => !!value))).sort()
}
const installProgressProjectIds = computed(() =>
sortedUnique(displayedFileInstallProgressItems.value.map((item) => item.key.project_id)),
)
const installProgressVersionIds = computed(() =>
sortedUnique(displayedFileInstallProgressItems.value.map((item) => item.key.version_id)),
)
const installProgressProjectsQuery = useQuery({
queryKey: computed(
() => ['labrinth', 'projects', 'v3', 'multiple', installProgressProjectIds.value] as const,
),
queryFn: () => client.labrinth.projects_v3.getMultiple(installProgressProjectIds.value),
enabled: computed(() => installProgressProjectIds.value.length > 0),
placeholderData: (previousData) => previousData,
staleTime: 5 * 60 * 1000,
})
const installProgressVersionsQuery = useQuery({
queryKey: computed(
() => ['labrinth', 'versions', 'v2', 'multiple', installProgressVersionIds.value] as const,
),
queryFn: () => client.labrinth.versions_v2.getVersions(installProgressVersionIds.value),
enabled: computed(() => installProgressVersionIds.value.length > 0),
placeholderData: (previousData) => previousData,
staleTime: 5 * 60 * 1000,
})
const installProgressTeamIds = computed(() =>
sortedUnique(
(installProgressProjectsQuery.data.value ?? [])
.filter(
(project) => installProgressProjectIds.value.includes(project.id) && !project.organization,
)
.map((project) => project.team_id),
),
)
const installProgressOrganizationIds = computed(() =>
sortedUnique(
(installProgressProjectsQuery.data.value ?? [])
.filter((project) => installProgressProjectIds.value.includes(project.id))
.map((project) => project.organization),
),
)
const installProgressTeamsQuery = useQuery({
queryKey: computed(
() => ['labrinth', 'teams', 'v3', 'multiple', installProgressTeamIds.value] as const,
),
queryFn: async () => {
const teamIds = installProgressTeamIds.value
const teams = await client.labrinth.teams_v3.getMultiple(teamIds)
return teamIds.map((teamId, index) => ({
teamId,
members: teams[index] ?? [],
}))
},
enabled: computed(() => installProgressTeamIds.value.length > 0),
placeholderData: (previousData) => previousData,
staleTime: 5 * 60 * 1000,
})
const installProgressOrganizationsQuery = useQuery({
queryKey: computed(
() =>
[
'labrinth',
'organizations',
'v3',
'multiple',
installProgressOrganizationIds.value,
] as const,
),
queryFn: () => client.labrinth.organizations_v3.getMultiple(installProgressOrganizationIds.value),
enabled: computed(() => installProgressOrganizationIds.value.length > 0),
placeholderData: (previousData) => previousData,
staleTime: 5 * 60 * 1000,
})
const installProgressProjectsById = computed(
() =>
new Map(
(installProgressProjectsQuery.data.value ?? []).map((project) => [project.id, project]),
),
)
const installProgressVersionsById = computed(
() =>
new Map(
(installProgressVersionsQuery.data.value ?? []).map((version) => [version.id, version]),
),
)
const installProgressTeamsById = computed(() => {
const teams = installProgressTeamsQuery.data.value ?? []
return new Map(teams.map((team) => [team.teamId, team.members]))
})
const installProgressOrganizationsById = computed(
() =>
new Map(
(installProgressOrganizationsQuery.data.value ?? []).map((organization) => [
organization.id,
organization,
]),
),
)
async function settleCompletedFileInstalls(items: FileInstallProgressItem[]) {
try {
await contentQuery.refetch()
} catch {
return
} finally {
const activeIds = new Set(
fileInstallProgressItems.value.filter(isFileInstallActive).map((item) => item.id),
)
const nextSettlingItems = new Map(settlingFileInstallProgressItems.value)
for (const item of items) {
if (!activeIds.has(item.id)) nextSettlingItems.delete(item.id)
}
settlingFileInstallProgressItems.value = nextSettlingItems
}
}
watch(
fileInstallProgressByFilename,
(progressByFilename, previousProgressByFilename) => {
const completed = new Set(completedInstallFilenames.value)
let shouldRefreshContent = false
if (previousProgressByFilename) {
for (const filename of previousProgressByFilename.keys()) {
if (!progressByFilename.has(filename)) {
if (!completed.has(filename)) shouldRefreshContent = true
completed.add(filename)
fileInstallProgressItems,
(progressItems, previousProgressItems) => {
const completed = new Set(completedInstallIds.value)
const settlingItems = new Map(settlingFileInstallProgressItems.value)
const itemsToSettle = new Map<string, FileInstallProgressItem>()
const progressIds = new Set(progressItems.map((item) => item.id))
if (previousProgressItems) {
for (const item of previousProgressItems) {
if (!progressIds.has(item.id) && isFileInstallActive(item)) {
settlingItems.set(item.id, item)
itemsToSettle.set(item.id, item)
completed.add(item.id)
}
}
}
for (const [filename, item] of progressByFilename) {
if (item.error != null || item.progress === 100) {
if (item.error == null && !completed.has(filename)) shouldRefreshContent = true
completed.add(filename)
} else {
completed.delete(filename)
for (const item of progressItems) {
if (isFileInstallActive(item)) {
completed.delete(item.id)
settlingItems.delete(item.id)
} else if (item.error != null) {
completed.add(item.id)
settlingItems.delete(item.id)
} else if (!completed.has(item.id)) {
completed.add(item.id)
settlingItems.set(item.id, item)
itemsToSettle.set(item.id, item)
}
}
completedInstallFilenames.value = completed
if (shouldRefreshContent && !contentQuery.isFetching.value) {
void contentQuery.refetch()
completedInstallIds.value = completed
settlingFileInstallProgressItems.value = settlingItems
if (itemsToSettle.size > 0) {
void settleCompletedFileInstalls(Array.from(itemsToSettle.values()))
}
},
{ immediate: true },
@@ -390,17 +546,58 @@ function getContentItemInstallFilename(item: ContentItem) {
return normalizeInstallFilename(filename)
}
function getContentItemInstallProgress(item: ContentItem) {
return fileInstallProgressByFilename.value.get(getContentItemInstallFilename(item))
function getContentItemInstallProgress(item: ContentItem): FileInstallProgressItem | undefined {
const projectId = item.project?.id
const versionId = item.version?.id
const filename = getContentItemInstallFilename(item)
return fileInstallProgressItems.value.find((progressItem) => {
const key = progressItem.key
if (key.project_id === projectId) return true
if (key.version_id === versionId) return true
return getFileInstallFilenames(key).includes(filename)
})
}
function getInstallProgressOwner(project: Labrinth.Projects.v3.Project | undefined) {
if (!project) return undefined
if (project.organization) {
const organization = installProgressOrganizationsById.value.get(project.organization)
if (!organization) return undefined
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `/organization/${organization.slug}`,
}
}
const members = installProgressTeamsById.value.get(project.team_id)
const owner =
members?.find((member) => member.is_owner) ??
members?.find((member) => member.role.toLowerCase() === 'owner')
if (!owner) return undefined
return {
id: owner.user.id,
name: owner.user.username,
type: 'user' as const,
avatar_url: owner.user.avatar_url,
link: `/user/${owner.user.username}`,
}
}
function fileInstallProgressToContentItem(
item: Archon.Websocket.v0.InstallProgressItem,
key: Archon.Websocket.v0.InstallProgressFileKey,
): ContentItem {
const filename = key.filename
): ServerContentItem {
const filename = getFileInstallTargetFilename(key)
const extensionIndex = filename.lastIndexOf('.')
const title = extensionIndex > 0 ? filename.slice(0, extensionIndex) : filename
const fallbackTitle = extensionIndex > 0 ? filename.slice(0, extensionIndex) : filename
const project = installProgressProjectsById.value.get(key.project_id)
const version = installProgressVersionsById.value.get(key.version_id)
const projectType =
key.parent_directory === 'plugins'
? 'plugin'
@@ -411,76 +608,48 @@ function fileInstallProgressToContentItem(
id: `installing:${item.id}`,
file_name: filename,
project: {
id: item.id,
slug: filename,
title,
id: key.project_id,
slug: project?.slug ?? key.project_id,
title: project?.name ?? fallbackTitle ?? key.project_id,
icon_url: project?.icon_url,
},
version: {
id: item.id,
version_number: formatMessage(commonMessages.installingLabel),
id: key.version_id,
version_number: version?.name || version?.version_number || key.version_id,
file_name: filename,
},
owner: getInstallProgressOwner(project),
enabled: true,
project_type: projectType,
has_update: false,
update_version_id: null,
installing: true,
installProgress: item.progress,
installIdentityFilenames: getFileInstallFilenames(key),
}
}
function decorateContentItemWithInstallProgress(
contentItem: ContentItem,
installProgress: FileInstallProgressItem,
): ServerContentItem {
const progressItem = fileInstallProgressToContentItem(installProgress, installProgress.key)
const hasHydratedProject = installProgressProjectsById.value.has(installProgress.key.project_id)
const hasHydratedVersion = installProgressVersionsById.value.has(installProgress.key.version_id)
const installing = isFileInstallActive(installProgress)
return {
...contentItem,
project: hasHydratedProject ? progressItem.project : contentItem.project,
version: hasHydratedVersion ? progressItem.version : contentItem.version,
owner: progressItem.owner ?? contentItem.owner,
installing,
installProgress: installing ? installProgress.progress : undefined,
installIdentityFilenames: progressItem.installIdentityFilenames,
}
}
const pendingServerContentInstalls = ref<PendingServerContentInstall[]>([])
const isContentInstallActive = computed(
() => hasActiveFileInstallProgress.value || pendingServerContentInstalls.value.length > 0,
)
const lastStableContentKeys = ref<Set<string>>(new Set())
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
const contentInstallAddedKeys = ref<Set<string>>(new Set())
const isFlushingStoredServerInstalls = ref(false)
const { pause: pausePendingInstallPoll, resume: resumePendingInstallPoll } = useIntervalFn(
() => {
if (pendingServerContentInstalls.value.length === 0 || contentQuery.isFetching.value) return
void contentQuery.refetch()
},
5000,
{ immediate: false },
)
function syncPendingServerContentInstalls() {
const pendingInstalls = readPendingServerContentInstalls(serverId, worldId.value)
pendingServerContentInstalls.value = pendingInstalls
const completed = new Set(completedInstallFilenames.value)
let changed = false
for (const item of pendingInstalls) {
if (item.fileName) {
changed = completed.delete(normalizeInstallFilename(item.fileName)) || changed
}
}
if (changed) {
completedInstallFilenames.value = completed
}
}
function handlePendingServerContentInstallsChanged(event: Event) {
const detail = (event as CustomEvent<{ serverId?: string | null; worldId?: string | null }>)
.detail
if (detail?.serverId !== serverId || detail?.worldId !== worldId.value) return
syncPendingServerContentInstalls()
void flushStoredServerInstalls()
}
function getAddonInstallKey(addon: Archon.Content.v1.Addon) {
return addon.project_id ?? addon.filename
}
function getAddonInstallKeys(addons: Archon.Content.v1.Addon[]) {
const keys = new Set<string>()
for (const addon of addons) {
keys.add(getAddonInstallKey(addon))
}
return keys
}
function getInstalledProjectIds() {
return new Set(
@@ -533,53 +702,6 @@ async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) {
return resolvedAddons
}
function addonMatchesPendingInstall(
addon: Archon.Content.v1.Addon,
pendingInstall: PendingServerContentInstall,
) {
return (
addon.project_id === pendingInstall.projectId ||
addon.version?.id === pendingInstall.versionId ||
(!!pendingInstall.fileName && addon.filename === pendingInstall.fileName)
)
}
function removeResolvedPendingServerContentInstalls(addons: Archon.Content.v1.Addon[]) {
if (addons.length === 0 || pendingServerContentInstalls.value.length === 0) return
for (const pendingInstall of pendingServerContentInstalls.value) {
if (addons.some((addon) => addonMatchesPendingInstall(addon, pendingInstall))) {
removePendingServerContentInstall(serverId, worldId.value, pendingInstall.projectId)
}
}
}
function syncContentInstallKeys(
addons: Archon.Content.v1.Addon[] = contentQuery.data.value?.addons ?? [],
) {
const currentKeys = getAddonInstallKeys(addons)
if (isContentInstallActive.value) {
if (!contentInstallBaselineKeys.value) {
contentInstallBaselineKeys.value =
readPendingServerContentInstallBaseline(serverId, worldId.value) ??
new Set(lastStableContentKeys.value)
}
const nextAddedKeys = new Set(contentInstallAddedKeys.value)
for (const key of currentKeys) {
if (!contentInstallBaselineKeys.value.has(key)) {
nextAddedKeys.add(key)
}
}
contentInstallAddedKeys.value = nextAddedKeys
return
}
lastStableContentKeys.value = currentKeys
contentInstallBaselineKeys.value = null
contentInstallAddedKeys.value = new Set()
}
async function flushStoredServerInstalls() {
const wid = worldId.value
if (!wid || isFlushingStoredServerInstalls.value) return
@@ -587,6 +709,17 @@ async function flushStoredServerInstalls() {
const queuedPlans = getStoredServerAddonInstallQueue(serverId, wid)
if (queuedPlans.size === 0) return
try {
await waitForServerContextRuntimeReady(client, serverId)
} catch (error) {
addNotification({
type: 'error',
title: formatMessage(messages.failedToInstallContent),
text: error instanceof Error ? error.message : undefined,
})
return
}
isFlushingStoredServerInstalls.value = true
try {
const result = await flushStoredServerAddonInstallQueue({
@@ -601,9 +734,6 @@ async function flushStoredServerInstalls() {
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, wid, plan.projectId)
}
addNotification({
type: 'error',
title: formatMessage(messages.failedToInstallContent),
@@ -617,146 +747,39 @@ async function flushStoredServerInstalls() {
}
} finally {
isFlushingStoredServerInstalls.value = false
syncPendingServerContentInstalls()
}
}
function pendingInstallToContentItem(item: PendingServerContentInstall): ContentItem {
return {
project: {
id: item.projectId,
slug: item.slug ?? item.projectId,
title: item.title,
icon_url: item.iconUrl ?? undefined,
},
version: {
id: item.versionId,
version_number:
item.versionName ?? item.versionNumber ?? formatMessage(commonMessages.installingLabel),
file_name: item.fileName ?? formatMessage(commonMessages.installingLabel),
},
owner: item.owner
? {
id: item.owner.id,
name: item.owner.name,
type: item.owner.type,
avatar_url: getContentOwnerAvatarUrl(item.owner),
link: item.owner.link,
}
: undefined,
id: `installing:${item.projectId}`,
enabled: true,
file_name: `installing:${item.projectId}`,
project_type: item.contentType,
has_update: false,
update_version_id: null,
installing: true,
}
}
const rawContentItems = computed<ContentItem[]>(() => {
const addons = contentQuery.data.value?.addons ?? []
const pendingProjectIds = new Set(
pendingServerContentInstalls.value.map((item) => item.projectId),
)
const pendingInstallByProjectId = new Map(
pendingServerContentInstalls.value.map((item) => [item.projectId, item]),
)
const pendingInstallByVersionId = new Map(
pendingServerContentInstalls.value.map((item) => [item.versionId, item]),
)
const pendingInstallByFileName = new Map<string, PendingServerContentInstall>()
for (const item of pendingServerContentInstalls.value) {
if (item.fileName) {
pendingInstallByFileName.set(item.fileName, item)
}
}
const installingContentKeys = new Set([...pendingProjectIds, ...contentInstallAddedKeys.value])
const resolvedPendingProjectIds = new Set(
pendingServerContentInstalls.value
.filter((item) => addons.some((addon) => addonMatchesPendingInstall(addon, item)))
.map((item) => item.projectId),
)
const pendingItems = pendingServerContentInstalls.value
.filter((item) => !resolvedPendingProjectIds.has(item.projectId))
.map(pendingInstallToContentItem)
const addonItems = addons.map((addon) => {
const contentItem = addonToContentItem(addon)
const pendingItem =
(addon.project_id ? pendingInstallByProjectId.get(addon.project_id) : null) ??
(addon.version?.id ? pendingInstallByVersionId.get(addon.version.id) : null) ??
pendingInstallByFileName.get(addon.filename) ??
null
const installProgress = getContentItemInstallProgress(contentItem)
const installFilename = getContentItemInstallFilename(contentItem)
const installing =
installProgress != null
? installProgress.error == null && installProgress.progress !== 100
: !completedInstallFilenames.value.has(installFilename) &&
(!!pendingItem || installingContentKeys.has(getAddonInstallKey(addon)))
if (!installing || !pendingItem) {
return {
...contentItem,
installing,
installProgress: installing ? installProgress?.progress : undefined,
}
}
const pendingContentItem = pendingInstallToContentItem(pendingItem)
return {
...contentItem,
project: {
...contentItem.project,
slug: pendingContentItem.project.slug,
title: pendingContentItem.project.title,
icon_url: contentItem.project.icon_url ?? pendingContentItem.project.icon_url,
},
version: {
id: pendingContentItem.version?.id ?? contentItem.version?.id ?? contentItem.file_name,
version_number:
pendingContentItem.version?.version_number ??
contentItem.version?.version_number ??
formatMessage(commonMessages.installingLabel),
file_name:
pendingContentItem.version?.file_name ??
contentItem.version?.file_name ??
contentItem.file_name,
},
owner: pendingContentItem.owner ?? contentItem.owner,
installing,
installProgress: installProgress?.progress,
}
return installProgress
? decorateContentItemWithInstallProgress(contentItem, installProgress)
: contentItem
})
const pendingDisplayItems = pendingItems.map((item) => {
const installProgress = getContentItemInstallProgress(item)
const installing =
installProgress != null
? installProgress.error == null && installProgress.progress !== 100
: !completedInstallFilenames.value.has(getContentItemInstallFilename(item))
return {
...item,
installing,
installProgress: installing ? installProgress?.progress : undefined,
}
})
const displayedInstallFilenames = new Set(
[...addonItems, ...pendingDisplayItems].map(getContentItemInstallFilename),
)
const progressOnlyItems = currentWorldInstallProgressItems.value.flatMap((item) => {
const displayedInstallFilenames = new Set(addonItems.map(getContentItemInstallFilename))
const progressOnlyItems = displayedFileInstallProgressItems.value.flatMap((item) => {
if (item.error != null) return []
const matchingAddon = addons.find((addon) => {
if (addon.project_id === item.key.project_id) return true
if (addon.version?.id === item.key.version_id) return true
return getFileInstallFilenames(item.key).includes(normalizeInstallFilename(addon.filename))
})
if (
item.key.type !== 'file' ||
item.error != null ||
item.progress === 100 ||
displayedInstallFilenames.has(normalizeInstallFilename(item.key.filename))
matchingAddon ||
displayedInstallFilenames.has(
normalizeInstallFilename(getFileInstallTargetFilename(item.key)),
)
) {
return []
}
return [fileInstallProgressToContentItem(item, item.key)]
})
return [...addonItems, ...pendingDisplayItems, ...progressOnlyItems]
return [...addonItems, ...progressOnlyItems]
})
const displayedContentItems = ref<ContentItem[]>([])
@@ -765,48 +788,51 @@ const contentReadyPending = computed(
() =>
contentQuery.isLoading.value &&
contentQuery.data.value === undefined &&
pendingServerContentInstalls.value.length === 0 &&
displayedContentItems.value.length === 0,
)
function getContentItemDisplayKey(item: ContentItem) {
return item.project?.id ?? item.file_name ?? item.id
}
function getContentItemId(item: ContentItem) {
return item.file_name ?? item.id
}
function mergeFragileContentItems(items: ContentItem[]) {
const nextItems = new Map(items.map((item) => [getContentItemDisplayKey(item), item]))
const mergedItems = displayedContentItems.value.flatMap((item) => {
let nextKey = getContentItemDisplayKey(item)
let nextItem = nextItems.get(nextKey)
if (!nextItem && item.installing) {
const installFilename = getContentItemInstallFilename(item)
const matchingEntry = Array.from(nextItems.entries()).find(
([, candidate]) => getContentItemInstallFilename(candidate) === installFilename,
)
if (matchingEntry) {
nextKey = matchingEntry[0]
nextItem = matchingEntry[1]
}
}
if (!nextItem) {
if (
item.installing &&
completedInstallFilenames.value.has(getContentItemInstallFilename(item))
) {
return []
}
return [item]
}
function getContentItemIdentityFilenames(item: ContentItem) {
const identityFilenames = (item as ServerContentItem).installIdentityFilenames ?? []
return new Set([
...identityFilenames,
normalizeInstallFilename(item.version?.file_name || item.file_name),
])
}
nextItems.delete(nextKey)
return [nextItem]
function findMatchingContentItemIndex(item: ContentItem, candidates: ContentItem[]) {
const projectId = item.project?.id
if (projectId) {
const projectIndex = candidates.findIndex((candidate) => candidate.project?.id === projectId)
if (projectIndex !== -1) return projectIndex
}
const versionId = item.version?.id
if (versionId) {
const versionIndex = candidates.findIndex((candidate) => candidate.version?.id === versionId)
if (versionIndex !== -1) return versionIndex
}
const filenames = getContentItemIdentityFilenames(item)
return candidates.findIndex((candidate) =>
Array.from(getContentItemIdentityFilenames(candidate)).some((filename) =>
filenames.has(filename),
),
)
}
function mergeFragileContentItems(items: ContentItem[]) {
const remainingItems = [...items]
const mergedItems = displayedContentItems.value.flatMap((item) => {
const matchingIndex = findMatchingContentItemIndex(item, remainingItems)
if (matchingIndex === -1) return [item]
return remainingItems.splice(matchingIndex, 1)
})
return [...mergedItems, ...nextItems.values()]
return [...mergedItems, ...remainingItems]
}
watch(
@@ -829,62 +855,16 @@ watch(
{ deep: true, immediate: true },
)
watch(
[isContentInstallActive, () => contentQuery.data.value?.addons],
([, addons]) => {
syncContentInstallKeys(addons ?? [])
},
{ deep: true, immediate: true },
)
watch(
[() => contentQuery.data.value?.addons, pendingServerContentInstalls],
([addons]) => {
removeResolvedPendingServerContentInstalls(addons ?? [])
},
{ deep: true, immediate: true },
)
watch(
() => pendingServerContentInstalls.value.length > 0,
(hasPendingInstalls) => {
if (hasPendingInstalls) {
resumePendingInstallPoll()
} else {
pausePendingInstallPoll()
}
},
{ immediate: true },
)
watch(
worldId,
() => {
completedInstallFilenames.value = new Set()
syncPendingServerContentInstalls()
syncContentInstallKeys()
completedInstallIds.value = new Set()
settlingFileInstallProgressItems.value = new Map()
void flushStoredServerInstalls()
},
{ immediate: true },
)
onMounted(() => {
syncPendingServerContentInstalls()
void flushStoredServerInstalls()
window.addEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,
)
})
onUnmounted(() => {
pausePendingInstallPoll()
window.removeEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,
)
})
const deleteMutation = useMutation({
mutationFn: ({ addon }: { addon: Archon.Content.v1.Addon }) =>
client.archon.content_v1.deleteAddon(serverId, worldId.value!, {
@@ -303,7 +303,7 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { getNodeWebSocketUrl, ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
import { ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
import {
BoxesIcon,
CopyIcon,
@@ -1150,7 +1150,7 @@ const nodeUnavailableDetails = computed(() => [
label: 'Error message',
value: nodeAccessible.value
? (serverError.value?.message ?? 'Unknown')
: 'Unable to reach node. Ping test failed.',
: 'Unable to establish the node WebSocket connection.',
type: 'block' as const,
},
])
@@ -1268,48 +1268,6 @@ function safeStringify(obj: unknown, indent = ' '): string {
)
}
async function testNodeReachability(): Promise<boolean> {
const nodeInstance = serverData.value?.node?.instance
if (!nodeInstance) return false
try {
const auth = await client.archon.servers_v0.getWebSocketAuth(props.serverId)
const authUrl = getNodeWebSocketUrl(auth.url)
const protocol = authUrl.toLowerCase().startsWith('ws://') ? 'ws' : 'wss'
const wsUrl = getNodeWebSocketUrl(`${nodeInstance}/pingtest`).replace(
/^wss?:\/\//i,
`${protocol}://`,
)
return await new Promise((resolve) => {
const socket = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
socket.close()
resolve(false)
}, 5000)
socket.onopen = () => {
clearTimeout(timeout)
socket.send(performance.now().toString())
}
socket.onmessage = () => {
clearTimeout(timeout)
socket.close()
resolve(true)
}
socket.onerror = () => {
clearTimeout(timeout)
resolve(false)
}
})
} catch (error) {
console.error(`Failed to ping node ${nodeInstance}:`, error)
return false
}
}
function initializeServer() {
if (serverData.value?.status === 'suspended') {
isLoading.value = false
@@ -1321,19 +1279,6 @@ function initializeServer() {
return
}
testNodeReachability()
.then((result) => {
nodeAccessible.value = result
if (!nodeAccessible.value) {
isLoading.value = false
}
})
.catch((err) => {
console.error('Error testing node reachability:', err)
nodeAccessible.value = false
isLoading.value = false
})
if (serverError.value) {
isLoading.value = false
} else {
@@ -1345,6 +1290,7 @@ function initializeServer() {
],
})
.then((connected) => {
nodeAccessible.value = connected
if (connected && cachedWsState?.consoleLines?.length) {
modrinthServersConsole.clear()
modrinthServersConsole.addLines(cachedWsState.consoleLines)
-1
View File
@@ -8,7 +8,6 @@ export * from './loaders'
export * from './notices'
export * from './savable'
export * from './search'
export * from './server-content-installing'
export * from './server-search'
export * from './tag-messages'
export * from './truncate'
@@ -1,203 +0,0 @@
import type {
ContentCardProject,
ContentCardVersion,
ContentOwner,
} from '../layouts/shared/content-tab/types'
export type PendingServerContentInstallType = 'mod' | 'plugin' | 'datapack'
type PendingServerContentOwner = Omit<ContentOwner, 'link'> & { link?: string }
export interface PendingServerContentInstall {
projectId: string
versionId: string
contentType: PendingServerContentInstallType
title: ContentCardProject['title']
versionName?: ContentCardVersion['version_number'] | null
versionNumber?: ContentCardVersion['version_number'] | null
fileName?: ContentCardVersion['file_name'] | null
owner?: PendingServerContentOwner | null
slug?: ContentCardProject['slug'] | null
iconUrl?: ContentCardProject['icon_url'] | null
createdAt: number
}
interface PendingServerContentInstallBaseline {
contentKeys: string[]
projectIds?: string[]
createdAt: number
}
export const pendingServerContentInstallsEvent = 'modrinth:pending-server-content-installs'
const stalePendingInstallAge = 30 * 60 * 1000
function getPendingServerContentInstallsKey(serverId: string | null, worldId: string | null) {
if (!serverId || !worldId) return null
return `server-content-installing:${serverId}:${worldId}`
}
function getPendingServerContentInstallBaselineKey(
serverId: string | null,
worldId: string | null,
) {
if (!serverId || !worldId) return null
return `server-content-installing-baseline:${serverId}:${worldId}`
}
function isPendingServerContentInstall(value: unknown): value is PendingServerContentInstall {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return (
typeof record.projectId === 'string' &&
typeof record.versionId === 'string' &&
(record.contentType === 'mod' ||
record.contentType === 'plugin' ||
record.contentType === 'datapack') &&
typeof record.title === 'string' &&
typeof record.createdAt === 'number'
)
}
function isPendingServerContentInstallBaseline(
value: unknown,
): value is PendingServerContentInstallBaseline {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
const contentKeys = record.contentKeys ?? record.projectIds
return (
Array.isArray(contentKeys) &&
contentKeys.every((contentKey) => typeof contentKey === 'string') &&
typeof record.createdAt === 'number'
)
}
function filterFreshPendingServerContentInstalls(items: PendingServerContentInstall[]) {
const cutoff = Date.now() - stalePendingInstallAge
return items.filter((item) => item.createdAt >= cutoff)
}
function isFreshPendingServerContentInstallBaseline(item: PendingServerContentInstallBaseline) {
return item.createdAt >= Date.now() - stalePendingInstallAge
}
function emitPendingServerContentInstallsChanged(serverId: string | null, worldId: string | null) {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent(pendingServerContentInstallsEvent, {
detail: { serverId, worldId },
}),
)
}
export function readPendingServerContentInstalls(serverId: string | null, worldId: string | null) {
const key = getPendingServerContentInstallsKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return []
try {
const raw = localStorage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
const freshItems = filterFreshPendingServerContentInstalls(
parsed.filter(isPendingServerContentInstall),
)
if (freshItems.length !== parsed.length) {
writePendingServerContentInstalls(serverId, worldId, freshItems)
}
return freshItems
} catch {
return []
}
}
export function writePendingServerContentInstalls(
serverId: string | null,
worldId: string | null,
items: PendingServerContentInstall[],
) {
const key = getPendingServerContentInstallsKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return
const freshItems = filterFreshPendingServerContentInstalls(items)
if (freshItems.length === 0) {
localStorage.removeItem(key)
const baselineKey = getPendingServerContentInstallBaselineKey(serverId, worldId)
if (baselineKey) {
localStorage.removeItem(baselineKey)
}
} else {
localStorage.setItem(key, JSON.stringify(freshItems))
}
emitPendingServerContentInstallsChanged(serverId, worldId)
}
export function readPendingServerContentInstallBaseline(
serverId: string | null,
worldId: string | null,
) {
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return null
try {
const raw = localStorage.getItem(key)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!isPendingServerContentInstallBaseline(parsed)) return null
if (!isFreshPendingServerContentInstallBaseline(parsed)) {
localStorage.removeItem(key)
return null
}
return new Set(parsed.contentKeys ?? parsed.projectIds)
} catch {
return null
}
}
export function writePendingServerContentInstallBaseline(
serverId: string | null,
worldId: string | null,
contentKeys: Iterable<string>,
) {
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return
localStorage.setItem(
key,
JSON.stringify({
contentKeys: Array.from(new Set(contentKeys)),
createdAt: Date.now(),
} satisfies PendingServerContentInstallBaseline),
)
emitPendingServerContentInstallsChanged(serverId, worldId)
}
export function addPendingServerContentInstalls(
serverId: string | null,
worldId: string | null,
items: Omit<PendingServerContentInstall, 'createdAt'>[],
) {
if (items.length === 0) return
const now = Date.now()
const next = new Map(
readPendingServerContentInstalls(serverId, worldId).map((item) => [item.projectId, item]),
)
for (const item of items) {
next.set(item.projectId, { ...item, createdAt: now })
}
writePendingServerContentInstalls(serverId, worldId, Array.from(next.values()))
}
export function removePendingServerContentInstall(
serverId: string | null,
worldId: string | null,
projectId: string,
) {
writePendingServerContentInstalls(
serverId,
worldId,
readPendingServerContentInstalls(serverId, worldId).filter(
(item) => item.projectId !== projectId,
),
)
}