mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
index.html now lists each view's host and the loading screen itself, so the gev:template expansion plugin, its view list, the loading-screen template and the ./build/html export are gone. createApplication takes omitViews for views an application leaves out; their hosts are removed, and a view an earlier call rendered is unmounted.
159 lines
4.9 KiB
JavaScript
159 lines
4.9 KiB
JavaScript
import { mountApplicationViews } from '../ui/views/index.js';
|
|
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.
|
|
* The page's views render first; `omitViews` names the ones this application
|
|
* leaves out (see mountApplicationViews).
|
|
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function,omitViews?:string[]}} constructors
|
|
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
|
|
*/
|
|
export function createApplication({
|
|
createScene,
|
|
createControls,
|
|
createData,
|
|
createTools,
|
|
omitViews = [],
|
|
}) {
|
|
// A host's page needs its views before any phase reads the markup.
|
|
mountApplicationViews(globalThis.document, { omit: omitViews });
|
|
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);
|
|
},
|
|
});
|
|
}
|