diff --git a/src/api/splatnet3.ts b/src/api/splatnet3.ts index 344179a..a224086 100644 --- a/src/api/splatnet3.ts +++ b/src/api/splatnet3.ts @@ -1,8 +1,8 @@ -import fetch from 'node-fetch'; +import fetch, { Response } from 'node-fetch'; import createDebug from 'debug'; import { WebServiceToken } from './coral-types.js'; import { NintendoAccountUser } from './na.js'; -import { defineResponse, ErrorResponse } from './util.js'; +import { defineResponse, ErrorResponse, HasResponse } from './util.js'; import CoralApi from './coral.js'; import { timeoutSignal } from '../util/misc.js'; import { BankaraBattleHistoriesResult, BattleHistoryCurrentPlayerResult, BulletToken, CurrentFestResult, FriendListResult, GraphQLRequest, GraphQLResponse, HistoryRecordResult, HomeResult, LatestBattleHistoriesResult, PrivateBattleHistoriesResult, RegularBattleHistoriesResult, RequestId, SettingResult, StageScheduleResult, VsHistoryDetailResult, CoopHistoryResult, CoopHistoryDetailResult, FestRecordResult, FestRecordRefetchResult, DetailFestRecordDetailResult, DetailVotingStatusResult, DetailFestVotingStatusRefetchResult, VotesUpdateFestVoteResult } from './splatnet3-types.js'; @@ -20,8 +20,14 @@ const languages = [ ]; const SPLATNET3_URL = SPLATNET3_WEBSERVICE_URL + '/api'; +const SHOULD_RENEW_TOKEN_AT = 300; // 5 minutes in seconds export default class SplatNet3Api { + onTokenShouldRenew: ((remaining: number, res: Response) => Promise) | null = null; + onTokenExpired: ((res: Response) => Promise) | null = null; + /** @internal */ + _renewToken: Promise | null = null; + protected constructor( public bullet_token: string, public version: string, @@ -31,8 +37,13 @@ export default class SplatNet3Api { async fetch( url: string, method = 'GET', body?: string | FormData, headers?: object, - /** @internal */ _log?: string - ) { + /** @internal */ _log?: string, + /** @internal */ _attempt = 0 + ): Promise> { + if (this._renewToken) { + await this._renewToken; + } + const [signal, cancel] = timeoutSignal(); const response = await fetch(SPLATNET3_URL + url, { method, @@ -50,12 +61,31 @@ export default class SplatNet3Api { signal, }).finally(cancel); - debug('fetch %s %s%s, response %s', method, url, _log ? ', ' + _log : '', response.status); + 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) { + // _renewToken will be awaited when calling fetch + this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, response).finally(() => { + this._renewToken = null; + }); + return this.fetch(url, method, body, headers, _log, _attempt + 1); + } if (response.status !== 200) { throw new ErrorResponse('[splatnet3] Non-200 status code', response, await response.text()); } + 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).finally(() => { + this._renewToken = null; + }); + } + const data = await response.json() as T; return defineResponse(data, response); diff --git a/src/common/auth/splatnet3.ts b/src/common/auth/splatnet3.ts index 7be3171..52ce7d8 100644 --- a/src/common/auth/splatnet3.ts +++ b/src/common/auth/splatnet3.ts @@ -1,5 +1,6 @@ import createDebug from 'debug'; import persist from 'node-persist'; +import { Response } from 'node-fetch'; import { getToken, Login } from './coral.js'; import SplatNet3Api, { SplatNet3AuthData } from '../../api/splatnet3.js'; import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js'; @@ -50,16 +51,64 @@ export async function getBulletToken( await storage.setItem('BulletToken.' + token, existingToken); - return { - splatnet: SplatNet3Api.createWithSavedToken(existingToken), - data: existingToken, - }; + const splatnet = SplatNet3Api.createWithSavedToken(existingToken); + splatnet.onTokenExpired = createTokenExpiredHandler(storage, token, splatnet, existingToken, proxy_url); + splatnet.onTokenShouldRenew = createTokenShouldRenewHandler(storage, token, splatnet, existingToken, proxy_url); + + return {splatnet, data: existingToken}; } debug('Using existing token'); - return { - splatnet: SplatNet3Api.createWithSavedToken(existingToken), - data: existingToken, + const splatnet = SplatNet3Api.createWithSavedToken(existingToken); + + if (allow_fetch_token) { + splatnet.onTokenExpired = createTokenExpiredHandler(storage, token, splatnet, existingToken, proxy_url); + splatnet.onTokenShouldRenew = createTokenShouldRenewHandler(storage, token, splatnet, existingToken, proxy_url); + } + + return {splatnet, data: existingToken}; +} + +function createTokenExpiredHandler( + storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, existingToken: SavedBulletToken, + znc_proxy_url?: string +) { + return (response: Response) => { + debug('Token expired, renewing'); + return renewToken(storage, token, splatnet, existingToken, znc_proxy_url); }; } + +function createTokenShouldRenewHandler( + storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, existingToken: SavedBulletToken, + znc_proxy_url?: string +) { + return (remaining: number, response: Response) => { + debug('Token will expire in %d seconds, renewing', remaining); + return renewToken(storage, token, splatnet, existingToken, znc_proxy_url); + }; +} + +async function renewToken( + storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, previousToken: SavedBulletToken, + znc_proxy_url?: string +) { + const {nso, data} = await getToken(storage, token, znc_proxy_url); + + if (data[Login]) { + const announcements = await nso.getAnnouncements(); + const friends = await nso.getFriendList(); + const webservices = await nso.getWebServices(); + const activeevent = await nso.getActiveEvent(); + } + + const existingToken: SavedBulletToken = await SplatNet3Api.loginWithCoral(nso, data.user); + + splatnet.bullet_token = existingToken.bullet_token.bulletToken; + splatnet.version = existingToken.version; + splatnet.language = existingToken.bullet_token.lang; + splatnet.useragent = existingToken.useragent; + + await storage.setItem('BulletToken.' + token, existingToken); +} diff --git a/src/discord/titles/nintendo/splatoon3.ts b/src/discord/titles/nintendo/splatoon3.ts index 560ca16..3d22590 100644 --- a/src/discord/titles/nintendo/splatoon3.ts +++ b/src/discord/titles/nintendo/splatoon3.ts @@ -16,7 +16,7 @@ import { product } from '../../../util/product.js'; const debug = createDebug('nxapi:discord:splatnet3'); export default class SplatNet3Monitor extends EmbeddedLoop { - update_interval: number = 3 * 60; // 3 minutes in seconds + update_interval: number = 1 * 60; // 1 minute in seconds splatnet: SplatNet3Api | null = null; data: SavedBulletToken | null = null; @@ -35,7 +35,7 @@ export default class SplatNet3Monitor extends EmbeddedLoop { constructor( readonly discord_presence: ExternalMonitorPresenceInterface, - readonly config: SplatNet3MonitorConfig | null, + protected config: SplatNet3MonitorConfig | null, ) { super(); } @@ -48,6 +48,7 @@ export default class SplatNet3Monitor extends EmbeddedLoop { if (config?.znc_proxy_url !== this.config?.znc_proxy_url) return false; if (config?.allow_fetch_token !== this.config?.allow_fetch_token) return false; + this.config = config; this.skipIntervalInCurrentLoop(); return true;