fix: use localstorage for sync state during install (#6057)

* fix: use localstorage for sync state during install

* fix: lint
This commit is contained in:
Calum H.
2026-05-09 22:02:42 +01:00
committed by GitHub
parent 07f9e3aedc
commit c7602602e5
9 changed files with 1262 additions and 757 deletions

View File

@@ -71,6 +71,207 @@ export interface BrowseInstallQueue<TProject extends BrowseInstallProject = Brow
set: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
}
const serverInstallQueueStoragePrefix = 'server-install-queue'
const serverInstallQueueLockStoragePrefix = 'server-install-queue-lock'
const serverInstallQueueLockTtl = 15 * 60 * 1000
const serverInstallQueueLockRefreshInterval = 30 * 1000
const activeInstallQueueFlushes = new Map<string, Promise<unknown>>()
export function getStoredServerInstallQueueKey(serverId: string | null, worldId: string | null) {
if (!serverId || !worldId) return null
return `${serverInstallQueueStoragePrefix}:${serverId}:${worldId}`
}
export function readStoredServerInstallQueue<
TProject extends BrowseInstallProject = BrowseInstallProject,
>(serverId: string | null, worldId: string | null) {
const key = getStoredServerInstallQueueKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') {
return new Map<string, BrowseInstallPlan<TProject>>()
}
try {
const raw = localStorage.getItem(key)
if (!raw) return new Map<string, BrowseInstallPlan<TProject>>()
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return new Map<string, BrowseInstallPlan<TProject>>()
return new Map<string, BrowseInstallPlan<TProject>>(
parsed.filter(isStoredServerInstallQueueEntry),
)
} catch {
return new Map<string, BrowseInstallPlan<TProject>>()
}
}
export function writeStoredServerInstallQueue<
TProject extends BrowseInstallProject = BrowseInstallProject,
>(
serverId: string | null,
worldId: string | null,
plans: Map<string, BrowseInstallPlan<TProject>>,
) {
const key = getStoredServerInstallQueueKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return
if (plans.size === 0) {
localStorage.removeItem(key)
return
}
localStorage.setItem(key, JSON.stringify(Array.from(plans.entries())))
}
function getServerInstallQueueLockName(lockKey: string) {
return `${serverInstallQueueStoragePrefix}:flush:${lockKey}`
}
function getStoredServerInstallQueueLockKey(lockName: string) {
return `${serverInstallQueueLockStoragePrefix}:${lockName}`
}
function isStoredServerInstallQueueLock(value: unknown): value is StoredServerInstallQueueLock {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return typeof record.token === 'string' && typeof record.expiresAt === 'number'
}
function readStoredServerInstallQueueLock(key: string) {
try {
const raw = localStorage.getItem(key)
if (!raw) return null
const parsed = JSON.parse(raw)
return isStoredServerInstallQueueLock(parsed) ? parsed : null
} catch {
return null
}
}
function createServerInstallQueueLockToken() {
return `${Date.now()}:${Math.random().toString(36).slice(2)}`
}
function tryAcquireStoredServerInstallQueueLock(
lockName: string,
): AcquiredStoredServerInstallQueueLock | null {
const key = getStoredServerInstallQueueLockKey(lockName)
const existingLock = readStoredServerInstallQueueLock(key)
if (existingLock && existingLock.expiresAt > Date.now()) return null
const token = createServerInstallQueueLockToken()
localStorage.setItem(
key,
JSON.stringify({
token,
expiresAt: Date.now() + serverInstallQueueLockTtl,
} satisfies StoredServerInstallQueueLock),
)
const storedLock = readStoredServerInstallQueueLock(key)
return storedLock?.token === token ? { key, token } : null
}
function refreshStoredServerInstallQueueLock(lock: AcquiredStoredServerInstallQueueLock) {
const storedLock = readStoredServerInstallQueueLock(lock.key)
if (storedLock?.token !== lock.token) return false
localStorage.setItem(
lock.key,
JSON.stringify({
token: lock.token,
expiresAt: Date.now() + serverInstallQueueLockTtl,
} satisfies StoredServerInstallQueueLock),
)
return true
}
function releaseStoredServerInstallQueueLock(lock: AcquiredStoredServerInstallQueueLock) {
const storedLock = readStoredServerInstallQueueLock(lock.key)
if (storedLock?.token === lock.token) {
localStorage.removeItem(lock.key)
}
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function withStoredServerInstallQueueLock<T>(
lockName: string,
callback: () => T | Promise<T>,
) {
if (typeof localStorage === 'undefined') {
return await callback()
}
let lock = tryAcquireStoredServerInstallQueueLock(lockName)
while (!lock) {
await wait(100)
lock = tryAcquireStoredServerInstallQueueLock(lockName)
}
const acquiredLock = lock
const refreshInterval = setInterval(
() => refreshStoredServerInstallQueueLock(acquiredLock),
serverInstallQueueLockRefreshInterval,
)
try {
return await callback()
} finally {
clearInterval(refreshInterval)
releaseStoredServerInstallQueueLock(acquiredLock)
}
}
async function runWithServerInstallQueueLock<T>(lockName: string, callback: () => T | Promise<T>) {
const locks =
typeof navigator === 'undefined' ? undefined : (navigator as NavigatorWithLocks).locks
if (locks) {
return await locks.request(lockName, { mode: 'exclusive' }, callback)
}
return await withStoredServerInstallQueueLock(lockName, callback)
}
async function withServerInstallQueueLock<T>(
lockKey: string | null | undefined,
callback: () => T | Promise<T>,
) {
if (!lockKey) return await callback()
const lockName = getServerInstallQueueLockName(lockKey)
for (;;) {
const activeFlush = activeInstallQueueFlushes.get(lockName)
if (!activeFlush) break
await activeFlush.catch(() => undefined)
}
const flush = runWithServerInstallQueueLock(lockName, callback)
activeInstallQueueFlushes.set(lockName, flush)
try {
return await flush
} finally {
if (activeInstallQueueFlushes.get(lockName) === flush) {
activeInstallQueueFlushes.delete(lockName)
}
}
}
export async function withStoredServerInstallQueueFlushLock<T>(
serverId: string | null,
worldId: string | null,
callback: () => T | Promise<T>,
) {
return await withServerInstallQueueLock(
getStoredServerInstallQueueKey(serverId, worldId),
callback,
)
}
/**
* Filter inputs for deriving selected install preferences.
*
@@ -118,6 +319,7 @@ export interface RequestInstallOptions<
export interface FlushInstallQueueOptions<TProject extends BrowseInstallProject> {
queue: BrowseInstallQueue<TProject>
install: (plan: BrowseInstallPlan<TProject>) => void | Promise<void>
lockKey?: string | null
onError?: (error: unknown, plan: BrowseInstallPlan<TProject>) => void
onProgress?: (
completed: number,
@@ -126,6 +328,13 @@ export interface FlushInstallQueueOptions<TProject extends BrowseInstallProject>
) => void | Promise<void>
}
export interface FlushStoredServerAddonInstallQueueOptions<TProject extends BrowseInstallProject> {
serverId: string
worldId: string
install: (plans: BrowseInstallPlan<TProject>[]) => void | Promise<void>
onQueueChange?: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
}
/**
* Result of a queue flush. Failed plans are also written back to the queue.
*/
@@ -135,11 +344,38 @@ export interface FlushInstallQueueResult<TProject extends BrowseInstallProject>
failedPlans: Map<string, BrowseInstallPlan<TProject>>
}
export interface FlushStoredServerAddonInstallQueueResult<TProject extends BrowseInstallProject> {
ok: boolean
flushedPlans: BrowseInstallPlan<TProject>[]
attemptedPlans: BrowseInstallPlan<TProject>[]
error?: unknown
}
interface InstallCandidate {
preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource
}
interface StoredServerInstallQueueLock {
token: string
expiresAt: number
}
interface AcquiredStoredServerInstallQueueLock {
key: string
token: string
}
type NavigatorWithLocks = {
locks?: {
request: <T>(
name: string,
options: { mode: 'exclusive' },
callback: () => T | Promise<T>,
) => Promise<T>
}
}
/**
* Maps a project/content type to the browse filter keys that represent its loader.
*/
@@ -367,6 +603,81 @@ export async function requestInstall<TProject extends BrowseInstallProject>(
* Successful plans are removed; failed plans remain in the queue for retry or user action.
*/
export async function flushInstallQueue<TProject extends BrowseInstallProject>({
queue,
install,
lockKey,
onError,
onProgress,
}: FlushInstallQueueOptions<TProject>): Promise<FlushInstallQueueResult<TProject>> {
return await withServerInstallQueueLock(lockKey, () =>
flushInstallQueueUnlocked({ queue, install, onError, onProgress }),
)
}
export function getStoredServerAddonInstallQueue<
TProject extends BrowseInstallProject = BrowseInstallProject,
>(serverId: string, worldId: string) {
const storedPlans = readStoredServerInstallQueue<TProject>(serverId, worldId)
const addonPlans = new Map(
Array.from(storedPlans).filter(([, plan]) => plan.contentType !== 'modpack'),
)
if (addonPlans.size !== storedPlans.size) {
writeStoredServerInstallQueue(serverId, worldId, addonPlans)
}
return addonPlans
}
export async function flushStoredServerAddonInstallQueue<TProject extends BrowseInstallProject>({
serverId,
worldId,
install,
onQueueChange,
}: FlushStoredServerAddonInstallQueueOptions<TProject>): Promise<
FlushStoredServerAddonInstallQueueResult<TProject>
> {
let attemptedPlans: BrowseInstallPlan<TProject>[] = []
try {
const flushedPlans = await withStoredServerInstallQueueFlushLock(
serverId,
worldId,
async () => {
const plans = Array.from(
getStoredServerAddonInstallQueue<TProject>(serverId, worldId).values(),
)
attemptedPlans = plans
if (plans.length === 0) return []
await install(plans)
const remainingPlans = getStoredServerAddonInstallQueue<TProject>(serverId, worldId)
for (const plan of plans) {
remainingPlans.delete(plan.projectId)
}
writeStoredServerInstallQueue(serverId, worldId, remainingPlans)
onQueueChange?.(remainingPlans)
return plans
},
)
return {
ok: true,
flushedPlans,
attemptedPlans,
}
} catch (error) {
return {
ok: false,
flushedPlans: [],
attemptedPlans,
error,
}
}
}
async function flushInstallQueueUnlocked<TProject extends BrowseInstallProject>({
queue,
install,
onError,
@@ -381,6 +692,10 @@ export async function flushInstallQueue<TProject extends BrowseInstallProject>({
try {
await install(plan)
successfulPlans.push(plan)
const remainingPlans = new Map(queue.get())
remainingPlans.delete(plan.projectId)
queue.set(remainingPlans)
} catch (error) {
failedPlans.set(plan.projectId, plan)
onError?.(error, plan)
@@ -389,9 +704,6 @@ export async function flushInstallQueue<TProject extends BrowseInstallProject>({
await onProgress?.(completed, queuedPlans.length, plan)
}
}
queue.set(failedPlans)
return {
ok: failedPlans.size === 0,
successfulPlans,
@@ -523,6 +835,53 @@ function uniqueDefined(values: readonly (string | null | undefined)[] = []) {
)
}
function isStoredServerInstallQueueEntry(
value: unknown,
): value is [string, BrowseInstallPlan<BrowseInstallProject>] {
if (!Array.isArray(value) || value.length !== 2) return false
const [key, plan] = value
return typeof key === 'string' && isStoredBrowseInstallPlan(plan)
}
function isStoredBrowseInstallPlan(
value: unknown,
): value is BrowseInstallPlan<BrowseInstallProject> {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return (
isStoredBrowseInstallProject(record.project) &&
typeof record.projectId === 'string' &&
typeof record.versionId === 'string' &&
isStoredBrowseInstallContentType(record.contentType) &&
isStoredBrowseInstallPreferences(record.preferences) &&
(record.source === 'filtered' || record.source === 'target')
)
}
function isStoredBrowseInstallProject(value: unknown): value is BrowseInstallProject {
return (
!!value &&
typeof value === 'object' &&
typeof (value as Record<string, unknown>).project_id === 'string'
)
}
function isStoredBrowseInstallContentType(value: unknown): value is BrowseInstallContentType {
return value === 'modpack' || value === 'mod' || value === 'plugin' || value === 'datapack'
}
function isStoredBrowseInstallPreferences(value: unknown): value is BrowseInstallPreferences {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return isOptionalStringArray(record.gameVersions) && isOptionalStringArray(record.loaders)
}
function isOptionalStringArray(value: unknown) {
return (
value === undefined || (Array.isArray(value) && value.every((item) => typeof item === 'string'))
)
}
function createNoCompatibleVersionError(
contentType: BrowseInstallContentType,
preferences: BrowseInstallPreferences,

View File

@@ -19,8 +19,13 @@ import {
pendingServerContentInstallsEvent,
readPendingServerContentInstallBaseline,
readPendingServerContentInstalls,
removePendingServerContentInstall,
} from '#ui/utils/server-content-installing'
import {
flushStoredServerAddonInstallQueue,
getStoredServerAddonInstallQueue,
} from '../../../shared/browse-tab/composables/install-logic'
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
@@ -97,6 +102,10 @@ const messages = defineMessages({
id: 'hosting.content.failed-to-bulk-update',
defaultMessage: 'Failed to update content',
},
failedToInstallContent: {
id: 'hosting.content.failed-to-install',
defaultMessage: 'Failed to install content',
},
})
const client = injectModrinthClient()
@@ -227,6 +236,7 @@ const pendingServerContentInstalls = ref<PendingServerContentInstall[]>([])
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)
function syncPendingServerContentInstalls() {
pendingServerContentInstalls.value = readPendingServerContentInstalls(serverId, worldId.value)
@@ -237,6 +247,7 @@ function handlePendingServerContentInstallsChanged(event: Event) {
.detail
if (detail?.serverId !== serverId || detail?.worldId !== worldId.value) return
syncPendingServerContentInstalls()
void flushStoredServerInstalls()
}
function getAddonInstallKey(addon: Archon.Content.v1.Addon) {
@@ -277,6 +288,50 @@ function syncContentInstallKeys(
contentInstallAddedKeys.value = new Set()
}
async function flushStoredServerInstalls() {
const wid = worldId.value
if (!wid || isFlushingStoredServerInstalls.value) return
const queuedPlans = getStoredServerAddonInstallQueue(serverId, wid)
if (queuedPlans.size === 0) return
isFlushingStoredServerInstalls.value = true
try {
const result = await flushStoredServerAddonInstallQueue({
serverId,
worldId: wid,
install: (plans) =>
client.archon.content_v1.addAddons(
serverId,
wid,
plans.map((plan) => ({
project_id: plan.projectId,
version_id: plan.versionId,
})),
),
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, wid, plan.projectId)
}
addNotification({
type: 'error',
title: formatMessage(messages.failedToInstallContent),
text: result.error instanceof Error ? result.error.message : undefined,
})
return
}
if (result.flushedPlans.length > 0) {
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
} finally {
isFlushingStoredServerInstalls.value = false
syncPendingServerContentInstalls()
}
}
function pendingInstallToContentItem(item: PendingServerContentInstall): ContentItem {
return {
project: {
@@ -428,12 +483,14 @@ watch(
() => {
syncPendingServerContentInstalls()
syncContentInstallKeys()
void flushStoredServerInstalls()
},
{ immediate: true },
)
onMounted(() => {
syncPendingServerContentInstalls()
void flushStoredServerInstalls()
window.addEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,

View File

@@ -1316,6 +1316,9 @@
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Failed to update content"
},
"hosting.content.failed-to-install": {
"defaultMessage": "Failed to install content"
},
"hosting.content.failed-to-load-modpack-content": {
"defaultMessage": "Failed to load modpack content"
},