mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 12:23:59 +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