import { fetch, Response } from 'undici'; import { BankaraBattleHistoriesRefetchResult, BankaraBattleHistoriesRefetchVariables, GraphQLError, GraphQLErrorResponse, GraphQLRequest, GraphQLResponse, GraphQLSuccessResponse, KnownRequestId, LatestBattleHistoriesRefetchResult, LatestBattleHistoriesRefetchVariables, MyOutfitInput, PagerUpdateBattleHistoriesByVsModeResult, PagerUpdateBattleHistoriesByVsModeVariables, PrivateBattleHistoriesRefetchResult, PrivateBattleHistoriesRefetchVariables, RegularBattleHistoriesRefetchResult, RegularBattleHistoriesRefetchVariables, RequestId, ResultTypes, VariablesTypes, XBattleHistoriesRefetchResult, XBattleHistoriesRefetchVariables } from 'splatnet3-types/splatnet3'; import { WebServiceToken } from './coral-types.js'; import { CoralApiInterface } from './coral.js'; import { NintendoAccountUser } from './na.js'; import { BulletToken } from './splatnet3-types.js'; import { defineResponse, ErrorResponse, HasResponse, ResponseSymbol } from './util.js'; import createDebug from '../util/debug.js'; import { timeoutSignal } from '../util/misc.js'; const debug = createDebug('nxapi:api:splatnet3'); const debugGraphQl = createDebug('nxapi:api:splatnet3:graphql'); debugGraphQl.enabled = true; const debugUpgradeQuery = createDebug('nxapi:api:splatnet3:upgrade-query'); export const SPLATNET3_WEBSERVICE_ID = 4834290508791808; export const SPLATNET3_WEBSERVICE_URL = 'https://api.lp1.av5ja.srv.nintendo.net'; export const SPLATNET3_WEBSERVICE_USERAGENT = 'Mozilla/5.0 (Linux; Android 8.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/58.0.3029.125 Mobile Safari/537.36'; const languages = [ 'de-DE', 'en-GB', 'en-US', 'es-ES', 'es-MX', 'fr-CA', 'fr-FR', 'it-IT', 'ja-JP', 'ko-KR', 'nl-NL', 'ru-RU', 'zh-CN', 'zh-TW', ]; const SPLATNET3_URL = SPLATNET3_WEBSERVICE_URL + '/api'; const SHOULD_RENEW_TOKEN_AT = 300; // 5 minutes in seconds const TOKEN_EXPIRES_IN = 2 * 60 * 60 * 1000; // 2 hours in milliseconds export enum SplatNet3AuthErrorCode { USER_NOT_REGISTERED = 'USER_NOT_REGISTERED', ERROR_INVALID_PARAMETERS = 'ERROR_INVALID_PARAMETERS', ERROR_INVALID_GAME_WEB_TOKEN = 'ERROR_INVALID_GAME_WEB_TOKEN', ERROR_OBSOLETE_VERSION = 'ERROR_OBSOLETE_VERSION', ERROR_RATE_LIMIT = 'ERROR_RATE_LIMIT', ERROR_SERVER = 'ERROR_SERVER', ERROR_SERVER_MAINTENANCE = 'ERROR_SERVER_MAINTENANCE', } const AUTH_ERROR_CODES = { 204: SplatNet3AuthErrorCode.USER_NOT_REGISTERED, 400: SplatNet3AuthErrorCode.ERROR_INVALID_PARAMETERS, 401: SplatNet3AuthErrorCode.ERROR_INVALID_GAME_WEB_TOKEN, 403: SplatNet3AuthErrorCode.ERROR_OBSOLETE_VERSION, 429: SplatNet3AuthErrorCode.ERROR_RATE_LIMIT, 500: SplatNet3AuthErrorCode.ERROR_SERVER, 503: SplatNet3AuthErrorCode.ERROR_SERVER_MAINTENANCE, 599: SplatNet3AuthErrorCode.ERROR_SERVER, } as const; const REPLAY_CODE_REGEX = /^[A-Z0-9]{16}$/; export const RequestIdSymbol = Symbol('RequestId'); export const VariablesSymbol = Symbol('Variables'); export type PersistedQueryResult = GraphQLSuccessResponse & PersistedQueryResultData; export interface PersistedQueryResultData { [ResponseSymbol]: Response; [RequestIdSymbol]: KnownRequestId; [VariablesSymbol]: {}; } export type NotNullPersistedQueryResult< T extends PersistedQueryResult | unknown, K extends T extends PersistedQueryResult ? keyof Result : keyof T, > = T extends PersistedQueryResult ? PersistedQueryResult<{ [FieldName in keyof Result]: FieldName extends K ? Exclude : Result[FieldName]; }> : PersistedQueryResult<{ [FieldName in keyof T]: FieldName extends K ? Exclude : T[FieldName]; }>; enum MapQueriesMode { /** NXAPI_SPLATNET3_UPGRADE_QUERIES=0 - never upgrade persisted query IDs (not recommended) */ NEVER, /** NXAPI_SPLATNET3_UPGRADE_QUERIES=1 - upgrade persisted query IDs that do not contain potentially breaking changes (not recommended) */ ONLY_SAFE_NO_REJECT, /** NXAPI_SPLATNET3_UPGRADE_QUERIES=2 - upgrade persisted query IDs, but reject requests that contain potentially breaking changes */ ONLY_SAFE, /** NXAPI_SPLATNET3_UPGRADE_QUERIES=3 - upgrade persisted query IDs, including requests that contain potentially breaking changes (default) */ ALL, } export default class SplatNet3Api { onTokenShouldRenew: ((remaining: number, res: Response) => Promise) | null = null; onTokenExpired: ((res?: Response) => Promise) | null = null; /** @internal */ _renewToken: Promise | null = null; protected _token_expired = false; graphql_strict = process.env.NXAPI_SPLATNET3_STRICT !== '0'; protected constructor( public bullet_token: string, public version: string, public map_queries: Partial>, readonly map_queries_mode: MapQueriesMode, readonly na_country: string, public language: string, public useragent: string, ) {} async fetch( url: string, method = 'GET', body?: string, headers?: object, /** @internal */ _log?: string, /** @internal */ _attempt = 0, ): Promise> { if (this._token_expired && !this._renewToken) { if (!this.onTokenExpired || _attempt) throw new Error('Token expired'); this._renewToken = this.onTokenExpired.call(null).then(data => { if (data) this.setTokenWithSavedToken(data); }).finally(() => { this._renewToken = null; }); } if (this._renewToken) { await this._renewToken; } const [signal, cancel] = timeoutSignal(); const response = await fetch(SPLATNET3_URL + url, { method, headers: Object.assign({ 'User-Agent': this.useragent, 'Accept': '*/*', 'Referrer': 'https://api.lp1.av5ja.srv.nintendo.net/', 'X-Requested-With': 'XMLHttpRequest', 'authorization': 'Bearer ' + this.bullet_token, 'content-type': 'application/json', 'X-Web-View-Ver': this.version, 'Accept-Language': this.language, }, headers), body, signal, }).finally(cancel); const version = response.headers.get('x-be-version'); debug('fetch %s %s%s, response %s, server revision %s', method, url, _log ? ', ' + _log : '', response.status, version); if (response.status === 401 && !_attempt && this.onTokenExpired) { this._token_expired = true; // _renewToken will be awaited when calling fetch this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, response).then(data => { if (data) this.setTokenWithSavedToken(data); }).finally(() => { this._renewToken = null; }); return this.fetch(url, method, body, headers, _log, _attempt + 1); } if (response.status !== 200) { throw await SplatNet3ErrorResponse.fromResponse(response, '[splatnet3] Non-200 status code'); } const remaining = parseInt(response.headers.get('x-bullettoken-remaining') ?? '0'); if (remaining <= SHOULD_RENEW_TOKEN_AT && !_attempt && this.onTokenShouldRenew) { // _renewToken will be awaited when calling fetch this._renewToken = this._renewToken ?? this.onTokenShouldRenew.call(null, remaining, response).then(data => { if (data) this.setTokenWithSavedToken(data); }).finally(() => { this._renewToken = null; }); } const data = await response.json() as T; return defineResponse(data, response); } async persistedQuery< T = unknown, V = unknown, /** @private */ _Id extends string = string, /** @private */ _Result extends (T extends object ? T : _Id extends KnownRequestId ? ResultTypes[_Id] : unknown) = (T extends object ? T : _Id extends KnownRequestId ? ResultTypes[_Id] : unknown), /** @private */ _Variables extends (V extends object ? V : _Id extends KnownRequestId ? VariablesTypes[_Id] : unknown) = (V extends object ? V : _Id extends KnownRequestId ? VariablesTypes[_Id] : unknown), >(id: _Id, variables: _Variables): Promise> { id = this.getUpgradedPersistedQueryId(id) as _Id; const req: GraphQLRequest<_Variables> = { variables, extensions: { persistedQuery: { version: 1, sha256Hash: id, }, }, }; const data = await this.fetch>('/graphql', 'POST', JSON.stringify(req), undefined, 'graphql query ' + id); if (data.errors && (!('data' in data) || this.graphql_strict)) { throw SplatNet3GraphQLErrorResponse.from(data[ResponseSymbol], data as GraphQLResponseWithErrors, id, variables); } for (const error of data.errors ?? []) { debugGraphQl('GraphQL error in query %s: %s', id, error.message, error); } Object.defineProperty(data, RequestIdSymbol, {value: id}); Object.defineProperty(data, VariablesSymbol, {value: variables}); return data as PersistedQueryResult<_Result>; } getPersistedQueryId(id: RequestId) { return this.getUpgradedPersistedQueryId(id, false); } private getUpgradedPersistedQueryId(id: string, reject = true) { if (this.map_queries_mode === MapQueriesMode.NEVER) return id; const ids = [id]; let new_id = id; let unsafe = false; while (typeof this.map_queries[new_id] === 'object') { const [map_id, map_unsafe] = this.map_queries[new_id] ?? [null, false]; if (!map_id) { if (this.map_queries_mode !== MapQueriesMode.ONLY_SAFE_NO_REJECT && reject) { throw new Error('[splatnet3] Updated persisted query for ' + id + ' does not exist'); } break; } if (map_unsafe && this.map_queries_mode === MapQueriesMode.ONLY_SAFE && reject) { throw new Error('[splatnet3] Updated persisted query ' + map_id + ' for ' + id + ' contains potentially breaking changes'); } if (map_unsafe && this.map_queries_mode !== MapQueriesMode.ALL) break; new_id = map_id; unsafe = unsafe || map_unsafe; if (ids.includes(new_id)) throw new Error('[splatnet3] Loop detected while upgrading persisted query'); ids.push(new_id); } if (reject && id !== new_id) { debugUpgradeQuery('Using persisted query %s for %s', new_id, id); if (unsafe) console.warn('[warn] Upgrading SplatNet 3 persisted query %s with potentially breaking changes', id); } return new_id; } /** * */ async getCurrentFest() { return this.persistedQuery(RequestId.CurrentFestQuery, {}); } /** * */ async getConfigureAnalytics() { return this.persistedQuery(RequestId.ConfigureAnalyticsQuery, {}); } /** / */ async getHome() { return this.persistedQuery(RequestId.HomeQuery, { naCountry: this.na_country, }); } /** / -> /setting */ async getSettings() { return this.persistedQuery(RequestId.SettingQuery, { naCountry: this.na_country, }); } /** / -> /photo_album */ async getPhotoAlbum() { return this.persistedQuery(RequestId.PhotoAlbumQuery, {}); } /** / -> /photo_album -> pull-to-refresh */ async getPhotoAlbumRefetch() { return this.persistedQuery(RequestId.PhotoAlbumRefetchQuery, {}); } /** / -> /catalog_record */ async getCatalog() { return this.persistedQuery(RequestId.CatalogQuery, {}); } /** / -> /catalog_record -> pull-to-refresh */ async getCatalogRefetch() { return this.persistedQuery(RequestId.CatalogRefetchQuery, {}); } /** / -> /checkin */ async getCheckinHistory() { return this.persistedQuery(RequestId.CheckinQuery, {}); } /** / -> /checkin */ async checkin(id: string) { return this.persistedQuery(RequestId.CheckinWithQRCodeMutation, { checkinEventId: id, }); } /** / -> /friends */ async getFriends() { return this.persistedQuery(RequestId.FriendListQuery, {}); } /** / -> /friends -> pull-to-refresh */ async getFriendsRefetch() { return this.persistedQuery(RequestId.FriendListRefetchQuery, {}); } /** / -> /hero_record */ async getHeroRecords() { return this.persistedQuery(RequestId.HeroHistoryQuery, {}); } /** / -> /hero_record -> pull-to-refresh */ async getHeroRecordsRefetch() { return this.persistedQuery(RequestId.HeroHistoryRefetchQuery, {}); } /** / -> /history_record */ async getHistoryRecords() { return this.persistedQuery(RequestId.HistoryRecordQuery, {}); } /** / -> /history_record -> pull-to-refresh */ async getHistoryRecordsRefetch() { return this.persistedQuery(RequestId.HistoryRecordRefetchQuery, {}); } /** / -> /schedule */ async getSchedules() { return this.persistedQuery(RequestId.StageScheduleQuery, {}); } /** / -> /stage_record */ async getStageRecords() { return this.persistedQuery(RequestId.StageRecordQuery, {}); } /** / -> /stage_record -> pull-to-refresh */ async getStageRecordsRefetch() { return this.persistedQuery(RequestId.StageRecordsRefetchQuery, {}); } /** / -> /weapon_record */ async getWeaponRecords() { return this.persistedQuery(RequestId.WeaponRecordQuery, {}); } /** / -> /weapon_record -> pull-to-refresh */ async getWeaponRecordsRefetch() { return this.persistedQuery(RequestId.WeaponRecordsRefetchQuery, {}); } // // Wandercrust // /** / -> /challenge */ async getChallengeHome() { return this.persistedQuery(RequestId.ChallengeQuery, {}); } /** / -> /challenge -> pull-to-refresh */ async getChallengeHomeRefetch() { return this.persistedQuery(RequestId.ChallengeRefetchQuery, {}); } /** / -> /challenge -> /challenge/{id} */ async getChallengeJourney(id: string) { const result = await this.persistedQuery(RequestId.JourneyQuery, { id, }); if (!result.data.journey) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Journey not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /challenge -> /challenge/{id} -> pull-to-refresh */ async getChallengeJourneyRefetch(id: string) { const result = await this.persistedQuery(RequestId.JourneyRefetchQuery, { id, }); if (!result.data.journey) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Journey not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /challenge -> /challenge/{id} -> /challenge/{id}/*s */ async getChallengeJourneyChallenges(id: string) { const result = await this.persistedQuery(RequestId.JourneyChallengeDetailQuery, { journeyId: id, }); if (!result.data.journey) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Journey not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /challenge -> /challenge/{id} -> /challenge/{id}/* -> pull-to-refresh */ async getChallengeJourneyChallengesRefetch(id: string) { const result = await this.persistedQuery(RequestId.JourneyChallengeDetailRefetchQuery, { journeyId: id, }); if (!result.data.journey) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Journey not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /challenge -> /challenge/{id} -> /challenge/{id}/* -> support */ async supportChallenge(id: string) { return this.persistedQuery(RequestId.SupportButton_SupportChallengeMutation, { id, }); } // // Splatfests // /** / -> /fest_record */ async getFestRecords() { return this.persistedQuery(RequestId.FestRecordQuery, {}); } /** / -> /fest_record -> pull-to-refresh */ async getFestRecordsRefetch() { return this.persistedQuery(RequestId.FestRecordRefetchQuery, {}); } /** / -> /fest_record/{id} */ async getFestDetail(id: string) { const result = await this.persistedQuery(RequestId.DetailFestRecordDetailQuery, { festId: id, }); if (!result.data.fest) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /fest_record -> /fest_record/{id} -> pull-to-refresh */ async getFestDetailRefetch(id: string) { const result = await this.persistedQuery(RequestId.DetailFestRefethQuery, { festId: id, }); if (!result.data.fest) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} */ async getFestVotingStatus(id: string) { const result = await this.persistedQuery(RequestId.DetailVotingStatusQuery, { festId: id, }); if (!result.data.fest) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} -> pull-to-refresh */ async getFestVotingStatusRefetch(id: string) { const result = await this.persistedQuery(RequestId.DetailFestVotingStatusRefethQuery, { festId: id, }); if (!result.data.fest) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} - not voted in game */ async updateFestPoll(id: string) { return this.persistedQuery(RequestId.VotesUpdateFestVoteMutation, { teamId: id, }); } /** / -> /fest_record -> /fest_record/{id} - closed -> /fest_record/ranking/{id} */ async getFestRanking(id: string) { const result = await this.persistedQuery(RequestId.DetailRankingQuery, { festId: id, }); if (!result.data.fest) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result); } return result as NotNullPersistedQueryResult; } /** * / -> /fest_record -> /fest_record/* - closed -> /fest_record/ranking/* -> scroll down * * @param {string} id FestTeam ID */ async getFestRankingPagination(id: string, cursor: string) { const result = await this.persistedQuery(RequestId.RankingHoldersFestTeamRankingHoldersPaginationQuery, { cursor, first: 25, id, }); if (!result.data.node) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] FestTeam not found', result); } return result as NotNullPersistedQueryResult; } // // X Rankings // /** / -> /x_ranking */ async getXRanking(region?: XRankingRegion | null) { return this.persistedQuery(RequestId.XRankingQuery, { region: region ?? undefined, }); } /** / -> /x_ranking -> pull-to-refresh */ async getXRankingRefetch(region?: XRankingRegion | null) { return this.persistedQuery(RequestId.XRankingRefetchQuery, { region: region ?? null, }); } /** / -> /x_ranking/{id}/{rule} */ async getXRankingDetail(id: string) { return this.persistedQuery(RequestId.XRankingDetailQuery, { id, }); } /** / -> /x_ranking/{id}/{rule} -> pull-to-refresh */ async getXRankingDetailRefetch(id: string) { return this.persistedQuery(RequestId.XRankingDetailRefetchQuery, { id, pageAr: 1, pageCl: 1, pageGl: 1, pageLf: 1, }); } /** / -> /x_ranking/{id}/{rule} -> scroll down */ async getXRankingDetailPagination< T extends XRankingLeaderboardType, R extends XRankingLeaderboardRule >(id: string, type: T, rule: R, cursor: string) { const query = type === XRankingLeaderboardType.X_RANKING ? rule === XRankingLeaderboardRule.SPLAT_ZONES ? RequestId.DetailTabViewXRankingArRefetchQuery : rule === XRankingLeaderboardRule.TOWER_CONTROL ? RequestId.DetailTabViewXRankingLfRefetchQuery : rule === XRankingLeaderboardRule.RAINMAKER ? RequestId.DetailTabViewXRankingGlRefetchQuery : rule === XRankingLeaderboardRule.CLAM_BLITZ ? RequestId.DetailTabViewXRankingClRefetchQuery : null : type === XRankingLeaderboardType.WEAPON ? rule === XRankingLeaderboardRule.SPLAT_ZONES ? RequestId.DetailTabViewWeaponTopsArRefetchQuery : rule === XRankingLeaderboardRule.TOWER_CONTROL ? RequestId.DetailTabViewWeaponTopsLfRefetchQuery : rule === XRankingLeaderboardRule.RAINMAKER ? RequestId.DetailTabViewWeaponTopsGlRefetchQuery : rule === XRankingLeaderboardRule.CLAM_BLITZ ? RequestId.DetailTabViewWeaponTopsClRefetchQuery : null : null; if (!query) throw new TypeError('Invalid leaderboard'); return this.persistedQuery<{ [XRankingLeaderboardType.X_RANKING]: { [XRankingLeaderboardRule.SPLAT_ZONES]: ResultTypes[RequestId.DetailTabViewXRankingArRefetchQuery]; [XRankingLeaderboardRule.TOWER_CONTROL]: ResultTypes[RequestId.DetailTabViewXRankingLfRefetchQuery]; [XRankingLeaderboardRule.RAINMAKER]: ResultTypes[RequestId.DetailTabViewXRankingGlRefetchQuery]; [XRankingLeaderboardRule.CLAM_BLITZ]: ResultTypes[RequestId.DetailTabViewXRankingClRefetchQuery]; }; [XRankingLeaderboardType.WEAPON]: { [XRankingLeaderboardRule.SPLAT_ZONES]: ResultTypes[RequestId.DetailTabViewWeaponTopsArRefetchQuery]; [XRankingLeaderboardRule.TOWER_CONTROL]: ResultTypes[RequestId.DetailTabViewWeaponTopsLfRefetchQuery]; [XRankingLeaderboardRule.RAINMAKER]: ResultTypes[RequestId.DetailTabViewWeaponTopsGlRefetchQuery]; [XRankingLeaderboardRule.CLAM_BLITZ]: ResultTypes[RequestId.DetailTabViewWeaponTopsClRefetchQuery]; }; }[T][R]>(query, { cursor, first: 25, id, page: 1, }); } // // SplatNet Shop // /** / -> /gesotown */ async getSaleGear() { return this.persistedQuery(RequestId.GesotownQuery, {}); } /** / -> /gesotown -> pull-to-refresh */ async getSaleGearRefetch() { return this.persistedQuery(RequestId.GesotownRefetchQuery, {}); } /** / -> /gesotown -> /gesotown/{id} */ async getSaleGearDetail(id: string) { const result = await this.persistedQuery(RequestId.SaleGearDetailQuery, { saleGearId: id, }); if (!result.data.saleGear) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Sale gear not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /gesotown -> /gesotown/{id} -> order */ async orderSaleGear(id: string, force = false) { return this.persistedQuery(RequestId.SaleGearDetailOrderGesotownGearMutation, { input: { id, isForceOrder: force, }, }); } // // Freshest Fits/my outfits // /** / -> /my_outfits */ async getMyOutfits() { return this.persistedQuery(RequestId.MyOutfitsQuery, {}); } /** / -> /my_outfits -> pull-to-refresh */ async getMyOutfitsRefetch() { return this.persistedQuery(RequestId.MyOutfitsRefetchQuery, {}); } /** / -> /my_outfits -> /my_outfits/{id} */ async getMyOutfitDetail(id: string) { const result = await this.persistedQuery(RequestId.MyOutfitDetailQuery, { myOutfitId: id, }); if (!result.data.myOutfit) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] My outfit not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /my_outfits -> /my_outfits/{id / create} */ async getEquipmentFilters() { return this.persistedQuery(RequestId.MyOutfitCommonDataFilteringConditionQuery, {}); } /** / -> /my_outfits -> /my_outfits/{id / create} */ async getEquipment() { return this.persistedQuery(RequestId.MyOutfitCommonDataEquipmentsQuery, {}); } /** / -> /my_outfits -> /my_outfits/{id / create} */ async createOutfit(data: MyOutfitInput) { return this.persistedQuery(RequestId.CreateMyOutfitMutation, { input: { myOutfit: data, }, connections: [ 'client:root:__connection_myOutfits_connection', ], }); } /** / -> /my_outfits -> /my_outfits/{id / create} */ async updateOutfit(id: string, data: MyOutfitInput) { return this.persistedQuery(RequestId.UpdateMyOutfitMutation, { input: { myOutfit: { id, ...data, }, }, }); } /** / -> /my_outfits [-> /my_outfits/{id}] -> share */ async shareOutfit(index: number, timezone_offset_minutes = 0) { return this.persistedQuery(RequestId.ShareMyOutfitQuery, { myOutfitIndex: index, timezoneOffset: timezone_offset_minutes, // (new Date()).getTimezoneOffset() }); } // // Replays // /** / -> /replay */ async getReplays() { return this.persistedQuery(RequestId.ReplayQuery, {}); } /** / -> /replay -> pull-to-refetch */ async getReplaysRefetch() { return this.persistedQuery(RequestId.ReplayUploadedReplayListRefetchQuery, {}); } /** / -> /replay -> enter code */ async getReplaySearchResult(code: string) { if (!REPLAY_CODE_REGEX.test(code)) throw new Error('Invalid replay code'); const result = await this.persistedQuery(RequestId.DownloadSearchReplayQuery, { code, }); if (!result.data.replay) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Replay not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /replay -> enter code -> download */ async reserveReplayDownload(id: string) { return this.persistedQuery(RequestId.ReplayModalReserveReplayDownloadMutation, { input: { id, }, }); } // // Battle history // /** / -> /history */ async getBattleHistoryCurrentPlayer() { return this.persistedQuery(RequestId.BattleHistoryCurrentPlayerQuery, {}); } /** / -> /history */ async getLatestBattleHistories() { return this.persistedQuery(RequestId.LatestBattleHistoriesQuery, {}); } /** / -> /history -> /history/latest -> pull-to-refresh */ async getLatestBattleHistoriesRefetch() { return this.persistedQuery< LatestBattleHistoriesRefetchResult, LatestBattleHistoriesRefetchVariables >(RequestId.LatestBattleHistoriesRefetchQuery, { fetchCurrentPlayer: true, }); } /** / -> /history */ async getRegularBattleHistories() { return this.persistedQuery(RequestId.RegularBattleHistoriesQuery, {}); } /** / -> /history -> /history/regular -> pull-to-refresh */ async getRegularBattleHistoriesRefetch() { return this.persistedQuery< RegularBattleHistoriesRefetchResult, RegularBattleHistoriesRefetchVariables >(RequestId.RegularBattleHistoriesRefetchQuery, { fetchCurrentPlayer: true, }); } /** / -> /history */ async getBankaraBattleHistories() { return this.persistedQuery(RequestId.BankaraBattleHistoriesQuery, {}); } /** / -> /history -> /history/bankara -> pull-to-refresh */ async getBankaraBattleHistoriesRefetch() { return this.persistedQuery< BankaraBattleHistoriesRefetchResult, BankaraBattleHistoriesRefetchVariables >(RequestId.BankaraBattleHistoriesRefetchQuery, { fetchCurrentPlayer: true, }); } /** / -> /history */ async getXBattleHistories() { return this.persistedQuery(RequestId.XBattleHistoriesQuery, {}); } /** / -> /history -> /history/xmatch -> pull-to-refresh */ async getXBattleHistoriesRefetch() { return this.persistedQuery< XBattleHistoriesRefetchResult, XBattleHistoriesRefetchVariables >(RequestId.XBattleHistoriesRefetchQuery, { fetchCurrentPlayer: true, }); } /** / -> /history */ async getPrivateBattleHistories() { return this.persistedQuery(RequestId.PrivateBattleHistoriesQuery, {}); } /** / -> /history -> /history/private -> pull-to-refresh */ async getPrivateBattleHistoriesRefetch() { return this.persistedQuery< PrivateBattleHistoriesRefetchResult, PrivateBattleHistoriesRefetchVariables >(RequestId.PrivateBattleHistoriesRefetchQuery, { fetchCurrentPlayer: true, }); } /** / -> /history -> /history/detail/{id} */ async getBattleHistoryDetail(id: string) { const result = await this.persistedQuery(RequestId.VsHistoryDetailQuery, { vsResultId: id, }); if (!result.data.vsHistoryDetail) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Battle history not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /history -> /history/detail/{id} -> pull-to-refresh */ async getBattleHistoryDetailPagerRefetch(id: string) { const result = await this.persistedQuery(RequestId.VsHistoryDetailPagerRefetchQuery, { vsResultId: id, }); if (!result.data.vsHistoryDetail) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Battle history not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /history -> /history/detail/* -> latest */ async getBattleHistoryLatest() { return this.persistedQuery(RequestId.PagerLatestVsDetailQuery, {}); } /** / -> /history -> /history/detail/* -> latest */ async getBattleHistoryPagerUpdateByVsMode() { return this.persistedQuery< PagerUpdateBattleHistoriesByVsModeResult, PagerUpdateBattleHistoriesByVsModeVariables >(RequestId.PagerUpdateBattleHistoriesByVsModeQuery, { isBankara: false, isEvent: false, isPrivate: false, isRegular: false, isXBattle: false, }); } // // Salmon Run // /** / -> /coop */ async getCoopHistory() { return this.persistedQuery(RequestId.CoopHistoryQuery, {}); } /** / -> /coop */ async getCoopHistoryRefetch() { return this.persistedQuery(RequestId.RefetchableCoopHistory_CoopResultQuery, {}); } /** / -> /coop -> /coop/{id} */ async getCoopHistoryDetail(id: string) { const result = await this.persistedQuery(RequestId.CoopHistoryDetailQuery, { coopHistoryDetailId: id, }); if (!result.data.coopHistoryDetail) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Co-op history not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /coop -> /coop/{id} -> pull-to-refresh */ async getCoopHistoryDetailRefetch(id: string) { const result = await this.persistedQuery(RequestId.CoopHistoryDetailRefetchQuery, { id, }); if (!result.data.node) { throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Co-op history not found', result); } return result as NotNullPersistedQueryResult; } /** / -> /coop -> /coop/* -> latest */ async getCoopHistoryLatest() { return this.persistedQuery(RequestId.CoopPagerLatestCoopQuery, {}); } // // async renewTokenWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) { const data = await SplatNet3Api.loginWithCoral(coral, user); this.setTokenWithSavedToken(data); return data; } async renewTokenWithWebServiceToken(webserviceToken: WebServiceToken, user: NintendoAccountUser) { const data = await SplatNet3Api.loginWithWebServiceToken(webserviceToken, user); this.setTokenWithSavedToken(data); return data; } protected setTokenWithSavedToken(data: SplatNet3AuthData) { this.bullet_token = data.bullet_token.bulletToken; this.version = data.version; this.language = data.bullet_token.lang; this.useragent = data.useragent; this._token_expired = false; } static async createWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) { const data = await this.loginWithCoral(coral, user); return {splatnet: this.createWithSavedToken(data), data}; } static createWithSavedToken(data: SplatNet3AuthData) { return new this( data.bullet_token.bulletToken, data.version, data.queries ?? {}, getMapPersistedQueriesModeFromEnvironment(), data.country, data.bullet_token.lang, data.useragent, ); } static createWithCliTokenData(data: SplatNet3CliTokenData) { return new this( data.bullet_token, data.version, data.queries ?? {}, getMapPersistedQueriesModeFromEnvironment(), data.country ?? 'GB', data.language, SPLATNET3_WEBSERVICE_USERAGENT, ); } static async loginWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) { const { default: { coral_gws_splatnet3: config } } = await import('../common/remote-config.js'); if (!config) throw new Error('Remote configuration prevents SplatNet 3 authentication'); const webserviceToken = await coral.getWebServiceToken(SPLATNET3_WEBSERVICE_ID); return this.loginWithWebServiceToken(webserviceToken, user); } static async loginWithWebServiceToken( webserviceToken: WebServiceToken, user: NintendoAccountUser ): Promise { const { default: { coral_gws_splatnet3: config } } = await import('../common/remote-config.js'); if (!config) throw new Error('Remote configuration prevents SplatNet 3 authentication'); const language = languages.includes(user.language) ? user.language : 'en-GB'; const version = config.app_ver ?? config.version + '-' + config.revision.substr(0, 8); const url = new URL(SPLATNET3_WEBSERVICE_URL); url.search = new URLSearchParams({ lang: user.language, na_country: user.country, na_lang: user.language, }).toString(); const [signal, cancel] = timeoutSignal(); const response = await fetch(url.toString(), { headers: { 'Upgrade-Insecure-Requests': '1', 'User-Agent': SPLATNET3_WEBSERVICE_USERAGENT, 'x-appcolorscheme': 'DARK', 'x-gamewebtoken': webserviceToken.accessToken, 'dnt': '1', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'en-GB,en-US;q=0.8', 'X-Requested-With': 'com.nintendo.znca', }, signal, }).finally(cancel); debug('fetch %s %s, response %s', 'GET', url, response.status); if (response.status !== 200) { throw await SplatNet3ErrorResponse.fromResponse(response, '[splatnet3] Non-200 status code'); } const body = await response.text(); const cookies = response.headers.get('Set-Cookie'); const [signal2, cancel2] = timeoutSignal(); const tr = await fetch(SPLATNET3_URL + '/bullet_tokens', { method: 'POST', headers: { 'User-Agent': SPLATNET3_WEBSERVICE_USERAGENT, 'Accept': '*/*', 'Referrer': 'https://api.lp1.av5ja.srv.nintendo.net/', 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'X-Web-View-Ver': version, 'X-NACOUNTRY': user.country, 'Accept-Language': language, 'X-GameWebToken': webserviceToken.accessToken, }, body: '', signal: signal2, }).finally(cancel2); debug('fetch %s %s, response %s', 'POST', '/bullet_tokens', response.status); const error: SplatNet3AuthErrorCode | undefined = AUTH_ERROR_CODES[tr.status as keyof typeof AUTH_ERROR_CODES]; if (error) throw await SplatNet3AuthErrorResponse.fromResponse(tr, '[splatnet3] ' + error); if (tr.status !== 201) throw await SplatNet3ErrorResponse.fromResponse(tr, '[splatnet3] Non-201 status code'); const bullet_token = await tr.json() as BulletToken; const created_at = Date.now(); const expires_at = created_at + TOKEN_EXPIRES_IN; return { webserviceToken, url: url.toString(), cookies, body, language, country: user.country, version, queries: config.map_queries, bullet_token, created_at, expires_at, useragent: SPLATNET3_WEBSERVICE_USERAGENT, }; } } function getMapPersistedQueriesModeFromEnvironment(): MapQueriesMode { if (process.env.NXAPI_SPLATNET3_UPGRADE_QUERIES === '0') return MapQueriesMode.NEVER; if (process.env.NXAPI_SPLATNET3_UPGRADE_QUERIES === '1') return MapQueriesMode.ONLY_SAFE_NO_REJECT; if (process.env.NXAPI_SPLATNET3_UPGRADE_QUERIES === '2') return MapQueriesMode.ONLY_SAFE; if (process.env.NXAPI_SPLATNET3_UPGRADE_QUERIES === '3') return MapQueriesMode.ALL; return MapQueriesMode.ALL; } export class SplatNet3ErrorResponse extends ErrorResponse {} export class SplatNet3AuthErrorResponse extends SplatNet3ErrorResponse { constructor( message: string, response: Response | globalThis.Response, body?: string | unknown | undefined, readonly code = AUTH_ERROR_CODES[response.status as keyof typeof AUTH_ERROR_CODES] ?? SplatNet3AuthErrorCode.ERROR_SERVER, ) { super(message, response, body); } } type GraphQLResponseWithErrors = (GraphQLSuccessResponse & {errors: GraphQLError[]}) | GraphQLErrorResponse; export class SplatNet3GraphQLErrorResponse< Id extends string = string, /** @private */ _Variables extends Id extends KnownRequestId ? VariablesTypes[Id] : unknown = Id extends KnownRequestId ? VariablesTypes[Id] : unknown, > extends SplatNet3ErrorResponse { constructor( message: string, response: Response | globalThis.Response, body?: string | GraphQLResponseWithErrors | undefined, readonly request_id?: Id | string, readonly variables?: _Variables, ) { super(message, response, body); } static from(response: Response, data: GraphQLResponseWithErrors, id: string, variables: unknown) { return new SplatNet3GraphQLErrorResponse('[splatnet3] GraphQL error: ' + data.errors.map(e => e.message).join(', '), response, data, id, variables); } } export class SplatNet3GraphQLResourceNotFoundResponse< Id extends string = string, /** @private */ _Result extends Id extends KnownRequestId ? ResultTypes[Id] : unknown = Id extends KnownRequestId ? ResultTypes[Id] : unknown, /** @private */ _Variables extends Id extends KnownRequestId ? VariablesTypes[Id] : unknown = Id extends KnownRequestId ? VariablesTypes[Id] : unknown, > extends SplatNet3ErrorResponse> { constructor( message: string, response: Response | globalThis.Response, body?: string | PersistedQueryResult<_Result> | undefined, readonly request_id?: Id | string, readonly variables?: _Variables, ) { super(message, response, body); } static from(message: string, data: PersistedQueryResult) { return new SplatNet3GraphQLResourceNotFoundResponse( message, data[ResponseSymbol], data, data[RequestIdSymbol], data[VariablesSymbol]); } } export interface SplatNet3AuthData { webserviceToken: WebServiceToken; url: string; cookies: string | null; body: string; language: string; country: string; version: string; queries?: Partial>; bullet_token: BulletToken; created_at: number; /** * /api/bullet_tokens does not provide the token validity duration. Instead this assumes * the token is valid for 2 hours. GraphQL responses include the actual remaining time * in the x-bullettoken-remaining header. */ expires_at: number; useragent: string; } export interface SplatNet3CliTokenData { bullet_token: string; expires_at: number; language: string; country: string; version: string; queries?: Partial>; } export enum XRankingRegion { /** Takoroka division */ PACIFIC = 'PACIFIC', /** Tentatek division */ ATLANTIC = 'ATLANTIC', } export enum XRankingLeaderboardType { X_RANKING, WEAPON, } export enum XRankingLeaderboardRule { SPLAT_ZONES, TOWER_CONTROL, RAINMAKER, CLAM_BLITZ, }