mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Add the scene host that attaches engine-bound parts in order and detaches them in reverse
This commit is contained in:
1 parent
b2cc00833d
commit
f0c2523ac4
2 files changed
+394
No files matched your search
@@ -0,0 +1,214 @@
|
|||||||
|
// The page's scene host: the application is created once per page, and only
|
||||||
|
// the parts bound to a globe engine (its viewer, canvas, camera and frames)
|
||||||
|
// detach from the outgoing engine and attach to the incoming one.
|
||||||
|
//
|
||||||
|
// A part is `{ name, attachScene(scene, { signal, defer }), detachScene({ handoff }) }`.
|
||||||
|
// Parts attach in registration order and detach in reverse. `signal` belongs
|
||||||
|
// to one attachment: it aborts when that attachment detaches, so work a part
|
||||||
|
// started for one engine never lands on the next. `defer(cleanup)` registers
|
||||||
|
// a release that runs, in reverse, when the part detaches (before its
|
||||||
|
// detachScene).
|
||||||
|
|
||||||
|
const STATES = Object.freeze([
|
||||||
|
'detached',
|
||||||
|
'attaching',
|
||||||
|
'attached',
|
||||||
|
'detaching',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class SceneSwitchingError extends Error {
|
||||||
|
constructor(message = 'The globe is switching engines') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SceneSwitchingError';
|
||||||
|
this.code = 'ENGINE_SWITCHING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{onError?: (error: unknown, part: string) => void}} [options]
|
||||||
|
*/
|
||||||
|
export function createSceneHost({ onError = null } = {}) {
|
||||||
|
const parts = [];
|
||||||
|
const listeners = new Set();
|
||||||
|
let state = 'detached';
|
||||||
|
let scene = null;
|
||||||
|
// The live attachment: its controller and each attached part's cleanups.
|
||||||
|
let attachment = null;
|
||||||
|
// The attach or detach under way, so a later call waits for it.
|
||||||
|
let transition = Promise.resolve();
|
||||||
|
|
||||||
|
function publish() {
|
||||||
|
for (const listener of [...listeners]) {
|
||||||
|
try {
|
||||||
|
listener({ state, scene });
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error, 'listener');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachPart(part, target, current) {
|
||||||
|
const defers = [];
|
||||||
|
const entry = { part, defers };
|
||||||
|
current.attached.push(entry);
|
||||||
|
await part.attachScene?.(target, {
|
||||||
|
signal: current.controller.signal,
|
||||||
|
defer(cleanup) {
|
||||||
|
if (typeof cleanup !== 'function')
|
||||||
|
throw new TypeError('A scene part defers a function');
|
||||||
|
defers.push(cleanup);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
current.controller.signal.throwIfAborted();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detachEntry(entry, options, errors) {
|
||||||
|
while (entry.defers.length) {
|
||||||
|
const cleanup = entry.defers.pop();
|
||||||
|
try {
|
||||||
|
await cleanup();
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error);
|
||||||
|
onError?.(error, entry.part.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await entry.part.detachScene?.(options);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error);
|
||||||
|
onError?.(error, entry.part.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function release(current, options) {
|
||||||
|
current.controller.abort();
|
||||||
|
const errors = [];
|
||||||
|
while (current.attached.length)
|
||||||
|
await detachEntry(current.attached.pop(), options, errors);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener) {
|
||||||
|
if (typeof listener !== 'function')
|
||||||
|
throw new TypeError('Expected a scene listener');
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queue(work) {
|
||||||
|
const run = transition.then(work, work);
|
||||||
|
transition = run.catch(() => {});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
/**
|
||||||
|
* Add a part. While a scene is attached the part attaches to it now, and
|
||||||
|
* the returned promise settles with that attachment.
|
||||||
|
*/
|
||||||
|
register(part) {
|
||||||
|
if (!part || typeof part !== 'object' || typeof part.name !== 'string')
|
||||||
|
throw new TypeError('A scene part needs a name');
|
||||||
|
if (parts.includes(part)) return Promise.resolve();
|
||||||
|
parts.push(part);
|
||||||
|
if (state !== 'attached' || !attachment) return Promise.resolve();
|
||||||
|
return attachPart(part, scene, attachment);
|
||||||
|
},
|
||||||
|
/** Attach every part to `target`, in order. A failure detaches the parts it attached. */
|
||||||
|
attach(target) {
|
||||||
|
if (!target) throw new TypeError('Attach needs a scene');
|
||||||
|
return queue(async () => {
|
||||||
|
if (state !== 'detached')
|
||||||
|
throw new Error(`Cannot attach a scene while ${state}`);
|
||||||
|
state = 'attaching';
|
||||||
|
const current = { controller: new AbortController(), attached: [] };
|
||||||
|
attachment = current;
|
||||||
|
scene = target;
|
||||||
|
publish();
|
||||||
|
try {
|
||||||
|
for (const part of [...parts])
|
||||||
|
await attachPart(part, target, current);
|
||||||
|
} catch (error) {
|
||||||
|
const errors = await release(current, {
|
||||||
|
handoff: false,
|
||||||
|
failed: true,
|
||||||
|
});
|
||||||
|
attachment = null;
|
||||||
|
scene = null;
|
||||||
|
state = 'detached';
|
||||||
|
publish();
|
||||||
|
if (errors.length)
|
||||||
|
throw new AggregateError(
|
||||||
|
[error, ...errors],
|
||||||
|
'A scene failed to attach and to release',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
state = 'attached';
|
||||||
|
publish();
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Detach every attached part, in reverse. Detaching nothing is a no-op;
|
||||||
|
* a part that fails to detach does not stop the others.
|
||||||
|
*/
|
||||||
|
detach(options = {}) {
|
||||||
|
return queue(async () => {
|
||||||
|
if (state === 'detached' || !attachment) return;
|
||||||
|
const current = attachment;
|
||||||
|
state = 'detaching';
|
||||||
|
publish();
|
||||||
|
let errors;
|
||||||
|
try {
|
||||||
|
errors = await release(current, options);
|
||||||
|
} finally {
|
||||||
|
attachment = null;
|
||||||
|
scene = null;
|
||||||
|
state = 'detached';
|
||||||
|
publish();
|
||||||
|
}
|
||||||
|
if (errors.length)
|
||||||
|
throw new AggregateError(errors, 'Scene parts could not detach');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** The attached scene, or null. */
|
||||||
|
current: () => (state === 'attached' ? scene : null),
|
||||||
|
state: () => state,
|
||||||
|
parts: () => parts.map(({ name }) => name),
|
||||||
|
subscribe,
|
||||||
|
/**
|
||||||
|
* The attached scene, now or once one attaches. Rejects with a
|
||||||
|
* SceneSwitchingError when `signal` aborts or `timeoutMs` passes first.
|
||||||
|
*/
|
||||||
|
whenAttached({ signal = null, timeoutMs = Infinity } = {}) {
|
||||||
|
if (state === 'attached') return Promise.resolve(scene);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer = null;
|
||||||
|
const finish = (error, value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
unsubscribe();
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(value);
|
||||||
|
};
|
||||||
|
const onAbort = () =>
|
||||||
|
finish(
|
||||||
|
new SceneSwitchingError('The wait for the globe was cancelled'),
|
||||||
|
);
|
||||||
|
const unsubscribe = subscribe(({ state: next, scene: value }) => {
|
||||||
|
if (next === 'attached') finish(null, value);
|
||||||
|
});
|
||||||
|
if (signal?.aborted) return onAbort();
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
if (Number.isFinite(timeoutMs))
|
||||||
|
timer = setTimeout(
|
||||||
|
() => finish(new SceneSwitchingError()),
|
||||||
|
Math.max(0, timeoutMs),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCENE_HOST_STATES = STATES;
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createSceneHost, SceneSwitchingError } from './sceneHost.js';
|
||||||
|
|
||||||
|
function recordingPart(name, log, { failAttach = false } = {}) {
|
||||||
|
const part = {
|
||||||
|
name,
|
||||||
|
signals: [],
|
||||||
|
async attachScene(scene, { signal, defer }) {
|
||||||
|
log.push(`attach ${name} ${scene.kind}`);
|
||||||
|
part.signals.push(signal);
|
||||||
|
defer(() => log.push(`cleanup ${name}`));
|
||||||
|
if (failAttach) throw new Error(`${name} failed`);
|
||||||
|
},
|
||||||
|
detachScene(options) {
|
||||||
|
log.push(`detach ${name}${options?.handoff ? ' handoff' : ''}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('parts attach in order and detach in reverse, cleanups first', async () => {
|
||||||
|
const log = [];
|
||||||
|
const host = createSceneHost();
|
||||||
|
const a = recordingPart('a', log);
|
||||||
|
const b = recordingPart('b', log);
|
||||||
|
host.register(a);
|
||||||
|
host.register(b);
|
||||||
|
const scene = { kind: 'cesium' };
|
||||||
|
assert.equal(await host.attach(scene), scene);
|
||||||
|
assert.equal(host.current(), scene);
|
||||||
|
assert.equal(host.state(), 'attached');
|
||||||
|
await host.detach({ handoff: true });
|
||||||
|
assert.deepEqual(log, [
|
||||||
|
'attach a cesium',
|
||||||
|
'attach b cesium',
|
||||||
|
'cleanup b',
|
||||||
|
'detach b handoff',
|
||||||
|
'cleanup a',
|
||||||
|
'detach a handoff',
|
||||||
|
]);
|
||||||
|
assert.equal(host.current(), null);
|
||||||
|
assert.ok(a.signals[0].aborted, 'the attachment signal aborts on detach');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed part detaches the parts already attached and aborts only that attachment', async () => {
|
||||||
|
const log = [];
|
||||||
|
const host = createSceneHost();
|
||||||
|
const a = recordingPart('a', log);
|
||||||
|
const b = recordingPart('b', log, { failAttach: true });
|
||||||
|
const c = recordingPart('c', log);
|
||||||
|
for (const part of [a, b, c]) host.register(part);
|
||||||
|
await assert.rejects(host.attach({ kind: 'navara' }), /b failed/);
|
||||||
|
assert.deepEqual(log, [
|
||||||
|
'attach a navara',
|
||||||
|
'attach b navara',
|
||||||
|
'cleanup b',
|
||||||
|
'detach b',
|
||||||
|
'cleanup a',
|
||||||
|
'detach a',
|
||||||
|
]);
|
||||||
|
assert.equal(host.state(), 'detached');
|
||||||
|
assert.ok(a.signals[0].aborted);
|
||||||
|
// The working engine attaches again on the same parts.
|
||||||
|
b.attachScene = async (scene, { defer }) => {
|
||||||
|
log.push(`attach b ${scene.kind}`);
|
||||||
|
defer(() => log.push('cleanup b'));
|
||||||
|
};
|
||||||
|
log.length = 0;
|
||||||
|
await host.attach({ kind: 'cesium' });
|
||||||
|
assert.deepEqual(log, [
|
||||||
|
'attach a cesium',
|
||||||
|
'attach b cesium',
|
||||||
|
'attach c cesium',
|
||||||
|
]);
|
||||||
|
assert.equal(a.signals.length, 2);
|
||||||
|
assert.equal(a.signals[1].aborted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detach is idempotent and a failing part does not stop the others', async () => {
|
||||||
|
const log = [];
|
||||||
|
const host = createSceneHost();
|
||||||
|
host.register(recordingPart('a', log));
|
||||||
|
host.register({
|
||||||
|
name: 'bad',
|
||||||
|
attachScene() {},
|
||||||
|
detachScene() {
|
||||||
|
throw new Error('bad detach');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await host.detach();
|
||||||
|
assert.deepEqual(log, []);
|
||||||
|
await host.attach({ kind: 'cesium' });
|
||||||
|
await assert.rejects(host.detach(), AggregateError);
|
||||||
|
assert.ok(log.includes('detach a'));
|
||||||
|
assert.equal(host.state(), 'detached');
|
||||||
|
await host.detach();
|
||||||
|
assert.equal(log.filter((line) => line === 'detach a').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a part registered while attached attaches at once and detaches with the rest', async () => {
|
||||||
|
const log = [];
|
||||||
|
const host = createSceneHost();
|
||||||
|
host.register(recordingPart('a', log));
|
||||||
|
await host.attach({ kind: 'cesium' });
|
||||||
|
await host.register(recordingPart('late', log));
|
||||||
|
assert.deepEqual(log, ['attach a cesium', 'attach late cesium']);
|
||||||
|
await host.detach();
|
||||||
|
assert.deepEqual(log.slice(2), [
|
||||||
|
'cleanup late',
|
||||||
|
'detach late',
|
||||||
|
'cleanup a',
|
||||||
|
'detach a',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(host.parts(), ['a', 'late']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attach refuses while a scene is attached', async () => {
|
||||||
|
const host = createSceneHost();
|
||||||
|
await host.attach({ kind: 'cesium' });
|
||||||
|
await assert.rejects(host.attach({ kind: 'navara' }), /while attached/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whenAttached resolves on the next attachment and rejects on abort or timeout', async () => {
|
||||||
|
const host = createSceneHost();
|
||||||
|
const waiting = host.whenAttached();
|
||||||
|
const scene = { kind: 'navara' };
|
||||||
|
await host.attach(scene);
|
||||||
|
assert.equal(await waiting, scene);
|
||||||
|
assert.equal(await host.whenAttached(), scene);
|
||||||
|
await host.detach();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const aborted = host.whenAttached({ signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(aborted, (error) => {
|
||||||
|
assert.ok(error instanceof SceneSwitchingError);
|
||||||
|
assert.equal(error.code, 'ENGINE_SWITCHING');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
host.whenAttached({ timeoutMs: 5 }),
|
||||||
|
(error) => error.code === 'ENGINE_SWITCHING',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscribers see every state of a switch', async () => {
|
||||||
|
const host = createSceneHost();
|
||||||
|
const states = [];
|
||||||
|
const off = host.subscribe(({ state }) => states.push(state));
|
||||||
|
await host.attach({ kind: 'cesium' });
|
||||||
|
await host.detach();
|
||||||
|
off();
|
||||||
|
await host.attach({ kind: 'navara' });
|
||||||
|
assert.deepEqual(states, ['attaching', 'attached', 'detaching', 'detached']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a detach requested during an attach waits for it', async () => {
|
||||||
|
const log = [];
|
||||||
|
const host = createSceneHost();
|
||||||
|
let release;
|
||||||
|
host.register({
|
||||||
|
name: 'slow',
|
||||||
|
async attachScene() {
|
||||||
|
log.push('attach start');
|
||||||
|
await new Promise((resolve) => (release = resolve));
|
||||||
|
log.push('attach end');
|
||||||
|
},
|
||||||
|
detachScene() {
|
||||||
|
log.push('detach');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const attaching = host.attach({ kind: 'cesium' });
|
||||||
|
const detaching = host.detach();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
release();
|
||||||
|
await attaching;
|
||||||
|
await detaching;
|
||||||
|
assert.deepEqual(log, ['attach start', 'attach end', 'detach']);
|
||||||
|
assert.equal(host.state(), 'detached');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user