feat(platform): expose lifecycle-safe UI observation
This commit is contained in:
@@ -119,6 +119,8 @@ export class PlatformRuntime {
|
||||
private fatalError: Error | null = null;
|
||||
private wireStopped = false;
|
||||
private startupGeneration = 0;
|
||||
private readonly stateObservers = new Set<() => void>();
|
||||
private observerDispatchError: Error | null = null;
|
||||
|
||||
constructor(private readonly options: PlatformRuntimeOptions) {
|
||||
this.clock = options.clock ?? realClock;
|
||||
@@ -132,6 +134,54 @@ export class PlatformRuntime {
|
||||
return this.gate.ready;
|
||||
}
|
||||
|
||||
subscribeState(
|
||||
listener: (state: PlatformState) => void,
|
||||
onError: (error: unknown) => void,
|
||||
): () => void {
|
||||
if (
|
||||
this.lifecycle === 'kicked'
|
||||
|| this.lifecycle === 'fatal'
|
||||
|| this.lifecycle === 'stopped'
|
||||
) {
|
||||
throw new Error(`PlatformRuntime cannot subscribe while ${this.lifecycle}`);
|
||||
}
|
||||
|
||||
let active = true;
|
||||
const unsubscribeStore = this.store.subscribe((state) => {
|
||||
if (!active) return;
|
||||
try {
|
||||
listener(state);
|
||||
} catch (error) {
|
||||
off();
|
||||
try {
|
||||
onError(error);
|
||||
} catch (callbackError) {
|
||||
const failure = this.failureError(
|
||||
'UI observer',
|
||||
error,
|
||||
[callbackError],
|
||||
);
|
||||
if (this.isStopped()) {
|
||||
this.observerDispatchError = failure;
|
||||
} else {
|
||||
this.showFatalOnce(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const off = (): void => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
try {
|
||||
unsubscribeStore();
|
||||
} finally {
|
||||
this.stateObservers.delete(off);
|
||||
}
|
||||
};
|
||||
this.stateObservers.add(off);
|
||||
return off;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.lifecycle !== 'idle') {
|
||||
throw new Error(`PlatformRuntime cannot start while ${this.lifecycle}`);
|
||||
@@ -260,6 +310,9 @@ export class PlatformRuntime {
|
||||
this.wireIsOpen = false;
|
||||
|
||||
const errors: unknown[] = [];
|
||||
for (const off of [...this.stateObservers]) {
|
||||
this.captureCleanupError(errors, off);
|
||||
}
|
||||
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
||||
const unsubscribe = this.unsubscribeWire;
|
||||
this.unsubscribeWire = null;
|
||||
@@ -324,6 +377,7 @@ export class PlatformRuntime {
|
||||
case 'open':
|
||||
this.wireIsOpen = true;
|
||||
this.store.setConnectionPhase('connected');
|
||||
this.throwObserverDispatchError();
|
||||
this.markReady('socket');
|
||||
if (this.connectionIntent.type === 'switch') {
|
||||
const loginEnvelope = this.loginEnvelope;
|
||||
@@ -341,8 +395,10 @@ export class PlatformRuntime {
|
||||
this.clearLoginGuard();
|
||||
}
|
||||
const stateBeforeDispatch = this.store.getState();
|
||||
const dispatchResult = this.requireRouter().dispatch(event.message);
|
||||
this.throwObserverDispatchError();
|
||||
if (
|
||||
this.requireRouter().dispatch(event.message) === 'handled-platform'
|
||||
dispatchResult === 'handled-platform'
|
||||
&& this.lifecycle === 'running'
|
||||
&& this.store.getState() !== stateBeforeDispatch
|
||||
) {
|
||||
@@ -353,6 +409,8 @@ export class PlatformRuntime {
|
||||
case 'reconnecting':
|
||||
this.wireIsOpen = false;
|
||||
this.store.setConnectionPhase(event.type);
|
||||
this.throwObserverDispatchError();
|
||||
if (this.isTerminal()) return;
|
||||
this.options.scene.showReconnect();
|
||||
break;
|
||||
case 'close':
|
||||
@@ -360,10 +418,11 @@ export class PlatformRuntime {
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = this.withObserverDispatchError(error);
|
||||
// A lifecycle callback may stop and then throw. Preserve that error without
|
||||
// replacing the requested terminal stop with a fatal lifecycle transition.
|
||||
if (this.isStopped()) throw error;
|
||||
this.showFatalOnce(error);
|
||||
if (this.isStopped()) throw failure;
|
||||
this.showFatalOnce(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +430,31 @@ export class PlatformRuntime {
|
||||
return this.lifecycle === 'stopped';
|
||||
}
|
||||
|
||||
private isTerminal(): boolean {
|
||||
return this.lifecycle === 'fatal'
|
||||
|| this.lifecycle === 'kicked'
|
||||
|| this.lifecycle === 'stopped';
|
||||
}
|
||||
|
||||
private throwObserverDispatchError(): void {
|
||||
const failure = this.takeObserverDispatchError();
|
||||
if (failure === null) return;
|
||||
throw failure;
|
||||
}
|
||||
|
||||
private takeObserverDispatchError(): Error | null {
|
||||
const failure = this.observerDispatchError;
|
||||
this.observerDispatchError = null;
|
||||
return failure;
|
||||
}
|
||||
|
||||
private withObserverDispatchError(error: unknown): unknown {
|
||||
const observerFailure = this.takeObserverDispatchError();
|
||||
return observerFailure === null
|
||||
? error
|
||||
: this.failureError('UI observer dispatch', observerFailure, [error]);
|
||||
}
|
||||
|
||||
private sendLoginEnvelope(envelope: OutboundEnvelope): void {
|
||||
const wire = this.requireOpenWire();
|
||||
const session = this.requireSession();
|
||||
@@ -441,6 +525,9 @@ export class PlatformRuntime {
|
||||
this.connectionIntent = { type: 'none' };
|
||||
this.wireIsOpen = false;
|
||||
const errors: unknown[] = [];
|
||||
for (const off of [...this.stateObservers]) {
|
||||
this.captureCleanupError(errors, off);
|
||||
}
|
||||
this.captureCleanupError(errors, () => { this.store.setConnectionPhase('kicked'); });
|
||||
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
||||
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
|
||||
@@ -603,6 +690,9 @@ export class PlatformRuntime {
|
||||
this.wireIsOpen = false;
|
||||
|
||||
const cleanupErrors: unknown[] = [];
|
||||
for (const off of [...this.stateObservers]) {
|
||||
this.captureCleanupError(cleanupErrors, off);
|
||||
}
|
||||
this.captureCleanupError(cleanupErrors, () => { this.clearLoginGuard(); });
|
||||
|
||||
const unsubscribe = this.unsubscribeWire;
|
||||
|
||||
Reference in New Issue
Block a user