diff --git a/README.md b/README.md index b9a5065..8aa33b3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ nxapi === -Access the Nintendo Switch Online and Nintendo Switch Parental Controls app APIs. Includes Discord Rich Presence, friend notifications and data downloads. +JavaScript library and command line and Electron app for accessing the Nintendo Switch Online and Nintendo Switch Parental Controls app APIs. Show your Nintendo Switch presence in Discord, get friend notifications on desktop, and download and access SplatNet 2, NookLink and Parental Controls data. [![Discord](https://img.shields.io/discord/998657768594608138?color=5865f2&label=Discord)](https://discord.com/invite/4D82rFkXRv) @@ -39,7 +39,7 @@ Access the Nintendo Switch Online and Nintendo Switch Parental Controls app APIs - Download island newspapers from and send messages and reactions using NookLink - Download all Nintendo Switch Parental Controls usage records -The API library and types are exported for use in JavaScript/TypeScript software. The app/commands properly cache access tokens and try to handle requests to appear as Nintendo's apps - if using nxapi as a library you will need to handle this yourself. +The API library and types are exported for use in JavaScript/TypeScript software. The app/commands properly cache access tokens and try to handle requests to appear as Nintendo's apps - if using nxapi as a library you will need to handle this yourself. [More information.](#usage-as-a-typescriptjavascript-library) #### Electron app @@ -744,6 +744,67 @@ const pkg = JSON.parse(await readFile(resolve(fileURLToPath(import.meta.url), '. addUserAgent(pkg.name + '/' + pkg.version + ' (+' + pkg.repository.url + ')'); ``` +#### Usage as a TypeScript/JavaScript library + +nxapi exports it's API library and types. [See src/exports.](src/exports) + +> You must set a user agent string using the `addUserAgent` function when using anything that contacts non-Nintendo APIs, such as the splatnet2statink API. + +> Please read https://github.com/frozenpandaman/splatnet2statink/wiki/api-docs if you intend to share anything you create. + +> nxapi uses native ECMAScript modules. nxapi also uses features like top-level await, so it cannot be converted to CommonJS using Rollup or similar. If you need to use nxapi from CommonJS modules or other module systems, use a [dynamic import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import). + +> If you need any help using nxapi as a library [join the Discord server](https://discord.com/invite/4D82rFkXRv) or [create a discussion](https://github.com/samuelthomas2774/nxapi/discussions/new). + +Example authenticating to the Nintendo Switch Online app: + +> This is a simplified example of authenticating to the Coral API and using cached tokens. More logic is required to ensure you are using these APIs properly - [see src/common/auth/nso.ts for the authentication functions used in nxapi's CLI and Electron app](src/common/auth/nso.ts). + +```ts +import { addUserAgent } from 'nxapi'; +import CoralApi from 'nxapi/coral'; + +addUserAgent('your-script/1.0.0 (+https://github.com/...)'); + +declare function getCachedCoralToken(): [string, Date]; +declare function setCachedCoralToken(token: string, expires_at: Date): void; +declare function getNintendoAccountSessionToken(): string; + +let coral; + +try { + const [token, expires_at] = getCachedCoralToken(); + if (expires_at.getTime() > Date.now()) throw new Error('Token expired'); + + coral = new CoralApi(token); +} catch (err) { + const na_session_token = getNintendoAccountSessionToken(); + const {nso, data} = await CoralApi.createWithSessionToken(na_session_token); + setCachedCoralToken(data.credential.accessToken, Date.now() + (data.credential.expiresIn * 1000)); + coral = nso; +} + +const friends = await coral.getFriendList(); +``` + +Example getting SplatNet 2 records: + +> This example does not include authenticating to SplatNet 2. To benefit from the caching in the nxapi command, the `nxapi splatnet2 token --json` command can be used in most scripts. For example: +> +> ```sh +> # your-script.js can then read the iksm_session, unique player ID and region from `JSON.parse(process.env.SPLATNET_TOKEN)` +> SPLATNET_TOKEN=`nxapi splatnet2 token --json` node your-script.js +> ``` + +```ts +import SplatNet2Api from 'nxapi/splatnet2'; + +const iksm_session = '...'; +const splatnet2 = new SplatNet2Api(iksm_session); + +const records = await splatnet2.getRecords(); +``` + ### Links - Nintendo Switch Online app API docs diff --git a/package.json b/package.json index 7e34329..83f4248 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,13 @@ "resources/common", "bin" ], + "exports": { + ".": "./dist/exports/index.js", + "./coral": "./dist/exports/coral.js", + "./moon": "./dist/exports/moon.js", + "./splatnet2": "./dist/exports/splatnet2.js", + "./nooklink": "./dist/exports/nooklink.js" + }, "bin": { "nxapi": "bin/nxapi.js" }, diff --git a/src/api/f.ts b/src/api/f.ts index 2e7e755..34d7c29 100644 --- a/src/api/f.ts +++ b/src/api/f.ts @@ -10,7 +10,7 @@ const debugFlapg = createDebug('nxapi:api:flapg'); const debugImink = createDebug('nxapi:api:imink'); const debugZncaApi = createDebug('nxapi:api:znca-api'); -abstract class ZncaApi { +export abstract class ZncaApi { constructor( public useragent?: string ) {} @@ -291,7 +291,7 @@ export type FResult = { result: AndroidZncaFResponse; }); -function getZncaApiFromEnvironment(useragent?: string): ZncaApi { +export function getZncaApiFromEnvironment(useragent?: string): ZncaApi { if (process.env.NXAPI_ZNCA_API) { if (process.env.NXAPI_ZNCA_API === 'flapg') { return new ZncaApiFlapg(useragent); diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index 45285b6..53db341 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -178,9 +178,14 @@ export type PresenceUrlResponse = CurrentUser | {user: CurrentUser} | Friend | {friend: Friend}; -export async function getPresenceFromUrl(presence_url: string) { +export async function getPresenceFromUrl(presence_url: string, useragent?: string) { const [signal, cancel] = timeoutSignal(); - const response = await fetch(presence_url, {signal}).finally(cancel); + const response = await fetch(presence_url, { + headers: { + 'User-Agent': getUserAgent(useragent), + }, + signal, + }).finally(cancel); debug('fetch %s %s, response %s', 'GET', presence_url, response.status); diff --git a/src/app/main/util.ts b/src/app/main/util.ts index 6467f5d..97aa334 100644 --- a/src/app/main/util.ts +++ b/src/app/main/util.ts @@ -6,15 +6,19 @@ import { dir } from '../../util/product.js'; export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle'); -export async function getNativeImageFromUrl(url: URL | string) { - const response = await fetch(url.toString()); +export async function getNativeImageFromUrl(url: URL | string, useragent?: string) { + const response = await fetch(url.toString(), { + headers: { + 'User-Agent': useragent ?? '', + }, + }); const image = await response.arrayBuffer(); return nativeImage.createFromBuffer(Buffer.from(image)); } -export async function tryGetNativeImageFromUrl(url: URL | string) { +export async function tryGetNativeImageFromUrl(url: URL | string, useragent?: string) { try { - return await getNativeImageFromUrl(url); + return await getNativeImageFromUrl(url, useragent); } catch (err) {} return undefined; diff --git a/src/app/main/webservices.ts b/src/app/main/webservices.ts index 9cea0f3..bf840aa 100644 --- a/src/app/main/webservices.ts +++ b/src/app/main/webservices.ts @@ -273,7 +273,11 @@ export class WebServiceIpc { debug('Downloading image %s to %s as %s', req.image_url, dir, filename); - const response = await fetch(req.image_url); + const response = await fetch(req.image_url, { + headers: { + 'User-Agent': '', + }, + }); const image = await response.arrayBuffer(); await fs.writeFile(path.join(dir, filename), Buffer.from(image)); diff --git a/src/cli/nso/znc-proxy-tokens.ts b/src/cli/nso/znc-proxy-tokens.ts index 87aacce..32b2b83 100644 --- a/src/cli/nso/znc-proxy-tokens.ts +++ b/src/cli/nso/znc-proxy-tokens.ts @@ -6,6 +6,7 @@ import { getToken } from '../../common/auth/nso.js'; import { AuthPolicy, AuthToken } from '../../api/znc-proxy.js'; import { Argv } from '../../util/yargs.js'; import { initStorage } from '../../util/storage.js'; +import { getUserAgent } from '../../util/useragent.js'; const debug = createDebug('cli:nso:znc-proxy-tokens'); @@ -157,6 +158,7 @@ export function builder(yargs: Argv) { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + argv.token, + 'User-Agent': getUserAgent(), }, }); debug('fetch %s %s, response %d', 'DELETE', '/token', response.status); diff --git a/src/cli/util/discord-activity.ts b/src/cli/util/discord-activity.ts index 9e08f78..b794129 100644 --- a/src/cli/util/discord-activity.ts +++ b/src/cli/util/discord-activity.ts @@ -8,6 +8,8 @@ import { DiscordPresenceContext, DiscordPresencePlayTime, getDiscordPresence, ge import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; import { initStorage } from '../../util/storage.js'; import { getToken } from '../../common/auth/nso.js'; +import { timeoutSignal } from '../../util/misc.js'; +import { getUserAgent } from '../../util/useragent.js'; const debug = createDebug('cli:util:discord-activity'); @@ -234,7 +236,14 @@ export async function getDiscordApplicationRpc(id: string) { const url = 'https://discord.com/api/v9/applications/' + id + '/rpc'; - const response = await fetch(url); + const [signal, cancel] = timeoutSignal(); + const response = await fetch(url, { + headers: { + 'User-Agent': getUserAgent(), + }, + signal, + }).finally(cancel); + debug('fetch %s %s, response %s', 'GET', url, response.status); if (response.status !== 200) { diff --git a/src/common/splatnet2/dump-records.ts b/src/common/splatnet2/dump-records.ts index 032ed35..1c5c873 100644 --- a/src/common/splatnet2/dump-records.ts +++ b/src/common/splatnet2/dump-records.ts @@ -108,7 +108,12 @@ export async function dumpProfileImage( debug('Fetching profile image', share); const [signal, cancel] = timeoutSignal(); - const image_response = await fetch(share.url, {signal}).finally(cancel); + const image_response = await fetch(share.url, { + headers: { + 'User-Agent': splatnet.useragent, + }, + signal, + }).finally(cancel); const image = await image_response.arrayBuffer(); debug('Writing profile image %s', image_filename); @@ -142,7 +147,12 @@ export async function dumpChallenges( debug('Fetching challenge image for %s', challenge.key, share); const [signal, cancel] = timeoutSignal(); - const image_response = await fetch(share.url, {signal}).finally(cancel); + const image_response = await fetch(share.url, { + headers: { + 'User-Agent': splatnet.useragent, + }, + signal, + }).finally(cancel); const image = await image_response.arrayBuffer(); debug('Writing challenge image %s', filename); diff --git a/src/common/splatnet2/dump-results.ts b/src/common/splatnet2/dump-results.ts index 1fc8487..c6c3bcf 100644 --- a/src/common/splatnet2/dump-results.ts +++ b/src/common/splatnet2/dump-results.ts @@ -53,7 +53,12 @@ export async function dumpResults( debug('Fetching battle results summary image', share); const [signal, cancel] = timeoutSignal(); - const image_response = await fetch(share.url, {signal}).finally(cancel); + const image_response = await fetch(share.url, { + headers: { + 'User-Agent': splatnet.useragent, + }, + signal, + }).finally(cancel); const image = await image_response.arrayBuffer(); debug('Writing battle results summary image %s', filename); @@ -111,7 +116,12 @@ export async function dumpResults( debug('Fetching battle results image', share); const [signal, cancel] = timeoutSignal(); - const image_response = await fetch(share.url, {signal}).finally(cancel); + const image_response = await fetch(share.url, { + headers: { + 'User-Agent': splatnet.useragent, + }, + signal, + }).finally(cancel); const image = await image_response.arrayBuffer(); debug('Writing battle results image %s', filename); diff --git a/src/exports/coral.ts b/src/exports/coral.ts new file mode 100644 index 0000000..211d3f7 --- /dev/null +++ b/src/exports/coral.ts @@ -0,0 +1,22 @@ +export { default } from '../api/coral.js'; +export * from '../api/coral-types.js'; + +export { default as ZncProxyApi } from '../api/znc-proxy.js'; + +export { + ZncaApi, + getZncaApiFromEnvironment, + f, + + ZncaApiFlapg, + FlapgIid, + FlapgApiResponse, + + ZncaApiImink, + IminkFResponse, + IminkFError, + + ZncaApiNxapi, + AndroidZncaFResponse, + AndroidZncaFError, +} from '../api/f.js'; diff --git a/src/exports/index.ts b/src/exports/index.ts new file mode 100644 index 0000000..4e6c093 --- /dev/null +++ b/src/exports/index.ts @@ -0,0 +1,3 @@ +export { getTitleIdFromEcUrl } from '../util/misc.js'; +export { ErrorResponse } from '../api/util.js'; +export { addUserAgent } from '../util/useragent.js'; diff --git a/src/exports/moon.ts b/src/exports/moon.ts new file mode 100644 index 0000000..a59f00f --- /dev/null +++ b/src/exports/moon.ts @@ -0,0 +1,2 @@ +export { default } from '../api/moon.js'; +export * from '../api/moon-types.js'; diff --git a/src/exports/nooklink.ts b/src/exports/nooklink.ts new file mode 100644 index 0000000..74cc391 --- /dev/null +++ b/src/exports/nooklink.ts @@ -0,0 +1,7 @@ +export { + default as NooklinkApi, + NooklinkUserApi, + MessageType, +} from '../api/nooklink.js'; + +export * from '../api/nooklink-types.js'; diff --git a/src/exports/splatnet2.ts b/src/exports/splatnet2.ts new file mode 100644 index 0000000..1742a6c --- /dev/null +++ b/src/exports/splatnet2.ts @@ -0,0 +1,19 @@ +export { + default, + LeagueType, + LeagueRegion, + ShareColour as ShareProfileColour, + toLeagueId, +} from '../api/splatnet2.js'; + +export { + Season as XRankSeason, + Rule as XPowerRankingRule, + getAllSeasons as getXRankSeasons, + getSeason as getXRankSeason, + getNextSeason as getNextXRankSeason, + getPreviousSeason as getPreviousXRankSeason, + toSeasonId as toXRankSeasonId, +} from '../api/splatnet2-xrank.js'; + +export * from '../api/splatnet2-types.js';