Merge branch 'remote-config'

This commit is contained in:
Samuel Elliott 2022-07-29 16:34:33 +01:00
commit e8e5a2f5a4
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
6 changed files with 275 additions and 0 deletions

View File

@ -15,3 +15,5 @@ npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type CoopResult
npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type CoopResultWithPlayerNicknameAndIcons --no-type-check > .vscode/schema/splatnet2/coop-result.schema.json
npx ts-json-schema-generator --path src/api/nooklink-types.ts --type Newspaper --no-type-check > .vscode/schema/nooklink/newspaper.schema.json
npx ts-json-schema-generator --path src/common/remote-config.ts --type NxapiRemoteConfig --no-type-check > .vscode/schema/remote-config.schema.json

View File

@ -49,6 +49,11 @@
"fileMatch": ["**/nooklink-newspaper-*.json"],
"url": "./.vscode/schema/nooklink/newspaper.schema.json"
},
{
"fileMatch": ["**/resources/common/remote-config.json", "**/data/remote-config.json"],
"url": "./.vscode/schema/remote-config.schema.json"
},
],
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.quoteStyle": "single",

View File

@ -0,0 +1,15 @@
{
"require_version": [],
"coral": {
"znca_version": "2.1.1"
},
"coral_auth": {
"splatnet2statink": {},
"flapg": {},
"imink": {}
},
"moon": {
"znma_version": "1.17.0",
"znma_build": "261"
}
}

View File

@ -3,3 +3,4 @@ export * as validateDiscordTitles from './validate-discord-titles.js';
export * as exportDiscordTitles from './export-discord-titles.js';
export * as discordActivity from './discord-activity.js';
export * as discordRpc from './discord-rpc.js';
export * as remoteConfig from './remote-config.js';

View File

