@wasm-gaming/engine-specs 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -3
- package/demo/app.html +28 -6
- package/demo/demo.sdk.js +105 -323
- package/dist/engine-specs.d.ts +262 -1
- package/dist/engine-specs.d.ts.map +1 -1
- package/dist/engine-specs.js +380 -1
- package/dist/engine-specs.js.map +1 -1
- package/package.json +1 -1
- package/src/engine-specs.ts +606 -1
package/src/engine-specs.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface EngineConfig { [key: string]: unknown }
|
|
|
17
17
|
export interface EngineOptions { [key: string]: unknown }
|
|
18
18
|
export interface EngineAssets { [key: string]: unknown }
|
|
19
19
|
export interface EngineStorage { [key: string]: unknown }
|
|
20
|
+
export interface EngineEvents { [key: string]: unknown }
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Members are `object`, not the four interfaces above, on purpose: a named
|
|
@@ -30,6 +31,12 @@ export interface EnginePayloads {
|
|
|
30
31
|
options: object;
|
|
31
32
|
assets: object;
|
|
32
33
|
storage: object;
|
|
34
|
+
/**
|
|
35
|
+
* The events this engine emits **beyond** {@link CoreEvents}, which every engine
|
|
36
|
+
* emits and no engine declares. Optional, unlike the four above: an engine with
|
|
37
|
+
* nothing of its own to announce omits it.
|
|
38
|
+
*/
|
|
39
|
+
events?: object;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
export interface DefaultPayloads extends EnginePayloads {
|
|
@@ -37,6 +44,145 @@ export interface DefaultPayloads extends EnginePayloads {
|
|
|
37
44
|
options: EngineOptions;
|
|
38
45
|
assets: EngineAssets;
|
|
39
46
|
storage: EngineStorage;
|
|
47
|
+
events: EngineEvents;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- Events ----
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What every engine emits, whatever else it adds. Unioned in by {@link EventsOf}
|
|
54
|
+
* rather than declared in the payload bundle, so an engine can neither forget these
|
|
55
|
+
* nor redefine them away.
|
|
56
|
+
*
|
|
57
|
+
* There is no `ready`: it announced that the engine was up but no session was open,
|
|
58
|
+
* which was what the removed `boot()` meant.
|
|
59
|
+
*/
|
|
60
|
+
export interface CoreEvents<P extends EnginePayloads = DefaultPayloads> {
|
|
61
|
+
/** The options the session opened with. A `restart()` emits it too. */
|
|
62
|
+
start: Partial<P['options']>;
|
|
63
|
+
/**
|
|
64
|
+
* Emitted **and** rejected. Both, always: a host that does not `await` the chain is
|
|
65
|
+
* not left blind, and a host that does is not forced to subscribe.
|
|
66
|
+
*/
|
|
67
|
+
error: Error;
|
|
68
|
+
/** The engine went away — `destroy()` has settled. Fires once, with no detail. */
|
|
69
|
+
exit: void;
|
|
70
|
+
/** Who asked, so a host can tell its own pause from a visibility change. */
|
|
71
|
+
pause: { owner: string };
|
|
72
|
+
resume: { owner: string };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What a listener receives. The payload travels under `detail`, as `CustomEvent`
|
|
77
|
+
* does it, rather than being the argument itself: an event can then grow a field
|
|
78
|
+
* without changing every listener signature, one listener can serve several types by
|
|
79
|
+
* reading `type`, and — the reason that actually decided it — an engine living in a
|
|
80
|
+
* worker has to put its type on the wire anyway, so `{ type, detail }` is the shape
|
|
81
|
+
* that survives `postMessage` instead of being re-wrapped on arrival.
|
|
82
|
+
*
|
|
83
|
+
* **This is not a DOM event.** Two members, and deliberately no more: it does not
|
|
84
|
+
* bubble, it cannot be cancelled, there is no `target` and no `preventDefault()`.
|
|
85
|
+
* Half-implementing a familiar interface is worse than offering a different one, so
|
|
86
|
+
* the shape stops where its guarantees do.
|
|
87
|
+
*
|
|
88
|
+
* Frozen when emitted, because the same object is handed to every listener in turn —
|
|
89
|
+
* otherwise the first could rewrite what the second reads.
|
|
90
|
+
*/
|
|
91
|
+
export interface EngineEvent<K = PropertyKey, Detail = unknown> {
|
|
92
|
+
readonly type: K;
|
|
93
|
+
readonly detail: Detail;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What `on` takes for one event of `E`. */
|
|
97
|
+
export type EngineListener<E, K extends keyof E> = (event: EngineEvent<K, E[K]>) => void;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Every event the engine emits: the core set, plus whatever it declared.
|
|
101
|
+
*
|
|
102
|
+
* `NonNullable` is what makes `events` optional: with the member absent, `P['events']`
|
|
103
|
+
* is `object | undefined`, and a bare `object` contributes no keys to the
|
|
104
|
+
* intersection. It also covers an engine that writes `events?:` in its own payload
|
|
105
|
+
* interface.
|
|
106
|
+
*/
|
|
107
|
+
export type EventsOf<P extends EnginePayloads> = CoreEvents<P> & NonNullable<P['events']>;
|
|
108
|
+
|
|
109
|
+
/** A map of listeners, which is how {@link EngineInit.on} carries them. */
|
|
110
|
+
export type EventListeners<E> = { [K in keyof E]?: EngineListener<E, K> };
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The listener bookkeeping, shipped rather than described — the one piece of runtime
|
|
114
|
+
* code in this package.
|
|
115
|
+
*
|
|
116
|
+
* It is a **convenience, not a requirement**: the contract stays structural, so an
|
|
117
|
+
* engine that already owns an emitter satisfies {@link EngineSurface} without this.
|
|
118
|
+
* What it saves is the part everyone gets subtly wrong — iterating a copy, so a
|
|
119
|
+
* listener that unsubscribes mid-emit does not corrupt the loop; de-duplicating a
|
|
120
|
+
* listener registered twice; and keeping one throwing listener from silencing the
|
|
121
|
+
* rest.
|
|
122
|
+
*
|
|
123
|
+
* Hold it **privately** and hand the same instance to every carrier. That is what
|
|
124
|
+
* makes `sdk.on(…)` and `play.on(…)` one channel rather than two, lets a host
|
|
125
|
+
* subscribe before the engine exists, and keeps `emit` out of the public API — a host
|
|
126
|
+
* must not be able to forge an engine event.
|
|
127
|
+
*/
|
|
128
|
+
export class EventEmitter<E> {
|
|
129
|
+
readonly #listeners = new Map<keyof E, Set<(event: never) => void>>();
|
|
130
|
+
|
|
131
|
+
/** @param listeners Registered immediately — what `EngineInit.on` carries. */
|
|
132
|
+
constructor(listeners?: EventListeners<E>) {
|
|
133
|
+
for (const type of Object.keys(listeners ?? {}) as (keyof E)[]) {
|
|
134
|
+
const listener = listeners?.[type];
|
|
135
|
+
if (listener) this.on(type, listener);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Registering the same listener twice registers it once, as `EventTarget` does. */
|
|
140
|
+
on<K extends keyof E>(type: K, listener: EngineListener<E, K>): this {
|
|
141
|
+
let set = this.#listeners.get(type);
|
|
142
|
+
if (!set) this.#listeners.set(type, (set = new Set()));
|
|
143
|
+
set.add(listener as (event: never) => void);
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The listener is required, as in `EventTarget`. A convenience `off(type)` dropping
|
|
149
|
+
* every listener for a type is a footgun on a page where two components subscribed
|
|
150
|
+
* independently: one unsubscribing would silence the other.
|
|
151
|
+
*/
|
|
152
|
+
off<K extends keyof E>(type: K, listener: EngineListener<E, K>): this {
|
|
153
|
+
const set = this.#listeners.get(type);
|
|
154
|
+
if (set?.delete(listener as (event: never) => void) && set.size === 0) {
|
|
155
|
+
this.#listeners.delete(type);
|
|
156
|
+
}
|
|
157
|
+
return this;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Takes the **detail**, and wraps it. The rest tuple is what lets an event with no
|
|
162
|
+
* detail be emitted as `emit('exit')` while still checking the ones that have one.
|
|
163
|
+
*/
|
|
164
|
+
emit<K extends keyof E>(
|
|
165
|
+
type: K,
|
|
166
|
+
...[detail]: E[K] extends void ? [] : [detail: E[K]]
|
|
167
|
+
): void {
|
|
168
|
+
const set = this.#listeners.get(type);
|
|
169
|
+
if (!set) return;
|
|
170
|
+
// Built once and frozen: every listener gets this same object, so a mutable one
|
|
171
|
+
// would let the first rewrite what the rest read.
|
|
172
|
+
const event: EngineEvent<K, E[K]> = Object.freeze({ type, detail: detail as E[K] });
|
|
173
|
+
// A copy: a listener may call `off()` — on itself or another — while this runs.
|
|
174
|
+
for (const listener of [...set]) {
|
|
175
|
+
try {
|
|
176
|
+
(listener as EngineListener<E, K>)(event);
|
|
177
|
+
} catch (thrown) {
|
|
178
|
+
// One bad listener must neither silence the rest nor vanish. Rethrowing on a
|
|
179
|
+
// fresh task puts it in front of `window.onerror` with its stack intact.
|
|
180
|
+
queueMicrotask(() => {
|
|
181
|
+
throw thrown;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
40
186
|
}
|
|
41
187
|
|
|
42
188
|
export interface EngineInit<P extends EnginePayloads = DefaultPayloads> {
|
|
@@ -49,6 +195,12 @@ export interface EngineInit<P extends EnginePayloads = DefaultPayloads> {
|
|
|
49
195
|
* cumulative state, a mount target is a single value that replaces.
|
|
50
196
|
*/
|
|
51
197
|
mount?: HTMLElement;
|
|
198
|
+
/**
|
|
199
|
+
* The same subscription as {@link EngineSurface.on}, as a map — the one shape the
|
|
200
|
+
* chain cannot express, because an object literal has nothing to chain onto.
|
|
201
|
+
* `new Sdk({ on: { error } })` and `new Sdk().on('error', error)` are the same call.
|
|
202
|
+
*/
|
|
203
|
+
on?: EventListeners<EventsOf<P>>;
|
|
52
204
|
}
|
|
53
205
|
|
|
54
206
|
// ---- The shared fluent surface: the members every carrier exposes ----
|
|
@@ -63,6 +215,21 @@ export interface EngineSurface<TSelf, P extends EnginePayloads = DefaultPayloads
|
|
|
63
215
|
storage(): Promise<PropertiesDefinition<P['storage']>>;
|
|
64
216
|
storage(patch: Partial<P['storage']>): TSelf;
|
|
65
217
|
mount(target: HTMLElement): TSelf;
|
|
218
|
+
/**
|
|
219
|
+
* Subscribe. Like `mount`, and unlike the four payloads, this has no getter form:
|
|
220
|
+
* there is no `on()` that hands back a listener list.
|
|
221
|
+
*
|
|
222
|
+
* **Synchronous on every carrier, `PromisePlay` included.** The chain is what the
|
|
223
|
+
* return value continues, never what the registration waits for — otherwise
|
|
224
|
+
* `sdk.start().on('error', report)` would bind after the session opened and miss an
|
|
225
|
+
* error thrown while it was opening, which is the error a host most wants.
|
|
226
|
+
*
|
|
227
|
+
* The factory and the engine it opens share one channel, so subscribing before
|
|
228
|
+
* `start()` is the normal way to do it, not a special case.
|
|
229
|
+
*/
|
|
230
|
+
on<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): TSelf;
|
|
231
|
+
/** Takes the listener, not just the type — see {@link EventEmitter.off}. */
|
|
232
|
+
off<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): TSelf;
|
|
66
233
|
}
|
|
67
234
|
|
|
68
235
|
// ---- Public contract each WASM engine must implement ----
|
|
@@ -89,7 +256,14 @@ export interface PromisePlay<P extends EnginePayloads = DefaultPayloads>
|
|
|
89
256
|
|
|
90
257
|
export interface EngineSDK<P extends EnginePayloads = DefaultPayloads>
|
|
91
258
|
extends EngineSurface<EngineSDK<P>, P> {
|
|
92
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Bring the engine up and open a session — the only way in.
|
|
261
|
+
*
|
|
262
|
+
* A factory governs at most one live engine, so calling it again on a live one
|
|
263
|
+
* reopens the session rather than building a second engine; that is exactly
|
|
264
|
+
* {@link EnginePlay.restart}. Whatever an engine has to do before a session can
|
|
265
|
+
* exist is its own business, done here.
|
|
266
|
+
*/
|
|
93
267
|
start(options?: Partial<P['options']>): PromisePlay<P>;
|
|
94
268
|
}
|
|
95
269
|
|
|
@@ -108,3 +282,434 @@ export interface EngineSDKConstructor<P extends EnginePayloads = DefaultPayloads
|
|
|
108
282
|
|
|
109
283
|
new (init?: EngineInit<P>): EngineSDK<P>;
|
|
110
284
|
}
|
|
285
|
+
|
|
286
|
+
// ---- The contract, implemented once ----
|
|
287
|
+
//
|
|
288
|
+
// Everything below is optional. The contract is structural: an engine satisfies
|
|
289
|
+
// `EngineSDK` by having the right members, not by extending anything here. What these
|
|
290
|
+
// exist for is that twelve engines were otherwise going to write the same three
|
|
291
|
+
// things — the chainable promise, the getter/setter overload dance, and the channel —
|
|
292
|
+
// and get them subtly different.
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A descriptor with its `value` removed: what an engine *declares*, since the value
|
|
296
|
+
* is state and changes.
|
|
297
|
+
*
|
|
298
|
+
* `default` is declared here and nowhere else. {@link EnginePlayBase} seeds its
|
|
299
|
+
* starting state from it, which is what collapses the two sources of truth every
|
|
300
|
+
* hand-written engine had — an initial `{ region: 'us' }` field alongside a
|
|
301
|
+
* `default: 'us'` in the descriptor, with nothing to catch them drifting apart.
|
|
302
|
+
*/
|
|
303
|
+
export type PropertySpec<Value> = Omit<PropertyDefinition<Value>, 'value'>;
|
|
304
|
+
|
|
305
|
+
/** `-?` as in {@link PropertiesDefinition}: an engine cannot forget to describe a key. */
|
|
306
|
+
export type PropertiesSpec<T> = { [K in keyof T]-?: PropertySpec<T[K]> };
|
|
307
|
+
|
|
308
|
+
/** Everything an engine has to say about its four payloads. */
|
|
309
|
+
export interface EngineSpec<P extends EnginePayloads = DefaultPayloads> {
|
|
310
|
+
config: PropertiesSpec<P['config']>;
|
|
311
|
+
options: PropertiesSpec<P['options']>;
|
|
312
|
+
assets: PropertiesSpec<P['assets']>;
|
|
313
|
+
storage: PropertiesSpec<P['storage']>;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The four payload names, which are also the four getter/setter pairs. */
|
|
317
|
+
export type PayloadKind = 'config' | 'options' | 'assets' | 'storage';
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Wrap a promise of a live engine in the chainable surface.
|
|
321
|
+
*
|
|
322
|
+
* Exported because it is the single most duplicated function in an engine, and an
|
|
323
|
+
* engine that does not extend {@link EnginePlayBase} still needs exactly this.
|
|
324
|
+
*
|
|
325
|
+
* `on` and `off` do **not** go through the chain: they reach the channel now, so a
|
|
326
|
+
* listener is live before the promise settles. Routing them through it would bind
|
|
327
|
+
* after the session opened and lose whatever was emitted while it was opening.
|
|
328
|
+
*/
|
|
329
|
+
export function hydratePlay<P extends EnginePayloads = DefaultPayloads>(
|
|
330
|
+
inner: Promise<EnginePlay<P>>,
|
|
331
|
+
events: EventEmitter<EventsOf<P>>,
|
|
332
|
+
): PromisePlay<P> {
|
|
333
|
+
const chain = (step: (play: EnginePlay<P>) => PromisePlay<P>): PromisePlay<P> =>
|
|
334
|
+
hydratePlay(
|
|
335
|
+
inner.then(async (play) => {
|
|
336
|
+
await step(play);
|
|
337
|
+
return play;
|
|
338
|
+
}),
|
|
339
|
+
events,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const hydrated: PromisePlay<P> = {
|
|
343
|
+
then: (onfulfilled, onrejected) => inner.then(onfulfilled, onrejected),
|
|
344
|
+
catch: (onrejected) => inner.catch(onrejected),
|
|
345
|
+
finally: (onfinally) => inner.finally(onfinally),
|
|
346
|
+
[Symbol.toStringTag]: 'PromisePlay',
|
|
347
|
+
|
|
348
|
+
config: ((patch?: Partial<P['config']>) =>
|
|
349
|
+
patch === undefined
|
|
350
|
+
? inner.then((play) => play.config())
|
|
351
|
+
: chain((play) => play.config(patch))) as PromisePlay<P>['config'],
|
|
352
|
+
options: ((patch?: Partial<P['options']>) =>
|
|
353
|
+
patch === undefined
|
|
354
|
+
? inner.then((play) => play.options())
|
|
355
|
+
: chain((play) => play.options(patch))) as PromisePlay<P>['options'],
|
|
356
|
+
assets: ((patch?: Partial<P['assets']>) =>
|
|
357
|
+
patch === undefined
|
|
358
|
+
? inner.then((play) => play.assets())
|
|
359
|
+
: chain((play) => play.assets(patch))) as PromisePlay<P>['assets'],
|
|
360
|
+
storage: ((patch?: Partial<P['storage']>) =>
|
|
361
|
+
patch === undefined
|
|
362
|
+
? inner.then((play) => play.storage())
|
|
363
|
+
: chain((play) => play.storage(patch))) as PromisePlay<P>['storage'],
|
|
364
|
+
|
|
365
|
+
mount: (target: HTMLElement) => chain((play) => play.mount(target)),
|
|
366
|
+
|
|
367
|
+
on: <K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>) => {
|
|
368
|
+
events.on(type, listener);
|
|
369
|
+
return hydrated;
|
|
370
|
+
},
|
|
371
|
+
off: <K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>) => {
|
|
372
|
+
events.off(type, listener);
|
|
373
|
+
return hydrated;
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
restart: (options?: Partial<P['options']>) => chain((play) => play.restart(options)),
|
|
377
|
+
destroy: () => inner.then((play) => play.destroy()),
|
|
378
|
+
};
|
|
379
|
+
return hydrated;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The live engine, with everything the contract prescribes already written: state,
|
|
384
|
+
* the cumulative merge, the descriptors, the chainable promise, and the channel.
|
|
385
|
+
*
|
|
386
|
+
* A subclass supplies what only it can know — {@link EnginePlayBase.open} and
|
|
387
|
+
* {@link EnginePlayBase.close}, optionally {@link EnginePlayBase.mounted} and
|
|
388
|
+
* {@link EnginePlayBase.patched} — and its properties as an {@link EngineSpec}.
|
|
389
|
+
*
|
|
390
|
+
* The spec arrives as a **constructor argument**, not an abstract field, and that is
|
|
391
|
+
* not a style choice: a subclass field initialiser runs *after* `super()`, so a
|
|
392
|
+
* `protected abstract spec` would still be `undefined` while this constructor seeded
|
|
393
|
+
* state from it. Passing it up makes the ordering trap impossible rather than merely
|
|
394
|
+
* avoided.
|
|
395
|
+
*/
|
|
396
|
+
export abstract class EnginePlayBase<P extends EnginePayloads = DefaultPayloads>
|
|
397
|
+
implements EnginePlay<P>
|
|
398
|
+
{
|
|
399
|
+
readonly #spec: EngineSpec<P>;
|
|
400
|
+
readonly #state: Record<PayloadKind, Record<string, unknown>>;
|
|
401
|
+
readonly #events: EventEmitter<EventsOf<P>>;
|
|
402
|
+
#target: HTMLElement | undefined;
|
|
403
|
+
/** The target {@link EnginePlayBase.mounted} has already been told about. */
|
|
404
|
+
#attached: HTMLElement | undefined;
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param events The factory's channel. Omitted only when a play is built on its
|
|
408
|
+
* own; the factory always passes its own, which is what makes `sdk.on(…)` and
|
|
409
|
+
* `play.on(…)` one channel rather than two.
|
|
410
|
+
*/
|
|
411
|
+
constructor(spec: EngineSpec<P>, init?: EngineInit<P>, events?: EventEmitter<EventsOf<P>>) {
|
|
412
|
+
this.#spec = spec;
|
|
413
|
+
this.#events = events ?? new EventEmitter<EventsOf<P>>(init?.on);
|
|
414
|
+
this.#state = {
|
|
415
|
+
config: seed(spec.config, init?.config),
|
|
416
|
+
options: seed(spec.options, init?.options),
|
|
417
|
+
assets: seed(spec.assets, init?.assets),
|
|
418
|
+
storage: seed(spec.storage, init?.storage),
|
|
419
|
+
};
|
|
420
|
+
// Recorded, not applied. `mounted()` is overridable, and a subclass's own fields
|
|
421
|
+
// are still uninitialised while this constructor runs — so the hook is deferred to
|
|
422
|
+
// the first `restart()`, by which point the subclass is whole.
|
|
423
|
+
this.#target = init?.mount;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- What a subclass implements ----
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Open or reopen a session with the options as they now stand. Called by
|
|
430
|
+
* `restart()`, which merges the patch, emits `start` once this resolves, and turns
|
|
431
|
+
* a throw into both a rejection and an `error` event.
|
|
432
|
+
*/
|
|
433
|
+
protected abstract open(options: P['options']): void | Promise<void>;
|
|
434
|
+
|
|
435
|
+
/** Stop and release. `exit` is emitted once this resolves. */
|
|
436
|
+
protected abstract close(): void | Promise<void>;
|
|
437
|
+
|
|
438
|
+
/** The element is already recorded when this runs. */
|
|
439
|
+
protected mounted(target: HTMLElement): void | Promise<void> {
|
|
440
|
+
void target;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** A payload changed on a running engine — apply what is `live`, ignore the rest. */
|
|
444
|
+
protected patched(kind: PayloadKind, patch: object): void | Promise<void> {
|
|
445
|
+
void kind;
|
|
446
|
+
void patch;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Emit one of this engine's own events.
|
|
451
|
+
*
|
|
452
|
+
* `protected`, so a typed host cannot forge an engine event — but be exact about
|
|
453
|
+
* what that buys: TypeScript erases `protected`, so this is a compile-time guard,
|
|
454
|
+
* not a capability boundary. Plain JavaScript can still reach it. Only the `#`
|
|
455
|
+
* fields below — the channel and the state — are unreachable at runtime.
|
|
456
|
+
*/
|
|
457
|
+
protected emit<K extends keyof EventsOf<P>>(
|
|
458
|
+
type: K,
|
|
459
|
+
...detail: EventsOf<P>[K] extends void ? [] : [detail: EventsOf<P>[K]]
|
|
460
|
+
): void {
|
|
461
|
+
this.#events.emit(type, ...detail);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Where the picture is, for a subclass that needs the element after `mounted()`. */
|
|
465
|
+
protected get target(): HTMLElement | undefined {
|
|
466
|
+
return this.#target;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---- The contract ----
|
|
470
|
+
|
|
471
|
+
config(): Promise<PropertiesDefinition<P['config']>>;
|
|
472
|
+
config(patch: Partial<P['config']>): PromisePlay<P>;
|
|
473
|
+
config(patch?: Partial<P['config']>): Promise<PropertiesDefinition<P['config']>> | PromisePlay<P> {
|
|
474
|
+
// `arguments.length`, not `patch === undefined`: `config(undefined)` is a write of
|
|
475
|
+
// nothing, not a read.
|
|
476
|
+
if (arguments.length === 0) return Promise.resolve(this.#describe('config'));
|
|
477
|
+
return this.#patch('config', patch);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
options(): Promise<PropertiesDefinition<P['options']>>;
|
|
481
|
+
options(patch: Partial<P['options']>): PromisePlay<P>;
|
|
482
|
+
options(
|
|
483
|
+
patch?: Partial<P['options']>,
|
|
484
|
+
): Promise<PropertiesDefinition<P['options']>> | PromisePlay<P> {
|
|
485
|
+
if (arguments.length === 0) return Promise.resolve(this.#describe('options'));
|
|
486
|
+
return this.#patch('options', patch);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
assets(): Promise<PropertiesDefinition<P['assets']>>;
|
|
490
|
+
assets(patch: Partial<P['assets']>): PromisePlay<P>;
|
|
491
|
+
assets(patch?: Partial<P['assets']>): Promise<PropertiesDefinition<P['assets']>> | PromisePlay<P> {
|
|
492
|
+
if (arguments.length === 0) return Promise.resolve(this.#describe('assets'));
|
|
493
|
+
return this.#patch('assets', patch);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
storage(): Promise<PropertiesDefinition<P['storage']>>;
|
|
497
|
+
storage(patch: Partial<P['storage']>): PromisePlay<P>;
|
|
498
|
+
storage(
|
|
499
|
+
patch?: Partial<P['storage']>,
|
|
500
|
+
): Promise<PropertiesDefinition<P['storage']>> | PromisePlay<P> {
|
|
501
|
+
if (arguments.length === 0) return Promise.resolve(this.#describe('storage'));
|
|
502
|
+
return this.#patch('storage', patch);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
mount(target: HTMLElement): PromisePlay<P> {
|
|
506
|
+
this.#target = target;
|
|
507
|
+
return this.#run(() => this.#attach());
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
on<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): PromisePlay<P> {
|
|
511
|
+
this.#events.on(type, listener);
|
|
512
|
+
return hydratePlay<P>(Promise.resolve(this), this.#events);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
off<K extends keyof EventsOf<P>>(
|
|
516
|
+
type: K,
|
|
517
|
+
listener: EngineListener<EventsOf<P>, K>,
|
|
518
|
+
): PromisePlay<P> {
|
|
519
|
+
this.#events.off(type, listener);
|
|
520
|
+
return hydratePlay<P>(Promise.resolve(this), this.#events);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
restart(options?: Partial<P['options']>): PromisePlay<P> {
|
|
524
|
+
return this.#run(async () => {
|
|
525
|
+
if (options) Object.assign(this.#state.options, options);
|
|
526
|
+
// A target supplied to the constructor lands here, which is what makes
|
|
527
|
+
// `new Sdk({ mount: el }).start()` and `new Sdk().mount(el).start()` the same
|
|
528
|
+
// call — the rule the contract states for everything on the surface.
|
|
529
|
+
await this.#attach();
|
|
530
|
+
const opened = { ...this.#state.options } as P['options'];
|
|
531
|
+
await this.open(opened);
|
|
532
|
+
this.#emitCore('start', opened);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async destroy(): Promise<void> {
|
|
537
|
+
await this.#guard(async () => {
|
|
538
|
+
await this.close();
|
|
539
|
+
this.#target = undefined;
|
|
540
|
+
this.#attached = undefined;
|
|
541
|
+
this.#emitCore('exit', undefined);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Tell the engine where it is, once per distinct target. Reparenting is legal. */
|
|
546
|
+
async #attach(): Promise<void> {
|
|
547
|
+
if (!this.#target || this.#target === this.#attached) return;
|
|
548
|
+
this.#attached = this.#target;
|
|
549
|
+
await this.mounted(this.#target);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---- Shared machinery ----
|
|
553
|
+
|
|
554
|
+
/** One getter's answer: the spec, with the current value written into each entry. */
|
|
555
|
+
#describe<K extends PayloadKind>(kind: K): PropertiesDefinition<P[K]> {
|
|
556
|
+
const spec = this.#spec[kind] as Record<string, PropertySpec<unknown>>;
|
|
557
|
+
const state = this.#state[kind];
|
|
558
|
+
const out: Record<string, PropertyDefinition<unknown>> = {};
|
|
559
|
+
for (const key of Object.keys(spec)) {
|
|
560
|
+
out[key] = { ...spec[key], value: state[key] };
|
|
561
|
+
}
|
|
562
|
+
return out as PropertiesDefinition<P[K]>;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Cumulative, merged flat, one level — `undefined` is a value, not "leave alone". */
|
|
566
|
+
#patch(kind: PayloadKind, patch: object | undefined): PromisePlay<P> {
|
|
567
|
+
Object.assign(this.#state[kind], patch);
|
|
568
|
+
return this.#run(async () => {
|
|
569
|
+
await this.patched(kind, patch ?? {});
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** A step, as a link in the chain — and as an `error` event if it throws. */
|
|
574
|
+
#run(step: () => Promise<void>): PromisePlay<P> {
|
|
575
|
+
return hydratePlay<P>(this.#guard(step), this.#events);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The rule that errors reach a host **both ways**: the promise rejects *and*
|
|
580
|
+
* `error` is emitted. Written once here rather than at every throw site.
|
|
581
|
+
*/
|
|
582
|
+
async #guard(step: () => Promise<void>): Promise<this> {
|
|
583
|
+
try {
|
|
584
|
+
await step();
|
|
585
|
+
return this;
|
|
586
|
+
} catch (thrown) {
|
|
587
|
+
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
588
|
+
this.#emitCore('error', error);
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The core events, emitted from here rather than by a subclass.
|
|
595
|
+
*
|
|
596
|
+
* The cast is the one thing TypeScript will not check here: `emit` takes a rest
|
|
597
|
+
* tuple that is conditional on the detail type, and that conditional stays
|
|
598
|
+
* unresolved while `P` is still a type parameter. It is sound — `EventsOf<P>`
|
|
599
|
+
* always contains `CoreEvents<P>` by construction — and confining it to this one
|
|
600
|
+
* method is why the three call sites above read plainly.
|
|
601
|
+
*/
|
|
602
|
+
#emitCore<K extends keyof CoreEvents<P>>(type: K, detail: CoreEvents<P>[K]): void {
|
|
603
|
+
// Cast the channel, not the method: pulling `emit` out into a variable would call
|
|
604
|
+
// it unbound, and it reads its listeners off `this`.
|
|
605
|
+
const events = this.#events as unknown as { emit(type: K, detail: CoreEvents<P>[K]): void };
|
|
606
|
+
events.emit(type, detail);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Starting state: every `default` the spec declares, then whatever `init` overrides. */
|
|
611
|
+
function seed(spec: object, patch: object | undefined): Record<string, unknown> {
|
|
612
|
+
const state: Record<string, unknown> = {};
|
|
613
|
+
for (const [key, property] of Object.entries(spec as Record<string, PropertySpec<unknown>>)) {
|
|
614
|
+
if (property.default !== undefined) state[key] = property.default;
|
|
615
|
+
}
|
|
616
|
+
return Object.assign(state, patch);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The factory, with everything the contract prescribes already written: the
|
|
621
|
+
* cumulative init, the synchronous chain, the channel that outlives every session,
|
|
622
|
+
* and one live engine per factory.
|
|
623
|
+
*
|
|
624
|
+
* A subclass supplies its static identity and {@link EngineSDKBase.createPlay}.
|
|
625
|
+
*/
|
|
626
|
+
export abstract class EngineSDKBase<P extends EnginePayloads = DefaultPayloads>
|
|
627
|
+
implements EngineSDK<P>
|
|
628
|
+
{
|
|
629
|
+
readonly #init: EngineInit<P>;
|
|
630
|
+
#play: EnginePlayBase<P> | undefined;
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Created before any engine exists and handed to every carrier downstream. That is
|
|
634
|
+
* what lets a host subscribe to an error thrown while the first session opens.
|
|
635
|
+
*/
|
|
636
|
+
protected readonly events: EventEmitter<EventsOf<P>>;
|
|
637
|
+
|
|
638
|
+
constructor(init?: EngineInit<P>) {
|
|
639
|
+
this.#init = { ...init };
|
|
640
|
+
this.events = new EventEmitter<EventsOf<P>>(init?.on);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Build the live engine. The channel is passed in, never created here. */
|
|
644
|
+
protected abstract createPlay(
|
|
645
|
+
init: EngineInit<P>,
|
|
646
|
+
events: EventEmitter<EventsOf<P>>,
|
|
647
|
+
): EnginePlayBase<P>;
|
|
648
|
+
|
|
649
|
+
config(): Promise<PropertiesDefinition<P['config']>>;
|
|
650
|
+
config(patch: Partial<P['config']>): this;
|
|
651
|
+
config(patch?: Partial<P['config']>): Promise<PropertiesDefinition<P['config']>> | this {
|
|
652
|
+
if (arguments.length === 0) return this.#pending().config();
|
|
653
|
+
return this.#patch('config', patch);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
options(): Promise<PropertiesDefinition<P['options']>>;
|
|
657
|
+
options(patch: Partial<P['options']>): this;
|
|
658
|
+
options(patch?: Partial<P['options']>): Promise<PropertiesDefinition<P['options']>> | this {
|
|
659
|
+
if (arguments.length === 0) return this.#pending().options();
|
|
660
|
+
return this.#patch('options', patch);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
assets(): Promise<PropertiesDefinition<P['assets']>>;
|
|
664
|
+
assets(patch: Partial<P['assets']>): this;
|
|
665
|
+
assets(patch?: Partial<P['assets']>): Promise<PropertiesDefinition<P['assets']>> | this {
|
|
666
|
+
if (arguments.length === 0) return this.#pending().assets();
|
|
667
|
+
return this.#patch('assets', patch);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
storage(): Promise<PropertiesDefinition<P['storage']>>;
|
|
671
|
+
storage(patch: Partial<P['storage']>): this;
|
|
672
|
+
storage(patch?: Partial<P['storage']>): Promise<PropertiesDefinition<P['storage']>> | this {
|
|
673
|
+
if (arguments.length === 0) return this.#pending().storage();
|
|
674
|
+
return this.#patch('storage', patch);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Recorded now, applied when the engine exists — same call as `new Sdk({ mount })`. */
|
|
678
|
+
mount(target: HTMLElement): this {
|
|
679
|
+
this.#init.mount = target;
|
|
680
|
+
this.#play?.mount(target);
|
|
681
|
+
return this;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
on<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): this {
|
|
685
|
+
this.events.on(type, listener);
|
|
686
|
+
return this;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
off<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): this {
|
|
690
|
+
this.events.off(type, listener);
|
|
691
|
+
return this;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The only way in. On a live engine it reopens the session rather than building a
|
|
696
|
+
* second one: a factory governs at most one engine.
|
|
697
|
+
*/
|
|
698
|
+
start(options?: Partial<P['options']>): PromisePlay<P> {
|
|
699
|
+
this.#play ??= this.createPlay(this.#init, this.events);
|
|
700
|
+
return this.#play.restart(options);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Answers a read before there is an engine: the settings the next one will use. */
|
|
704
|
+
#pending(): EnginePlayBase<P> {
|
|
705
|
+
return this.#play ?? this.createPlay(this.#init, this.events);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
#patch(kind: PayloadKind, patch: object | undefined): this {
|
|
709
|
+
this.#init[kind] = { ...this.#init[kind], ...patch };
|
|
710
|
+
// A live engine gets it too, so the factory does not silently describe a state
|
|
711
|
+
// the engine is not in.
|
|
712
|
+
if (this.#play && patch) this.#play[kind](patch as never);
|
|
713
|
+
return this;
|
|
714
|
+
}
|
|
715
|
+
}
|