refactor: extract application construction and lifecycle

This commit is contained in:
Sameh Khamis committed 2026-09-11 17:02:54 -07:00
1 parent 092f28e301
commit 4d37760314
36 files changed
+1363 -382

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
const START_ORDER = ['scene', 'controls', 'data', 'tools'];
// Controls cancel restoration while the data manager and viewer still exist.
const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
/**
* Own application startup and teardown without importing an application entry.
* Constructors receive earlier components, an AbortSignal and defer(cleanup).
* Register cleanup immediately after acquiring each resource, before any await.
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
*/
export function createApplication({ createScene, createControls, createData, createTools }) {
const factories = { scene: createScene, controls: createControls, data: createData, tools: createTools };
for (const phase of START_ORDER) {
if (typeof factories[phase] !== 'function') throw new TypeError(`Missing ${phase} constructor`);
}
const controller = new AbortController();
const cleanups = Object.fromEntries(START_ORDER.map((phase) => [phase, []]));
const components = {};
const listeners = new Set();
let state = Object.freeze({ status: 'created', phase: null });
let startPromise;
let destroyPromise;
let cleanupPromise;
function publish(status, phase = null) {
state = Object.freeze({ status, phase });
for (const listener of [...listeners]) {
try { listener(state); } catch { console.error('Application state listener failed'); }
}
}
function cleanup() {
cleanupPromise ||= Promise.resolve().then(async () => {
const errors = [];
for (const phase of STOP_ORDER) {
while (cleanups[phase].length) {
try { await cleanups[phase].pop()(); } catch (error) { errors.push(error); }
}
delete components[phase];
}
if (errors.length) throw new AggregateError(errors, 'Application cleanup failed');
});
return cleanupPromise;
}
async function initialize() {
try {
for (const phase of START_ORDER) {
controller.signal.throwIfAborted();
publish('starting', phase);
controller.signal.throwIfAborted();
let acceptingCleanup = true;
try {
components[phase] = await factories[phase]({
...components,
signal: controller.signal,
defer(dispose) {
if (!acceptingCleanup || typeof dispose !== 'function') {
throw new TypeError('Register cleanup during component construction');
}
cleanups[phase].push(dispose);
},
});
} finally {
acceptingCleanup = false;
}
controller.signal.throwIfAborted();
}
publish('ready');
controller.signal.throwIfAborted();
return Object.freeze({ ...components });
} catch (error) {
controller.abort();
let failure = error;
try { await cleanup(); } catch (cleanupError) {
failure = new AggregateError([error, cleanupError], 'Application startup and cleanup failed');
}
if (!destroyPromise) publish('failed');
throw failure;
}
}
return Object.freeze({
start() {
if (destroyPromise) return Promise.reject(new Error('Application has been destroyed'));
// Assign before notifying subscribers, including reentrant start/destroy.
startPromise ||= Promise.resolve().then(initialize);
return startPromise;
},
destroy() {
if (destroyPromise) return destroyPromise;
destroyPromise = Promise.resolve().then(async () => {
try {
await startPromise?.catch(() => {});
await cleanup();
publish('destroyed');
} catch (error) {
publish('failed');
throw error;
} finally {
listeners.clear();
}
});
controller.abort();
publish('destroying');
return destroyPromise;
},
getState: () => state,
getComponents: () => Object.freeze({ ...components }),
subscribe(listener) {
if (typeof listener !== 'function') throw new TypeError('Expected a state listener');
if (state.status === 'destroyed') return () => {};
listeners.add(listener);
try { listener(state); } catch { console.error('Application state listener failed'); }
return () => listeners.delete(listener);
},
});
}
+169
View File
@@ -0,0 +1,169 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) {
const events = [];
const constructors = Object.fromEntries(phases.map((phase) => [
`create${phase}`,
({ defer }) => {
events.push(`start:${phase}`);
defer(() => events.push(`stop:${phase}`));
return { name: phase };
},
]));
return { events, app: createApplication({ ...constructors, ...overrides }) };
}
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
test('importing and constructing the package is inactive without browser globals', () => {
const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
globalThis.fetch = () => { throw new Error('unexpected request'); };
globalThis.setTimeout = () => { throw new Error('unexpected timer'); };
const { createApplication } = await import('gods-eye-view/application');
const fail = () => { throw new Error('unexpected construction'); };
const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail });
if (app.getState().status !== 'created') process.exit(1);
`], { cwd: new URL('../../', import.meta.url), encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
});
test('constructors are validated before allocating anything', () => {
assert.throws(() => createApplication({}), /Missing scene constructor/);
});
test('startup shares one promise and teardown keeps data alive for controls', async () => {
const { app, events } = fixture({ createTools({ scene, controls, data, defer }) {
assert.equal(scene.name, 'Scene');
assert.equal(controls.name, 'Controls');
assert.equal(data.name, 'Data');
defer(() => events.push('stop:Tools'));
return { name: 'Tools' };
} });
const states = [];
app.subscribe((state) => states.push(`${state.status}:${state.phase}`));
const started = app.start();
assert.equal(started, app.start());
const components = await started;
assert.ok(Object.isFrozen(components));
assert.equal(components.tools.name, 'Tools');
assert.equal(app.getState().status, 'ready');
const stopped = app.destroy();
assert.equal(stopped, app.destroy());
await stopped;
assert.deepEqual(events.slice(-4), ['stop:Tools', 'stop:Controls', 'stop:Data', 'stop:Scene']);
assert.deepEqual(app.getComponents(), {});
assert.equal(states.at(-1), 'destroyed:null');
await assert.rejects(app.start(), /destroyed/);
});
for (const phase of phases) {
test(`failure during ${phase} releases partially acquired resources`, async () => {
const { app, events } = fixture({ [`create${phase}`]({ defer }) {
defer(() => events.push('partial:released'));
throw new Error('broken constructor');
} });
await assert.rejects(app.start(), /broken constructor/);
assert.equal(events.filter((value) => value === 'partial:released').length, 1);
assert.equal(app.getState().status, 'failed');
assert.deepEqual(app.getComponents(), {});
await app.destroy();
assert.equal(events.filter((value) => value === 'partial:released').length, 1);
});
}
test('destroy before startup prevents every constructor', async () => {
const { app, events } = fixture();
const started = app.start();
const failed = assert.rejects(started, { name: 'AbortError' });
await app.destroy();
await failed;
assert.deepEqual(events, []);
});
test('destroy aborts an in-flight constructor and cleans late acquisitions before resolving', async () => {
const gate = deferred();
const entered = deferred();
let observedSignal;
const { app, events } = fixture({ async createScene({ signal, defer }) {
observedSignal = signal;
entered.resolve();
await gate.promise;
defer(() => events.push('late:released'));
return {};
} });
const started = app.start();
const failed = assert.rejects(started, { name: 'AbortError' });
await entered.promise;
let settled = false;
const stopped = app.destroy().then(() => { settled = true; });
assert.equal(observedSignal.aborted, true);
await Promise.resolve();
assert.equal(settled, false);
gate.resolve();
await stopped;
await failed;
assert.deepEqual(events, ['late:released']);
});
test('cleanup errors are reported after all remaining resources are attempted', async () => {
const { app, events } = fixture({ createTools({ defer }) {
defer(() => events.push('tools:earlier'));
defer(() => { throw new Error('dispose failed'); });
return {};
} });
await app.start();
await assert.rejects(app.destroy(), (error) => {
assert.ok(error instanceof AggregateError);
assert.equal(error.errors[0].message, 'dispose failed');
return true;
});
assert.deepEqual(events.slice(-4), ['tools:earlier', 'stop:Controls', 'stop:Data', 'stop:Scene']);
assert.equal(app.getState().status, 'failed');
});
test('subscribers can destroy during startup without allowing the next constructor', async () => {
const { app, events } = fixture();
app.subscribe((state) => { if (state.phase === 'controls') void app.destroy(); });
await assert.rejects(app.start(), { name: 'AbortError' });
await app.destroy();
assert.deepEqual(events, ['start:Scene', 'stop:Scene']);
});
test('destroy during ready notification rejects startup and tears everything down', async () => {
const { app } = fixture();
app.subscribe((state) => { if (state.status === 'ready') void app.destroy(); });
await assert.rejects(app.start(), { name: 'AbortError' });
await app.destroy();
assert.equal(app.getState().status, 'destroyed');
});
test('cleanup registration closes when a constructor settles', async () => {
let register;
const { app } = fixture({ createScene({ defer }) { register = defer; return {}; } });
await app.start();
assert.throws(() => register(() => {}), /during component construction/);
await app.destroy();
});
test('different application instances do not share lifecycle state', async () => {
const first = fixture();
const second = fixture();
await Promise.all([first.app.start(), second.app.start()]);
await first.app.destroy();
assert.equal(second.app.getState().status, 'ready');
assert.equal(second.events.length, 4);
await second.app.destroy();
});
test('the separate viewer export imports without constructing a browser viewer', async () => {
const { createApplicationViewer } = await import('gods-eye-view/application/viewer');
assert.equal(typeof createApplicationViewer, 'function');
assert.throws(() => createApplicationViewer({}), /containers are required/);
});
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapStackController } from '../mapStackController.js';
import {
installRenderGovernor, uninstallRenderGovernor,
holdContinuousRender, governorRequestRender, getRenderGovernorDiagnostics,
} from '../renderGovernor.js';
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
let resolveProvider;
let removed = 0;
const changes = [];
const controller = new MapStackController({}, { onChange: (state) => changes.push(state.status) });
controller._getImageryProvider = () => new Promise((resolve) => { resolveProvider = resolve; });
controller._removeImageryErrorListener = () => { removed++; };
const switching = controller.setStack('osm');
controller.destroy();
resolveProvider({ provider: {} });
await switching;
assert.equal(removed, 1);
assert.deepEqual(changes, ['switching']);
await controller.setStack('osm');
controller.destroy();
assert.equal(removed, 1);
});
test('uninstall only releases the current viewer and makes later render requests inactive', () => {
let renders = 0;
const viewer = { scene: { requestRender: () => { renders++; } } };
installRenderGovernor(viewer);
holdContinuousRender('fixture');
uninstallRenderGovernor({});
assert.equal(getRenderGovernorDiagnostics().installed, true);
uninstallRenderGovernor(viewer);
const before = renders;
governorRequestRender('late');
assert.equal(renders, before);
assert.equal(getRenderGovernorDiagnostics().installed, false);
assert.deepEqual(getRenderGovernorDiagnostics().holds, []);
});
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { flyToAustin } from '../camera.js';
test('teardown before the initial camera delay prevents a late flight', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let flights = 0;
let cancelled = 0;
const stop = flyToAustin({
isDestroyed: () => false,
camera: { setView() {}, flyTo() { flights++; }, cancelFlight() { cancelled++; } },
});
stop();
t.mock.timers.tick(1000);
assert.equal(flights, 0);
assert.equal(cancelled, 1);
});
+35
View File
@@ -0,0 +1,35 @@
import * as Cesium from 'cesium';
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer) throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer(container, {
timeline: false,
animation: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
fullscreenButton: false,
vrButton: false,
selectionIndicator: false,
infoBox: false,
baseLayer: false,
creditContainer,
msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } },
});
try {
viewer.targetFrameRate = 60;
viewer.scene.globe.show = false;
viewer.scene.skyAtmosphere.show = true;
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
viewer.scene.skyAtmosphere.saturationShift = -0.12;
viewer.scene.skyAtmosphere.brightnessShift = -0.08;
return viewer;
} catch (error) {
viewer.destroy();
throw error;
}
}