diff --git a/src/app/(profile)/profile/[destinyMembershipId]/page.tsx b/src/app/(profile)/profile/[destinyMembershipId]/page.tsx index aab14537..3d5ddcea 100644 --- a/src/app/(profile)/profile/[destinyMembershipId]/page.tsx +++ b/src/app/(profile)/profile/[destinyMembershipId]/page.tsx @@ -10,6 +10,7 @@ import { } from "~/lib/profile/prefetch" import { type ProfileProps } from "~/lib/profile/types" import { bungieProfileIconUrl } from "~/util/destiny" +import { isValidDestinyMembershipId } from "~/util/destiny/routeParams" export const revalidate = 0 @@ -19,7 +20,27 @@ type PageProps = { } } +function isBenignLinkedProfilesError(err: unknown): boolean { + if (err instanceof DOMException && err.name === "AbortError") { + return true + } + + if (err instanceof Error) { + return ( + err.name === "AbortError" || + err.message.includes("aborted") || + err.message.includes("Operation failed after") + ) + } + + return false +} + export default async function Page({ params }: PageProps) { + if (!isValidDestinyMembershipId(params.destinyMembershipId)) { + notFound() + } + // Find the app profile by id if it exists const [appProfile, basicProfile] = await Promise.all([ getUniqueProfileByDestinyMembershipId(params.destinyMembershipId), @@ -32,7 +53,13 @@ export default async function Page({ params }: PageProps) { const linkedProfilesResponse = await prefetchDestinyLinkedProfiles( params.destinyMembershipId - ) + ).catch(err => { + if (isBenignLinkedProfilesError(err)) { + return null + } + + throw err + }) const applicableMemberships = linkedProfilesResponse?.profiles.filter( m => m.applicableMembershipTypes.length > 0 ) @@ -85,6 +112,15 @@ export default async function Page({ params }: PageProps) { } export async function generateMetadata({ params }: PageProps): Promise { + if (!isValidDestinyMembershipId(params.destinyMembershipId)) { + return { + robots: { + follow: true, + index: false + } + } + } + const [profile, basic] = await Promise.all([ getUniqueProfileByDestinyMembershipId(params.destinyMembershipId), prefetchRaidHubPlayerBasic(params.destinyMembershipId) diff --git a/src/app/clan/[groupId]/page.tsx b/src/app/clan/[groupId]/page.tsx index 949d07b5..b54e4bfa 100644 --- a/src/app/clan/[groupId]/page.tsx +++ b/src/app/clan/[groupId]/page.tsx @@ -1,14 +1,24 @@ import { type Metadata } from "next" +import { notFound } from "next/navigation" import { ClanComponent } from "~/components/__deprecated__/clan/Clan" import { PageWrapper } from "~/components/PageWrapper" import { baseMetadata } from "~/lib/metadata" import { fixClanName } from "~/util/destiny/fixClanName" +import { isValidClanGroupId } from "~/util/destiny/routeParams" import { getClan, type PageProps } from "../server" export const revalidate = 0 export default async function Page({ params }: PageProps) { + if (!isValidClanGroupId(params.groupId)) { + notFound() + } + const clan = await getClan(params.groupId) + if (!clan) { + notFound() + } + return ( @@ -17,6 +27,10 @@ export default async function Page({ params }: PageProps) { } export async function generateMetadata({ params }: PageProps): Promise { + if (!isValidClanGroupId(params.groupId)) { + return {} + } + const clan = await getClan(params.groupId) if (!clan) return {} diff --git a/src/app/clan/server.ts b/src/app/clan/server.ts index d38461b8..57367021 100644 --- a/src/app/clan/server.ts +++ b/src/app/clan/server.ts @@ -16,7 +16,7 @@ const clanClient = new ServerBungieClient({ timeout: 6000 }) -const expectedErrorCodes = [1, 621, 622, 686] +const expectedErrorCodes = [1, 7, 621, 622, 686] export const getClan = reactRequestDedupe(async (groupId: string) => getGroup(clanClient, { groupId }) diff --git a/src/components/__deprecated__/profile/raids/raids.module.css b/src/components/__deprecated__/profile/raids/raids.module.css index 33643a9d..80d5c9e8 100644 --- a/src/components/__deprecated__/profile/raids/raids.module.css +++ b/src/components/__deprecated__/profile/raids/raids.module.css @@ -223,6 +223,12 @@ cursor: pointer; } +.dot > circle, +.dot > polygon, +.dot > svg { + pointer-events: none; +} + .dot:hover circle { r: 8; } diff --git a/src/components/profile/raids/finder/InstanceFinder.tsx b/src/components/profile/raids/finder/InstanceFinder.tsx index f212558b..ba9d7e03 100644 --- a/src/components/profile/raids/finder/InstanceFinder.tsx +++ b/src/components/profile/raids/finder/InstanceFinder.tsx @@ -7,7 +7,8 @@ import { OptionalWrapper } from "~/components/OptionalWrapper" import { usePageProps } from "~/components/PageWrapper" import { useSession } from "~/hooks/app/useSession" import { type ProfileProps } from "~/lib/profile/types" -import { useInstances } from "~/services/raidhub/useRaidHubInstances" +import { RaidHubError } from "~/services/raidhub/RaidHubError" +import { RETRIABLE_RAIDHUB_ERROR_CODES, useInstances } from "~/services/raidhub/useRaidHubInstances" import { InstanceFinderForm } from "./InstanceFinderForm" import { InstanceTable } from "./InstanceTable" @@ -88,7 +89,15 @@ const InstanceFinderInternal = memo(() => {
{state.isIdle &&

Enter your search criteria above to find instances.

} {state.isLoading &&

Loading...

} - {state.isError &&

Error: {(state.error as Error).message}

} + {state.isError && ( +

+ Error:{" "} + {state.error instanceof RaidHubError && + RETRIABLE_RAIDHUB_ERROR_CODES.has(state.error.errorCode) + ? "RaidHub is temporarily unavailable. Please try again in a moment." + : (state.error as Error).message} +

+ )} {state.isSuccess && state.data.length === 0 &&

No instances found.

} {state.isSuccess && state.data.length > 0 && ( diff --git a/src/components/providers/LocaleManager.tsx b/src/components/providers/LocaleManager.tsx index d4a0d722..bdff5bda 100644 --- a/src/components/providers/LocaleManager.tsx +++ b/src/components/providers/LocaleManager.tsx @@ -5,6 +5,15 @@ import { type DestinyManifestLanguage } from "bungie-net-core/manifest" import { userAgentFromString } from "next/server" import { createContext, useContext, useEffect, useState, type ReactNode } from "react" +function isSupportedLocaleTag(locale: string): boolean { + const trimmed = locale.trim() + return trimmed.length > 0 && /^[a-z]{2,3}(-[a-z0-9]+)*$/i.test(trimmed) +} + +function sanitizeNavigatorLocales(locales: readonly string[]): string[] { + return locales.filter(isSupportedLocaleTag) +} + const d2ManifestLocales = [ "en", "fr", @@ -37,9 +46,12 @@ export function LocaleManager(props: { children: ReactNode } | null) { const [manifestLanguage, setManifestLanguage] = useState("en") useEffect(() => { - setLocale(navigator.language) + const sanitizedLanguages = sanitizeNavigatorLocales(navigator.languages) + const preferredLocale = sanitizeNavigatorLocales([navigator.language])[0] ?? "en-US" + + setLocale(preferredLocale) const matchedLanguage = match( - navigator.languages, + sanitizedLanguages.length > 0 ? sanitizedLanguages : [preferredLocale], d2ManifestLocales.map(locale => { const transformedLocale = locale .replace(/-chs$/i, "-Hans") diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts index 9173d8d8..4b207be5 100644 --- a/src/instrumentation-client.ts +++ b/src/instrumentation-client.ts @@ -1,4 +1,5 @@ import * as Sentry from "@sentry/nextjs" +import { installBrowserCompatShims } from "./lib/browser-compat" import { beforeSendClientEvent } from "./lib/sentry/client" import { getSentryDsnForClient, @@ -8,6 +9,8 @@ import { } from "./lib/sentry/env" import { sentrySharedOptions } from "./lib/sentry/shared-options" +installBrowserCompatShims() + const dsn = getSentryDsnForClient() if (dsn) { diff --git a/src/lib/browser-compat.ts b/src/lib/browser-compat.ts new file mode 100644 index 00000000..57ec03c2 --- /dev/null +++ b/src/lib/browser-compat.ts @@ -0,0 +1,51 @@ +/** + * Early client shims for third-party scripts and embedded browsers that assume + * APIs our app does not provide. Loaded from instrumentation-client before React. + */ +export function installBrowserCompatShims(): void { + if (typeof window === "undefined") { + return + } + + // Tampermonkey "open in new tab" scripts set SVGAElement.target (read-only in modern browsers). + try { + const proto = window.SVGAElement?.prototype + if (proto) { + const descriptor = Object.getOwnPropertyDescriptor(proto, "target") + if (descriptor && !descriptor.set) { + Object.defineProperty(proto, "target", { + ...descriptor, + set() { + return + }, + configurable: true + }) + } + } + } catch { + // Read-only prototype in some environments — safe to continue. + } + + // In-app browsers inject sendDataToNative expecting window.webkit.messageHandlers. + try { + if (!("webkit" in window)) { + const messageHandlers = new Proxy( + {}, + { + get: () => ({ + postMessage: () => { + return + } + }) + } + ) + + Object.defineProperty(window, "webkit", { + value: { messageHandlers }, + configurable: true + }) + } + } catch { + // Cannot define webkit — continue without shim. + } +} diff --git a/src/lib/profile/prefetch.ts b/src/lib/profile/prefetch.ts index 01707e6d..f021a0d0 100644 --- a/src/lib/profile/prefetch.ts +++ b/src/lib/profile/prefetch.ts @@ -109,8 +109,16 @@ export const prefetchDestinyLinkedProfiles = reactRequestDedupe((membershipId: s .catch(e => { if (e instanceof BungiePlatformError) { return null - } else { - throw e } + + if (e instanceof DOMException && e.name === "AbortError") { + return null + } + + if (e instanceof Error && (e.name === "AbortError" || e.message.includes("aborted"))) { + return null + } + + throw e }) ) diff --git a/src/lib/sentry/policy.ts b/src/lib/sentry/policy.ts index 68fb98a2..0c752052 100644 --- a/src/lib/sentry/policy.ts +++ b/src/lib/sentry/policy.ts @@ -38,7 +38,9 @@ const HANDLED_RAIDHUB_ERROR_CODES = new Set([ "PathValidationError", "QueryValidationError", "BodyValidationError", - "BungieServiceOffline" + "BungieServiceOffline", + "InternalServerError", + "ServiceUnavailableError" ]) const HANDLED_TRPC_ERROR_CODES = new Set(["NOT_FOUND", "UNAUTHORIZED", "FORBIDDEN", "BAD_REQUEST"]) @@ -191,6 +193,12 @@ function isTransientTrpcHtmlError(error: unknown): boolean { return message.includes("Unexpected token '<'") || message.includes("([ 1672, // DestinyThrottledByGameServer, 1618, // DestinyUnexpectedError — Bungie 500 blips, often succeeds on retry + 1626, // DestinyInternalError — transient Bungie 500 on clan/profile lookups 1688 // DestinyDirectBabelClientTimeout ]) + /** Cloudflare / Bungie HTML error pages — retry once before surfacing. */ + static readonly TransientHttpStatuses = new Set([502, 503, 504, 520, 522, 524]) + static readonly ExpectedErrorCodes = new Set([ 5, // SystemDisabled + 7, // ParameterParseFailure — invalid clan/group id in URL 8, // ParameterInvalidRange — bad membership/type combos on clan lookups 18, // InvalidParameters — bad membership/type combos on optional lookups (e.g. clans) 217, // UserCannotResolveCentralAccount — player search miss @@ -95,6 +100,7 @@ export default abstract class BaseBungieClient implements BungieClientProtocol { 1600, // DestinyAccountAcquisitionFailure — no linked Destiny account 1601, // DestinyAccountNotFound — deleted/wrong-platform membership on profile 1618, // DestinyUnexpectedError — Bungie-side 500, surfaced in UI as load failure + 1626, // DestinyInternalError — Bungie-side blip on optional clan lookups 1653, // PGCRNotFound 1665, // DestinyPrivacyRestriction — private profile 1688 // DestinyDirectBabelClientTimeout diff --git a/src/services/bungie/ClientBungieClient.ts b/src/services/bungie/ClientBungieClient.ts index 8fe73ce4..b8b10cf0 100644 --- a/src/services/bungie/ClientBungieClient.ts +++ b/src/services/bungie/ClientBungieClient.ts @@ -1,7 +1,7 @@ import type { BungieFetchConfig } from "bungie-net-core" import EventEmitter from "events" import { withBungieAuthFailure } from "~/lib/sentry/context" -import { BungieHTTPError, BungiePlatformError } from "~/models/BungieAPIError" +import { BungieHTMLError, BungieHTTPError, BungiePlatformError } from "~/models/BungieAPIError" import BaseBungieClient from "./BungieClient" export default class ClientBungieClient extends BaseBungieClient { @@ -96,10 +96,18 @@ export default class ClientBungieClient extends BaseBungieClient { }) } else if ( err instanceof BungiePlatformError && - ClientBungieClient.RetryableErrorCodes.has(err.ErrorCode) + ClientBungieClient.RetryableErrorCodes.has(err.ErrorCode) && + !url.searchParams.has("retry") ) { url.searchParams.set("retry", err.cause.ErrorStatus) return this.request(url, payload) + } else if ( + err instanceof BungieHTMLError && + ClientBungieClient.TransientHttpStatuses.has(err.status) && + !url.searchParams.has("retry") + ) { + url.searchParams.set("retry", `html-${err.status}`) + return this.request(url, payload) } else { throw err } diff --git a/src/services/bungie/ServerBungieClient.ts b/src/services/bungie/ServerBungieClient.ts index b939a1b2..f18a6863 100644 --- a/src/services/bungie/ServerBungieClient.ts +++ b/src/services/bungie/ServerBungieClient.ts @@ -3,7 +3,7 @@ import "server-only" import type { BungieFetchConfig } from "bungie-net-core" import { saferFetch } from "~/lib/server/saferFetch" import { baseUrl } from "~/lib/server/utils" -import { BungiePlatformError } from "~/models/BungieAPIError" +import { BungieHTMLError, BungiePlatformError } from "~/models/BungieAPIError" import BaseBungieClient from "~/services/bungie/BungieClient" export default class ServerBungieClient extends BaseBungieClient { @@ -67,11 +67,21 @@ export default class ServerBungieClient extends BaseBungieClient { console.error(err) if ( err instanceof BungiePlatformError && - ServerBungieClient.RetryableErrorCodes.has(err.ErrorCode) + ServerBungieClient.RetryableErrorCodes.has(err.ErrorCode) && + !url.searchParams.has("retry") ) { url.searchParams.set("retry", err.cause.ErrorStatus) return this.request(url, payload) as T } + + if ( + err instanceof BungieHTMLError && + ServerBungieClient.TransientHttpStatuses.has(err.status) && + !url.searchParams.has("retry") + ) { + url.searchParams.set("retry", `html-${err.status}`) + return this.request(url, payload) as T + } } throw err } diff --git a/src/services/bungie/hooks/useClan.ts b/src/services/bungie/hooks/useClan.ts index 05c6ad26..ee64f729 100644 --- a/src/services/bungie/hooks/useClan.ts +++ b/src/services/bungie/hooks/useClan.ts @@ -2,6 +2,20 @@ import { useQuery } from "@tanstack/react-query" import { getGroup } from "bungie-net-core/endpoints/GroupV2" import { type GroupResponse } from "bungie-net-core/models" import { useBungieClient } from "~/components/providers/session/BungieClientProvider" +import { isValidClanGroupId } from "~/util/destiny/routeParams" + +function isRetriableClanFetchError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + return ( + error.message.includes("Content-Length header of network response exceeds response Body") || + error.message === "Failed to fetch" || + error.message === "Load failed" || + error.message.includes("NetworkError") + ) +} export const useClan = ( params: { groupId: string }, @@ -15,6 +29,7 @@ export const useClan = ( return useQuery({ queryKey: ["bungie", "clan", params] as const, + enabled: isValidClanGroupId(params.groupId), queryFn: ({ queryKey }) => getGroup(bungieClient, queryKey[2]).then(res => { if (res.Response.detail.groupType != 1) { @@ -22,6 +37,8 @@ export const useClan = ( } return res.Response }), + retry: (failureCount, error) => failureCount < 3 && isRetriableClanFetchError(error), + retryDelay: failureCount => Math.min(2 ** failureCount * 1000, 8000), ...opts }) } diff --git a/src/services/raidhub/useRaidHubInstances.ts b/src/services/raidhub/useRaidHubInstances.ts index 2656f4da..5ec6ee13 100644 --- a/src/services/raidhub/useRaidHubInstances.ts +++ b/src/services/raidhub/useRaidHubInstances.ts @@ -1,8 +1,13 @@ import { useMutation } from "@tanstack/react-query" import { useSession } from "~/hooks/app/useSession" +import { RaidHubError } from "./RaidHubError" import { getRaidHubApi } from "./common" import type { InstanceFinderQuery } from "./types" +const RETRIABLE_RAIDHUB_ERROR_CODES = new Set(["InternalServerError", "ServiceUnavailableError"]) + +export { RETRIABLE_RAIDHUB_ERROR_CODES } + async function getInstances({ membershipId, bearerToken, @@ -35,12 +40,42 @@ export const useInstances = () => { return useMutation({ mutationKey: ["raidhub", "instances"], - mutationFn: ({ + mutationFn: async ({ destinyMembershipId, query }: { destinyMembershipId: string query: InstanceFinderQuery - }) => getInstances({ membershipId: destinyMembershipId, bearerToken, query }) + }) => { + let lastError: unknown + + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await getInstances({ + membershipId: destinyMembershipId, + bearerToken, + query + }) + } catch (error) { + lastError = error + + if ( + !( + error instanceof RaidHubError && + RETRIABLE_RAIDHUB_ERROR_CODES.has(error.errorCode) + ) || + attempt === 2 + ) { + throw error + } + + await new Promise(resolve => + setTimeout(resolve, Math.min(2 ** attempt * 1000, 8000)) + ) + } + } + + throw lastError + } }) } diff --git a/src/util/destiny/routeParams.ts b/src/util/destiny/routeParams.ts new file mode 100644 index 00000000..70857357 --- /dev/null +++ b/src/util/destiny/routeParams.ts @@ -0,0 +1,20 @@ +/** Bungie destiny membership IDs are 19-digit decimal strings. */ +const DESTINY_MEMBERSHIP_ID_PATTERN = /^\d{19}$/ + +/** Clan group IDs are numeric Bungie identifiers. */ +const CLAN_GROUP_ID_PATTERN = /^\d{1,12}$/ + +/** PGCR instance IDs are numeric. */ +const INSTANCE_ID_PATTERN = /^\d{1,20}$/ + +export function isValidDestinyMembershipId(id: string): boolean { + return DESTINY_MEMBERSHIP_ID_PATTERN.test(id) +} + +export function isValidClanGroupId(id: string): boolean { + return CLAN_GROUP_ID_PATTERN.test(id) +} + +export function isValidInstanceId(id: string): boolean { + return INSTANCE_ID_PATTERN.test(id) +} diff --git a/src/util/dexie/dexie.ts b/src/util/dexie/dexie.ts index 1b38800e..97efec3a 100644 --- a/src/util/dexie/dexie.ts +++ b/src/util/dexie/dexie.ts @@ -419,8 +419,11 @@ export function isDexieManifestStorageError(err: unknown): boolean { return ( name === "BulkError" || name === "MissingAPIError" || + name === "TypeError" || message.includes("Destiny manifest update failed") || - message.includes("bulkPut()") + message.includes("bulkPut()") || + message === "e is undefined" || + message === "r is undefined" ) } @@ -471,6 +474,21 @@ export async function recoverDexieDatabase(db: CustomDexie): Promise { const dexieDB = new CustomDexie() +if (typeof window !== "undefined") { + window.addEventListener("unhandledrejection", event => { + if ( + !isDexieManifestStorageError(event.reason) && + !isDexieConnectionLostError(event.reason) + ) { + return + } + + event.preventDefault() + console.warn("Dexie unhandled rejection — recovering in-memory", event.reason) + void recoverDexieDatabase(dexieDB) + }) +} + export const useDexie = () => { return dexieDB }