@ -0,0 +1,35 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../util.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
const debug = createDebug('cli:util:remote-config');
export const command = 'remote-config';
export const desc = 'Show nxapi remote configuration';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('json', {
describe: 'Output raw JSON',
type: 'boolean',
}).option('json-pretty-print', {
describe: 'Output pretty-printed JSON',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const { default: config } = await import('../../common/remote-config.js');
if (argv.jsonPrettyPrint) {
console.log(JSON.stringify(config, null, 4));
return;
}
if (argv.json) {
console.log(JSON.stringify(config));
return;
}
console.log('Remote config', config);
}

217
src/common/remote-config.ts Normal file
View File

@ -0,0 +1,217 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import fetch from 'node-fetch';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import { ErrorResponse } from '../api/util.js';
import { timeoutSignal } from '../util/misc.js';
import { getUserAgent } from '../util/useragent.js';
import { paths } from '../util/storage.js';
import { dev, dir, git, version } from '../util/product.js';
const debug = createDebug('nxapi:remote-config');
const CONFIG_URL = 'https://nxapi.ta.fancy.org.uk/data/config.json';
/** Maximum time in seconds to consider cached data fresh */
const MAX_FRESH = 24 * 60 * 60; // 1 day in seconds
/** Maximum time in seconds to allow using cached data after it's considered stale */
const MAX_STALE = 24 * 60 * 60; // 1 day in seconds
const default_config: NxapiRemoteConfig = {
require_version: [version],
...JSON.parse(await fs.readFile(path.join(dir, 'resources', 'common', 'remote-config.json'), 'utf-8')),
};
async function loadRemoteConfig() {
await mkdirp(paths.cache);
const config_cache_path = path.resolve(paths.cache, 'config.json');
const url = process.env.NXAPI_CONFIG_URL ?? CONFIG_URL;
let data: RemoteConfigCacheData | undefined = undefined;
let must_revalidate = true;
try {
data = JSON.parse(await fs.readFile(config_cache_path, 'utf-8'));
if (data && (data.stale_at ?? data.expires_at) > Date.now()) {
// Response is still fresh
return data;
}
if (data && data.expires_at > Date.now()) {
// Response is stale, but not expired
must_revalidate = false;
}
} catch (err) {}
try {
const config = await getRemoteConfig(url, undefined, data ? {
previous: data.data,
updated_at: new Date(data.updated_at),
etag: data.etag,
} : undefined);
const response = config[ResponseSymbol];
const cache_directives = (response.headers.get('Cache-Control') ?? '')
.split(',').map(d => d.trim().toLowerCase()).filter(d => d);
const max_age_directive = cache_directives.find(d => d.startsWith('max-age='))?.substr(8);
const max_age = max_age_directive ? Math.min(parseInt(max_age_directive), MAX_FRESH) : null;
const stale_ie_directive = cache_directives.find(d => d.startsWith('stale-if-error='))?.substr(15);
const stale_ie = stale_ie_directive ? Math.min(parseInt(stale_ie_directive), MAX_STALE) : null;
const stale_at = max_age ? Date.now() + (max_age * 1000) : null;
const expires_at =
cache_directives.includes('no-store') || cache_directives.includes('no-cache') ? 0 :
stale_ie && max_age ? Date.now() + (max_age * 1000) + (stale_ie * 1000) :
stale_at ?? 0;
const new_cache: RemoteConfigCacheData = {
created_at: config[CachedSymbol] ? data!.created_at : Date.now(),
updated_at: new Date(response.headers.get('Last-Modified') ?? Date.now()).getTime(),
etag: response.headers.get('ETag'),
revalidated_at: config[CachedSymbol] ? Date.now() : null,
stale_at,
expires_at,
url: response.url,
headers: response.headers.raw(),
data: config,
};
await fs.writeFile(config_cache_path, JSON.stringify(new_cache, null, 4) + '\n', 'utf-8');
return new_cache;
} catch (err) {
// Throw if the data was never loaded or has expired
if (!data || must_revalidate) throw err;
return data;
}
}
const ResponseSymbol = Symbol('Response');
const CachedSymbol = Symbol('Cached');
async function getRemoteConfig(url: string, useragent?: string, cache?: {
previous: NxapiRemoteConfig;
updated_at: Date;
etag: string | null;
}) {
debug('Getting remote config from %s', url);
const [signal, cancel] = timeoutSignal();
const response = await fetch(url, {
headers: {
'User-Agent': getUserAgent(),
'X-nxapi-Version': version,
'X-nxapi-Revision': git?.revision ?? undefined!,
'If-Modified-Since': cache ? cache.updated_at.toUTCString() : undefined!,
'If-None-Match': cache?.etag ?? undefined!,
},
signal,
}).finally(cancel);
if (cache && response.status === 304) {
return Object.assign({}, cache.previous, {
[ResponseSymbol]: response,
[CachedSymbol]: true,
});
}
if (response.status !== 200) {
throw new ErrorResponse('[nxapi] Unknown error', response, await response.text());
}
const config = await response.json() as NxapiRemoteConfig;
debug('Got remote config', config);
return Object.assign(config, {
[ResponseSymbol]: response,
[CachedSymbol]: false,
});
}
async function tryLoadRemoteConfig() {
try {
return await loadRemoteConfig();
} catch (err) {
console.warn('Failed to load remote configuration; falling back to default configuration', err);
return null;
}
}
const debug_fixed_config: NxapiRemoteConfig | null =
!dev ? null :
await fs.readFile(path.join(paths.data, 'remote-config.json'), 'utf-8').then(JSON.parse).catch(err => {
if (err.code === 'ENOENT') return null;
debug('Error reading local debug config');
console.warn('Error reading local debug configuration', err);
return null;
}) || null;
export enum RemoteConfigMode {
/** Always use local configuration */
DISABLE,
/** Always use remote configuration */
REQUIRE,
/** Try to use remote configuration, but allow falling back to local configuration */
OPPORTUNISTIC,
}
export const mode =
process.env.NXAPI_ENABLE_REMOTE_CONFIG !== '1' ? RemoteConfigMode.DISABLE :
process.env.NXAPI_REMOTE_CONFIG_FALLBACK === '1' ? RemoteConfigMode.OPPORTUNISTIC :
RemoteConfigMode.REQUIRE;
export const cache =
debug_fixed_config ? null :
mode === RemoteConfigMode.DISABLE ? null :
mode === RemoteConfigMode.OPPORTUNISTIC ? await tryLoadRemoteConfig() :
await loadRemoteConfig();
const config = debug_fixed_config ?? cache?.data ?? default_config;
if (cache && !config.require_version.includes(version)) {
throw new Error('nxapi update required');
}
export default config;
export interface RemoteConfigCacheData {
created_at: number;
updated_at: number;
etag: string | null;
revalidated_at: number | null;
/** Timestamp we must attempt to update the cache, but can continue to use the data if it fails */
stale_at: number | null;
/** Timestamp we must discard the cache require re-downloading the data */
expires_at: number;
url: string;
headers: Record<string, string[]>;
data: NxapiRemoteConfig;
}
export interface NxapiRemoteConfig {
/**
* Versions that may connect to Nintendo and third-party auth APIs. The nxapi version number is sent to the server
* so specific APIs can be disabled instead of using this.
*/
require_version: string[];
// If null the API should not be used
coral: CoralRemoteConfig | null;
coral_auth: {
splatnet2statink: {} | null;
flapg: {} | null;
imink: {} | null;
};
moon: MoonRemoteConfig | null;
}
export interface CoralRemoteConfig {
znca_version: string; // '2.1.1'
}
export interface MoonRemoteConfig {
znma_version: string; // '1.17.0'
znma_build: string; // '261'
}