You've already forked AstralRinth
fix: billing fix for medal servers (#6437)
* fix: billing fix for medal servers * fix: lint
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user