feat(local): compose login account and device sources

This commit is contained in:
2026-09-05 14:51:24 +08:00
parent 562f58dd2d
commit e00d051112
4 changed files with 306 additions and 6 deletions
@@ -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 {