feat(local): compose login account and device sources
This commit is contained in:
@@ -7,6 +7,17 @@ export interface LocalStoragePort {
|
||||
|
||||
export const LOCAL_MACHINE_KEY = 'youle.local-test.machine.v1';
|
||||
|
||||
export function sampleLocalRandom(
|
||||
random: () => number,
|
||||
failure: (reason: string, cause?: unknown) => Error,
|
||||
): number {
|
||||
let value: number;
|
||||
try { value = random(); } catch (cause) { throw failure('source failed', cause); }
|
||||
if (!Number.isFinite(value)) throw failure('expected finite number');
|
||||
if (value < 0 || value >= 1) throw failure('expected range [0, 1)');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function localAccountKey(context: LocalStartupContext): string {
|
||||
assertLocalStartupContext(context);
|
||||
const { identity } = context.config;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { LocalStartupContext } from '../../config/local-startup.ts';
|
||||
import type { LoginAccountIdentity, LoginDeviceSnapshot } from '../../protocol/contracts/index.ts';
|
||||
import { LOCAL_MACHINE_KEY, sampleLocalRandom, type LocalStoragePort } from './local-storage.ts';
|
||||
import { createLocalVisitorProvider } from './visitor-account.ts';
|
||||
|
||||
export interface LocalHostSnapshot {
|
||||
readonly location: unknown;
|
||||
readonly returnCitySN?: unknown;
|
||||
}
|
||||
|
||||
export interface LocalLoginSources {
|
||||
prepareLogin(): Promise<LoginAccountIdentity>;
|
||||
getLoginDeviceSnapshot(): LoginDeviceSnapshot;
|
||||
resetAccount(): Promise<LoginAccountIdentity>;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
readonly context: LocalStartupContext;
|
||||
readonly storage: LocalStoragePort;
|
||||
readonly random: () => number;
|
||||
readonly now: () => number;
|
||||
readonly sampleHost: () => LocalHostSnapshot;
|
||||
readonly isSessionActive: () => boolean;
|
||||
}
|
||||
|
||||
function machineFailure(path: string, reason: string, cause?: unknown): Error {
|
||||
const error = new Error(`Invalid local machine cache ${LOCAL_MACHINE_KEY} at ${path}: ${reason}`) as Error & { cause?: unknown };
|
||||
if (cause !== undefined) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getMachineId(options: Options): string {
|
||||
let raw: string | null;
|
||||
try { raw = options.storage.getItem(LOCAL_MACHINE_KEY); }
|
||||
catch (cause) { throw machineFailure('$', 'storage read failed', cause); }
|
||||
if (raw !== null) {
|
||||
let record: unknown;
|
||||
try { record = JSON.parse(raw); }
|
||||
catch (cause) { throw machineFailure('$', 'invalid JSON', cause); }
|
||||
if (!isRecord(record)) throw machineFailure('$', 'expected object');
|
||||
if (record.schemaVersion !== 1) throw machineFailure('schemaVersion', 'expected 1');
|
||||
for (const field of Object.keys(record)) {
|
||||
if (field !== 'schemaVersion' && field !== 'machineid') throw machineFailure(field, 'unknown field');
|
||||
}
|
||||
if (typeof record.machineid !== 'string') throw machineFailure('machineid', 'expected string');
|
||||
const match = /^(0|[1-9][0-9]*)-([1-9][0-9]{3})$/.exec(record.machineid);
|
||||
if (!match || !Number.isSafeInteger(Number(match[1])) || Number(match[2]) > 9998) {
|
||||
throw machineFailure('machineid', 'expected nonnegative safe timestamp and suffix 1000..9998');
|
||||
}
|
||||
return record.machineid;
|
||||
}
|
||||
let timestamp: number;
|
||||
try { timestamp = options.now(); }
|
||||
catch (cause) { throw machineFailure('now', 'source failed', cause); }
|
||||
if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw machineFailure('now', 'expected nonnegative safe integer');
|
||||
const random = sampleLocalRandom(options.random, (reason, cause) => machineFailure('random', reason, cause));
|
||||
const machineid = `${timestamp}-${1000 + Math.floor(random * 8999)}`;
|
||||
try { options.storage.setItem(LOCAL_MACHINE_KEY, JSON.stringify({ schemaVersion: 1, machineid })); }
|
||||
catch (cause) { throw machineFailure('$', 'storage write failed', cause); }
|
||||
return machineid;
|
||||
}
|
||||
|
||||
export function createLocalLoginSources(options: Options): LocalLoginSources {
|
||||
const provider = createLocalVisitorProvider(options);
|
||||
let device: LoginDeviceSnapshot | undefined;
|
||||
let preparing: Promise<LoginAccountIdentity> | undefined;
|
||||
let resetting: Promise<LoginAccountIdentity> | undefined;
|
||||
|
||||
function prepareLogin(): Promise<LoginAccountIdentity> {
|
||||
if (resetting) return Promise.reject(new Error('Cannot prepare login while reset is pending'));
|
||||
if (preparing) return preparing;
|
||||
device = undefined;
|
||||
preparing = (async () => {
|
||||
const cachedAccount = await provider.getAccount();
|
||||
const machineid = getMachineId(options);
|
||||
const snapshot = options.sampleHost();
|
||||
if (!isRecord(snapshot) || !Object.prototype.hasOwnProperty.call(snapshot, 'location')
|
||||
|| (snapshot.location !== null && !isRecord(snapshot.location))) {
|
||||
throw new Error('Invalid local host location: expected explicit null or record');
|
||||
}
|
||||
const city = snapshot.returnCitySN;
|
||||
if (city !== undefined) {
|
||||
if (!isRecord(city)) throw new Error('Invalid local host returnCitySN: expected record');
|
||||
for (const field of ['ip', 'province', 'city'] as const) {
|
||||
if (typeof city[field] !== 'string') throw new Error(`Invalid local host returnCitySN.${field}: expected string`);
|
||||
}
|
||||
}
|
||||
const account: LoginAccountIdentity = Object.freeze({ ...cachedAccount,
|
||||
...(city === undefined ? {} : { province: city.province as string, city: city.city as string }),
|
||||
});
|
||||
const nextDevice: LoginDeviceSnapshot = Object.freeze({ location: snapshot.location,
|
||||
machineid, machineroom: '', deviceLogin: Object.freeze({ enabled: false as const }),
|
||||
loginPlayerId: Object.freeze({ enabled: false as const }),
|
||||
...(city === undefined ? {} : { ip: city.ip as string }),
|
||||
});
|
||||
device = nextDevice;
|
||||
return account;
|
||||
})().finally(() => { preparing = undefined; });
|
||||
return preparing;
|
||||
}
|
||||
|
||||
function resetAccount(): Promise<LoginAccountIdentity> {
|
||||
device = undefined;
|
||||
if (preparing) return Promise.reject(new Error('Cannot reset account while prepare is pending'));
|
||||
if (resetting) return Promise.reject(new Error('Cannot reset account while reset is pending'));
|
||||
resetting = provider.resetAccount().finally(() => { resetting = undefined; });
|
||||
return resetting;
|
||||
}
|
||||
|
||||
function getLoginDeviceSnapshot(): LoginDeviceSnapshot {
|
||||
if (device === undefined) throw new Error('Local login device requires a successful prepareLogin');
|
||||
return device;
|
||||
}
|
||||
|
||||
return Object.freeze({ prepareLogin, resetAccount, getLoginDeviceSnapshot });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LoginAccountIdentity } from '../../protocol/contracts/index.ts';
|
||||
import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts';
|
||||
import { localAccountKey, type LocalStoragePort } from './local-storage.ts';
|
||||
import { localAccountKey, sampleLocalRandom, type LocalStoragePort } from './local-storage.ts';
|
||||
|
||||
const AVATARS = Object.freeze([
|
||||
'https://img2.baidu.com/it/u=1823726962,2692568839&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
@@ -67,11 +67,7 @@ function parseCached(raw: string, key: string): LoginAccountIdentity {
|
||||
}
|
||||
|
||||
function sample(random: () => number, key: string): number {
|
||||
let value: number;
|
||||
try { value = random(); } catch (cause) { throw wrap(key, 'random', 'source failed', cause); }
|
||||
if (!Number.isFinite(value)) throw wrap(key, 'random', 'expected finite number');
|
||||
if (value < 0 || value >= 1) throw wrap(key, 'random', 'expected range [0, 1)');
|
||||
return value;
|
||||
return sampleLocalRandom(random, (reason, cause) => wrap(key, 'random', reason, cause));
|
||||
}
|
||||
|
||||
function generate(random: () => number, key: string): LoginAccountIdentity {
|
||||
|
||||
Reference in New Issue
Block a user