fix: billing fix for medal servers (#6437)

* fix: billing fix for medal servers

* fix: lint
This commit is contained in:
Calum H.
2026-06-19 19:04:25 +01:00
committed by GitHub
parent 50b2b9567c
commit 8e6004fdd5
6 changed files with 195 additions and 263 deletions
+2 -68
View File
@@ -21,7 +21,6 @@
:server-name="`${auth?.user?.username}'s server`"
:out-of-stock-url="outOfStockUrl"
:fetch-capacity-statuses="fetchCapacityStatuses"
:pings="regionPings"
:regions="regions"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
@@ -1233,81 +1232,16 @@ const planQuery = async () => {
}
const regions = ref([])
const regionPings = ref([])
function pingRegions() {
function fetchRegions() {
client.archon.servers_v1.getRegions().then((res) => {
regions.value = res
regions.value.forEach((region) => {
runPingTest(region)
})
})
}
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
function runPingTest(region, index = initialIndex[region.shortcode] ?? 1) {
if (index > (initialIndex[region.shortcode] ?? 1) + 10) {
regionPings.value.push({
region: region.shortcode,
ping: -1,
})
return
}
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
try {
const socket = new WebSocket(wsUrl)
const pings = []
socket.onopen = () => {
for (let i = 0; i < PING_COUNT; i++) {
setTimeout(() => {
socket.send(performance.now())
}, i * PING_INTERVAL)
}
setTimeout(
() => {
socket.close()
const median = Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
if (median) {
regionPings.value.push({
region: region.shortcode,
ping: median,
})
}
},
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
)
}
socket.onmessage = (event) => {
pings.push(performance.now() - event.data)
}
socket.onerror = (event) => {
console.error(
`Failed to connect pingtest WebSocket with ${wsUrl}, trying index ${index + 1}:`,
event,
)
runPingTest(region, index + 1)
}
} catch (error) {
console.error(`Failed to connect pingtest WebSocket with ${wsUrl}:`, error)
}
}
onMounted(() => {
startTyping()
planQuery()
pingRegions()
fetchRegions()
})
watch(customer, (newCustomer) => {
+1
View File
@@ -44,6 +44,7 @@ export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/nod
export * from './types'
export { withJWTRetry } from './utils/jwt-retry'
export { getNodeWebSocketUrl } from './utils/node-url'
export { pingWebSocketUrl, type WebSocketPingOptions } from './utils/pingtest'
export {
type ParsedSseEvent,
type ParsedSseItem,
+97
View File
@@ -0,0 +1,97 @@
export interface WebSocketPingOptions {
count?: number
intervalMs?: number
settleDelayMs?: number
timeoutMs?: number
signal?: AbortSignal
}
export async function pingWebSocketUrl(
url: string,
options: WebSocketPingOptions = {},
): Promise<number> {
const count = options.count ?? 5
const intervalMs = options.intervalMs ?? 200
const settleDelayMs = options.settleDelayMs ?? 1000
const timeoutMs = options.timeoutMs ?? count * intervalMs + settleDelayMs + 1000
if (options.signal?.aborted) return -1
return await new Promise<number>((resolve) => {
const samples: number[] = []
const timers = new Set<ReturnType<typeof setTimeout>>()
let socket: WebSocket | undefined
let settled = false
const setTrackedTimeout = (callback: () => void, ms: number) => {
const timer = setTimeout(() => {
timers.delete(timer)
callback()
}, ms)
timers.add(timer)
return timer
}
const cleanup = () => {
for (const timer of timers) clearTimeout(timer)
timers.clear()
options.signal?.removeEventListener('abort', abort)
if (
socket &&
(socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN)
) {
socket.close()
}
}
const finish = (ping: number) => {
if (settled) return
settled = true
cleanup()
resolve(ping)
}
const abort = () => finish(-1)
options.signal?.addEventListener('abort', abort, { once: true })
try {
socket = new WebSocket(url)
} catch {
finish(-1)
return
}
setTrackedTimeout(() => finish(-1), timeoutMs)
socket.onopen = () => {
for (let i = 0; i < count; i++) {
setTrackedTimeout(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(String(performance.now()))
}
}, i * intervalMs)
}
setTrackedTimeout(
() => {
const ping =
samples.length > 0
? Math.round([...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)])
: -1
finish(ping)
},
count * intervalMs + settleDelayMs,
)
}
socket.onmessage = (event) => {
samples.push(performance.now() - Number(event.data))
}
socket.onerror = () => finish(-1)
socket.onclose = () => {
if (!settled && samples.length === 0) finish(-1)
}
})
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { type Archon, type Labrinth, pingWebSocketUrl } from '@modrinth/api-client'
import {
CheckCircleIcon,
ChevronRightIcon,
@@ -10,7 +10,7 @@ import {
} from '@modrinth/assets'
import { useQueryClient } from '@tanstack/vue-query'
import type Stripe from 'stripe'
import { computed, nextTick, ref, toRef, useTemplateRef, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, toRef, useTemplateRef, watch } from 'vue'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
@@ -43,7 +43,6 @@ const props = defineProps<{
paymentMethods: Stripe.PaymentMethod[]
customer: Stripe.Customer
currency: string
pings: RegionPing[]
regions: Archon.Servers.v1.Region[]
availableProducts: Labrinth.Billing.Internal.Product[]
planStage?: boolean
@@ -117,7 +116,13 @@ const steps: Step[] = props.planStage
: (['region', 'payment', 'review'] as Step[])
const isUpgrade = computed(() => !!props.existingSubscription)
const skipRegionStep = computed(() => isUpgrade.value && !customServer.value)
const existingSubscriptionRegion = computed(() =>
props.existingSubscription?.metadata?.type === 'pyro'
? props.existingSubscription.metadata.region
: undefined,
)
const hideRegionSelection = computed(() => isUpgrade.value && !!existingSubscriptionRegion.value)
const skipRegionStep = computed(() => !customServer.value && hideRegionSelection.value)
const visibleSteps = computed(() => steps.filter((s) => !(s === 'region' && skipRegionStep.value)))
const titles: Record<Step, MessageDescriptor> = {
@@ -144,12 +149,81 @@ const currentRegion = computed(() => {
return props.regions.find((region) => region.shortcode === selectedRegion.value)
})
const regionPings = ref<RegionPing[]>([])
const currentPing = computed(() => {
return props.pings.find((ping) => ping.region === currentRegion.value?.shortcode)?.ping
return regionPings.value.find((ping) => ping.region === currentRegion.value?.shortcode)?.ping
})
const currentStep = ref<Step>()
const PING_COUNT = 5
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex: Record<string, number> = {
'eu-lim': 31,
}
let regionPingAbortController: AbortController | null = null
function setRegionPing(region: Archon.Servers.v1.Region, ping: number) {
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
regionPings.value.push({
region: region.shortcode,
ping,
})
}
function stopRegionPings() {
regionPingAbortController?.abort()
regionPingAbortController = null
regionPings.value = []
}
function startRegionPings() {
regionPingAbortController?.abort()
regionPings.value = []
const controller = new AbortController()
regionPingAbortController = controller
for (const region of props.regions) {
void runRegionPing(region, initialIndex[region.shortcode] ?? 1, controller.signal)
}
}
function ensureRegionPings() {
if (regionPingAbortController && !regionPingAbortController.signal.aborted) return
startRegionPings()
}
async function runRegionPing(region: Archon.Servers.v1.Region, index: number, signal: AbortSignal) {
if (signal.aborted) return
const ping = await pingWebSocketUrl(`wss://${region.shortcode}${index}.${region.zone}/pingtest`, {
count: PING_COUNT,
intervalMs: PING_INTERVAL,
settleDelayMs: MAX_PING_TIME,
timeoutMs: PING_COUNT * PING_INTERVAL + MAX_PING_TIME + 1000,
signal,
})
if (signal.aborted) return
if (ping > 0) {
setRegionPing(region, ping)
} else {
setRegionPing(region, -1)
}
}
function startRegionPingsIfNeeded() {
if (currentStep.value === 'region') {
ensureRegionPings()
}
}
const currentStepIndex = computed(() =>
currentStep.value ? visibleSteps.value.indexOf(currentStep.value) : -1,
)
@@ -268,6 +342,7 @@ async function setStep(step: Step | undefined, skipValidation = false) {
if (await beforeProceed(step)) {
currentStep.value = step
startRegionPingsIfNeeded()
await nextTick()
await afterProceed(step)
@@ -285,10 +360,10 @@ watch(
)
watch(
() => props.existingSubscription,
(sub) => {
if (sub?.metadata?.type === 'pyro' && sub.metadata.region) {
selectedRegion.value = sub.metadata.region
existingSubscriptionRegion,
(region) => {
if (region) {
selectedRegion.value = region
}
},
{ immediate: true },
@@ -325,6 +400,7 @@ function begin(
currentStep.value = skipPlanStep
? (visibleSteps.value[1] ?? visibleSteps.value[0])
: visibleSteps.value[0]
startRegionPingsIfNeeded()
skipPaymentMethods.value = true
projectId.value = project
modal.value?.show()
@@ -338,6 +414,13 @@ const emit = defineEmits<{
(e: 'hide' | 'purchase-success'): void
}>()
function handleHide() {
stopRegionPings()
emit('hide')
}
onBeforeUnmount(stopRegionPings)
function handleChooseCustom() {
customServer.value = true
selectedPlan.value = undefined
@@ -365,7 +448,7 @@ function goToBreadcrumbStep(id: string) {
}
</script>
<template>
<NewModal ref="modal" @hide="$emit('hide')">
<NewModal ref="modal" @hide="handleHide">
<template #title>
<div class="flex items-center gap-1 font-bold text-secondary">
<template v-for="(step, index) in visibleSteps" :key="step">
@@ -408,9 +491,9 @@ function goToBreadcrumbStep(id: string) {
v-model:region="selectedRegion"
v-model:plan="selectedPlan"
:regions="regions"
:pings="pings"
:pings="regionPings"
:custom="customServer"
:hide-region-selection="isUpgrade"
:hide-region-selection="hideRegionSelection"
:available-products="availableProducts"
:currency="currency"
:interval="selectedInterval"
@@ -10,7 +10,6 @@
:payment-methods="paymentMethods"
:currency="selectedCurrency"
:return-url="checkoutReturnUrl"
:pings="regionPings"
:regions="regionsData"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
@@ -62,13 +61,6 @@ const customer = ref<any>(null)
const paymentMethods = ref<any[]>([])
const selectedCurrency = ref<string>('USD')
const regionPings = ref<
{
region: string
ping: number
}[]
>([])
const pyroProducts = (props.products as Labrinth.Billing.Internal.Product[])
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
.sort((a, b) => {
@@ -96,14 +88,6 @@ const { data: regionsData } = useQuery({
queryFn: () => archon.servers_v1.getRegions(),
})
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
watch(
customerData,
(newCustomer) => {
@@ -120,18 +104,6 @@ watch(
{ immediate: true },
)
watch(
regionsData,
(newRegions) => {
if (newRegions) {
newRegions.forEach((region) => {
runPingTest(region)
})
}
},
{ immediate: true },
)
async function fetchPaymentData() {
await refetchPaymentMethods()
}
@@ -144,57 +116,6 @@ async function fetchStock(
return result.available
}
function runPingTest(
region: Archon.Servers.v1.Region,
index = initialIndex[region.shortcode] ?? 1,
) {
if (index > (initialIndex[region.shortcode] ?? 1) + 10) {
regionPings.value.push({
region: region.shortcode,
ping: -1,
})
return
}
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
try {
const socket = new WebSocket(wsUrl)
const pings: number[] = []
socket.onopen = () => {
for (let i = 0; i < PING_COUNT; i++) {
setTimeout(() => {
socket.send(String(performance.now()))
}, i * PING_INTERVAL)
}
setTimeout(
() => {
socket.close()
const median = Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
if (median) {
regionPings.value.push({
region: region.shortcode,
ping: median,
})
}
},
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
)
}
socket.onmessage = (event) => {
const start = Number(event.data)
pings.push(performance.now() - start)
}
socket.onerror = () => {
runPingTest(region, index + 1)
}
} catch {
// ignore
}
}
const subscription = ref<Labrinth.Billing.Internal.UserSubscription | null>(null)
const pendingDowngradeBody = ref<Labrinth.Billing.Internal.EditSubscriptionRequest | null>(null)
const currentPlanFromSubscription = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
@@ -23,7 +23,6 @@
:customer="customer"
:payment-methods="paymentMethods"
:currency="selectedCurrency"
:pings="regionPings"
:regions="regions"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
@@ -407,12 +406,6 @@ const medalUpgradeModal = ref<UpgradeModalRef | null>(null)
const resubscribeModal = ref<InstanceType<typeof ResubscribeModal> | null>(null)
const affiliateCode = ref<string | null>(null)
const selectedCurrency = ref<string>('USD')
const regionPings = ref<
{
region: string
ping: number
}[]
>([])
const pyroProducts = computed(() => {
return [...props.products]
@@ -453,27 +446,6 @@ const { data: regions, isLoading: regionsLoading } = useQuery({
enabled: loggedIn,
})
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
watch(
regions,
(newRegions) => {
regionPings.value = []
if (newRegions) {
newRegions.forEach((region) => {
runPingTest(region)
})
}
},
{ immediate: true },
)
async function fetchPaymentData() {
await Promise.all([refetchCustomer(), refetchPaymentMethods()])
}
@@ -486,82 +458,6 @@ async function fetchStock(
return result.available
}
function runPingTest(
region: Archon.Servers.v1.Region,
index = initialIndex[region.shortcode] ?? 1,
) {
if (index > (initialIndex[region.shortcode] ?? 1) + 10) {
regionPings.value.push({
region: region.shortcode,
ping: -1,
})
return
}
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
try {
const socket = new WebSocket(wsUrl)
const pings: number[] = []
let finalized = false
const finalize = (ping: number) => {
if (finalized) return
finalized = true
clearTimeout(connectTimeout)
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
regionPings.value.push({
region: region.shortcode,
ping,
})
socket.close()
}
const retryNext = () => {
if (finalized) return
finalized = true
clearTimeout(connectTimeout)
socket.close()
runPingTest(region, index + 1)
}
// Prevent hangs where the socket never opens or errors.
const connectTimeout = setTimeout(() => {
retryNext()
}, 3000)
socket.onopen = () => {
clearTimeout(connectTimeout)
for (let i = 0; i < PING_COUNT; i++) {
setTimeout(() => {
socket.send(String(performance.now()))
}, i * PING_INTERVAL)
}
setTimeout(
() => {
const median =
pings.length > 0
? Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
: -1
finalize(median)
},
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
)
}
socket.onmessage = (event) => {
const start = Number(event.data)
pings.push(performance.now() - start)
}
socket.onerror = () => {
retryNext()
}
} catch {
runPingTest(region, index + 1)
}
}
const {
data: serverResponse,
error: fetchError,