fix: use localstorage for sync state during install (#6057)
* fix: use localstorage for sync state during install * fix: lint
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user