From 34be99d84ab9b10b7b016d62ffaf3c4f84c03fbf Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 14:30:55 +0800 Subject: [PATCH] feat(config): add local startup boundary --- .../assets/framework/config/local-startup.ts | 125 ++++++++++++++ .../config/local-startup.test.ts | 154 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/config/local-startup.ts create mode 100644 cocoscreator_projects/framework-tests/config/local-startup.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/local-startup.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/local-startup.ts new file mode 100644 index 0000000..3bfef45 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/local-startup.ts @@ -0,0 +1,125 @@ +import { resolveIdentity } from './identity.ts'; +import { PROFILES } from './profiles.ts'; +import { + resolveRuntimeConfig, + type ResolveRuntimeConfigOptions, + type RuntimeConfig, +} from './runtime-config.ts'; + +export interface LocalStartupContext { + readonly hostKind: 'h5'; + readonly profile: 'local'; + readonly config: RuntimeConfig; +} + +const QUERY_CONTROLS = Object.freeze([ + 'profile', + 'mode', + 'gameconfig', + 'agentid', + 'channelid', + 'marketid', + 'version', + 'gameid', +] as const); + +function fail(field: string, reason: string): never { + throw new Error(`Invalid local startup ${field}: ${reason}`); +} + +function validateLocalUrl(url: string, field: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + fail(field, 'url must be absolute'); + } + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { + fail(field, 'url protocol must be ws: or wss:'); + } + if (parsed.username !== '' || parsed.password !== '') { + fail(field, 'url credentials are forbidden'); + } + const hostname = parsed.hostname.toLowerCase(); + if ( + hostname !== 'localhost' + && hostname !== '[::1]' + && !/^127(?:\.[0-9]{1,3}){3}$/.test(hostname) + ) { + fail(field, 'url hostname must be loopback'); + } +} + +function assertLocalQuery(search: string): void { + const query = new URLSearchParams(search); + for (const field of QUERY_CONTROLS) { + if (query.getAll(field).length > 1) fail(field, 'duplicate query control'); + } + + const profiles = query.getAll('profile'); + if (profiles.length !== 1 || profiles[0] !== 'local') { + fail('profile', 'must occur exactly once with value local'); + } + const modes = query.getAll('mode'); + if (modes.length === 1 && modes[0] !== 'debug') fail('mode', 'must be debug'); + const gameconfigs = query.getAll('gameconfig'); + if (gameconfigs.length === 1 && gameconfigs[0] !== '') { + fail('gameconfig', 'must be empty'); + } + if (query.has('gameid')) fail('gameid', 'query override is forbidden'); +} + +export async function resolveLocalStartup( + options: ResolveRuntimeConfigOptions, +): Promise { + if (options.mode !== 'debug') fail('mode', 'must be debug'); + if (options.hostKind !== 'h5') fail('hostKind', 'must be h5'); + assertLocalQuery(options.search); + + const config = await resolveRuntimeConfig(options); + const context = Object.freeze({ hostKind: 'h5' as const, profile: 'local' as const, config }); + assertLocalStartupContext(context); + return context; +} + +export function assertLocalStartupContext(context: LocalStartupContext): void { + if (context.hostKind !== 'h5') fail('hostKind', 'must be h5'); + if (context.profile !== 'local') fail('profile', 'must be local'); + + const { config } = context; + if (config.mode !== 'debug') fail('mode', 'must be debug'); + if (config.isDebugger !== true) fail('isDebugger', 'must be true'); + if (config.source !== 'direct') fail('source', 'must be direct'); + if (config.gameserver !== null) fail('gameserver', 'must be null'); + if (config.rawConfig !== null) fail('rawConfig', 'must be null'); + + try { + resolveIdentity([() => config.identity]); + } catch (error) { + if (error instanceof Error) throw error; + fail('identity', 'is invalid'); + } + + const authoritative = PROFILES.local?.servers; + if (!authoritative || authoritative.length === 0) { + fail('servers', 'PROFILES.local must define servers'); + } + if (config.servers.length !== authoritative.length) { + fail('servers', 'must equal PROFILES.local.servers'); + } + for (let index = 0; index < authoritative.length; index += 1) { + const expected = authoritative[index]; + validateLocalUrl(expected, `servers[${index}]`); + if (config.servers[index] !== expected) { + fail('servers', 'must equal PROFILES.local.servers'); + } + } +} + +export function assertLocalConnectionTarget(context: LocalStartupContext, url: string): void { + assertLocalStartupContext(context); + validateLocalUrl(url, 'url'); + if (!PROFILES.local?.servers?.includes(url)) { + fail('url', 'target must occur in PROFILES.local.servers'); + } +} diff --git a/cocoscreator_projects/framework-tests/config/local-startup.test.ts b/cocoscreator_projects/framework-tests/config/local-startup.test.ts new file mode 100644 index 0000000..cec3cb6 --- /dev/null +++ b/cocoscreator_projects/framework-tests/config/local-startup.test.ts @@ -0,0 +1,154 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + assertLocalConnectionTarget, + assertLocalStartupContext, + resolveLocalStartup, + type LocalStartupContext, +} from '../../YouleNexus/assets/framework/config/local-startup.ts'; +import { PROFILES } from '../../YouleNexus/assets/framework/config/profiles.ts'; +import type { ResolveRuntimeConfigOptions, RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; +import type { ConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config.ts'; +import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts'; + +function options(search = '?profile=local'): ResolveRuntimeConfigOptions & { + fetcher: ConfigFetcher & { calls: number }; +} { + const fetcher = { + calls: 0, + async fetch(): Promise { + this.calls += 1; + throw new Error('forbidden fetch'); + }, + }; + return { mode: 'debug', hostKind: 'h5', win: {}, search, fetcher }; +} + +test('local debug resolves the authoritative direct config without fetching', async () => { + const input = options(); + const context = await resolveLocalStartup(input); + + assert.equal(context.hostKind, 'h5'); + assert.equal(context.profile, 'local'); + assert.equal(context.config.identity.gameid, BUILD_IDENTITY.gameid); + assert.deepEqual(context.config.servers, PROFILES.local!.servers); + assert.equal(input.fetcher.calls, 0); + assert.equal(Object.isFrozen(context), true); +}); + +test('rejects non-local startup modes and hosts before fetching', async () => { + const cases: Array<[Partial, RegExp]> = [ + [{ mode: 'release' }, /mode.*debug/i], + [{ hostKind: 'native-settings' }, /hostKind.*h5/i], + [{ hostKind: 'uAgent_3' }, /hostKind.*h5/i], + ]; + for (const [override, expected] of cases) { + const base = options(); + const input = { ...base, ...override }; + await assert.rejects(resolveLocalStartup(input), expected); + assert.equal(base.fetcher.calls, 0); + } +}); + +test('requires exactly one local profile and only an optional single debug mode', async () => { + const cases: Array<[string, RegExp]> = [ + ['', /profile/i], + ['?profile=prod', /profile/i], + ['?profile=staging', /profile/i], + ['?profile=local&profile=local', /profile/i], + ['?profile=local&mode=release', /mode.*debug/i], + ['?profile=local&mode=debug&mode=debug', /mode/i], + ]; + for (const [search, expected] of cases) { + const input = options(search); + await assert.rejects(resolveLocalStartup(input), expected); + assert.equal(input.fetcher.calls, 0); + } + await resolveLocalStartup(options('?profile=local&mode=debug')); +}); + +test('rejects gameconfig and ambiguous identity controls before fetching', async () => { + const cases: Array<[string, RegExp]> = [ + ['?profile=local&gameconfig=remote-host', /gameconfig/i], + ['?profile=local&agentid=A&agentid=B', /agentid/i], + ['?profile=local&version=1&version=2', /version/i], + ['?profile=local&gameid=other', /gameid/i], + ]; + for (const [search, expected] of cases) { + const input = options(search); + await assert.rejects(resolveLocalStartup(input), expected); + assert.equal(input.fetcher.calls, 0); + } +}); + +test('permits query identity overrides except gameid and validates numeric version', async () => { + const context = await resolveLocalStartup(options( + '?profile=local&agentid=A&channelid=C&marketid=9&version=12', + )); + assert.deepEqual(context.config.identity, { + ...BUILD_IDENTITY, + agentid: 'A', + channelid: 'C', + marketid: '9', + version: 12, + }); + + const invalid = options('?profile=local&version=not-a-number'); + await assert.rejects(resolveLocalStartup(invalid), /identity.*version.*finite/i); + assert.equal(invalid.fetcher.calls, 0); +}); + +function validContext(): LocalStartupContext { + return { + hostKind: 'h5', + profile: 'local', + config: { + mode: 'debug', + isDebugger: true, + identity: BUILD_IDENTITY, + servers: PROFILES.local!.servers!, + source: 'direct', + gameserver: null, + rawConfig: null, + }, + }; +} + +test('context validator rejects forged config fields and malformed identity', () => { + const base = validContext(); + const configCases: Array<[keyof RuntimeConfig, unknown]> = [ + ['mode', 'release'], + ['isDebugger', false], + ['source', 'remote'], + ['gameserver', 'https://example.com/config.txt'], + ['rawConfig', {}], + ['servers', ['ws://127.0.0.1:9999']], + ['servers', ['https://127.0.0.1:3088']], + ['servers', ['ws://user:pass@127.0.0.1:3088']], + ]; + for (const [field, value] of configCases) { + assert.throws( + () => assertLocalStartupContext({ ...base, config: { ...base.config, [field]: value } } as LocalStartupContext), + new RegExp(field, 'i'), + ); + } + assert.throws( + () => assertLocalStartupContext({ + ...base, + config: { ...base.config, identity: { ...BUILD_IDENTITY, version: Number.NaN } }, + }), + /identity.*version.*finite/i, + ); +}); + +test('connection target must be a valid URL from the authoritative local profile', () => { + const context = validContext(); + assert.doesNotThrow(() => assertLocalConnectionTarget(context, 'ws://127.0.0.1:3088')); + for (const [url, expected] of [ + ['ws://127.0.0.1:9999', /url|target|servers/i], + ['ws://example.com:3088', /url|target|servers|hostname/i], + ['not-a-url', /url/i], + ] as const) { + assert.throws(() => assertLocalConnectionTarget(context, url), expected); + } +});