@scenar/core 0.1.18 → 0.1.20-rc.1
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 +19 -0
- package/embed/host-controller.d.ts +53 -0
- package/embed/host-controller.d.ts.map +1 -0
- package/embed/host-controller.js +52 -0
- package/embed/host-controller.js.map +1 -0
- package/embed/protocol.d.ts +117 -0
- package/embed/protocol.d.ts.map +1 -0
- package/embed/protocol.js +111 -0
- package/embed/protocol.js.map +1 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -1
- package/index.js +4 -0
- package/index.js.map +1 -1
- package/package.json +1 -1
- package/src/embed/host-controller.test.ts +129 -0
- package/src/embed/host-controller.ts +95 -0
- package/src/embed/protocol.test.ts +110 -0
- package/src/embed/protocol.ts +173 -0
- package/src/index.ts +24 -0
package/README.md
CHANGED
|
@@ -60,6 +60,25 @@ The engine identifies interactive elements via data attributes. These constants
|
|
|
60
60
|
- `NarrationEntry` — `{ src, durationMs }` for one audio clip.
|
|
61
61
|
- `NarrationManifest` — `{ steps: (NarrationEntry | null)[] }`.
|
|
62
62
|
|
|
63
|
+
### Embed protocol (v1)
|
|
64
|
+
|
|
65
|
+
The wire contract for a packed scenario delivered as a cross-origin iframe. The embedded player emits events; the host page sends commands. Every message carries a fixed source tag and version, and receivers ignore anything that does not match — the global `message` channel is shared with the host and every other widget on the page.
|
|
66
|
+
|
|
67
|
+
- `SCENAR_EMBED_SOURCE` — `"scenar-embed"`, stamped on every message.
|
|
68
|
+
- `SCENAR_EMBED_PROTOCOL_VERSION` — `1`. Bumped only on a breaking message-shape change.
|
|
69
|
+
- `ScenarEmbedEvent` — events the player emits (`ready`, `resize`, `started`, `paused`, `stepchange`, `progress`, `completed`, `audioBlocked`, `error`).
|
|
70
|
+
- `ScenarEmbedCommand` — commands the host sends (`play`, `pause`, `seek`, `setMuted`, `setVolume`, `prefetch`, `destroy`).
|
|
71
|
+
- `frameEmbedEvent(event)` / `frameEmbedCommand(command)` — stamp a message with the source + version envelope, ready to post.
|
|
72
|
+
- `parseEmbedEvent(data)` / `parseEmbedCommand(data)` — the schema boundary: validate an inbound `MessageEvent.data` and return the typed message, or `null`. Origin and source-window checks live in the receiver.
|
|
73
|
+
- `ScenarEmbedEventMessage` / `ScenarEmbedCommandMessage` — a framed message (event/command plus the envelope) as it travels over `postMessage`.
|
|
74
|
+
|
|
75
|
+
### Embed host controller
|
|
76
|
+
|
|
77
|
+
- `createEmbedHostController(target, options?)` — a framework-free driver for an embedded player. It sends commands to the iframe and forwards validated events to `options.onEvent`. Both directions are pinned to `target.origin` (derive it with `new URL(embedUrl).origin`). The same controller backs both the React console preview and a vanilla `embed.js` loader, so host behavior never forks.
|
|
78
|
+
- `ScenarEmbedHostController` — the returned handle: `play`, `pause`, `seek`, `setMuted`, `setVolume`, `prefetch`, `destroy`. `destroy()` tells the embed to stop and detaches the message listener; the host still owns the iframe element.
|
|
79
|
+
- `ScenarEmbedHostTarget` — `{ iframe, origin }`, the embed this controller drives.
|
|
80
|
+
- `ScenarEmbedHostOptions` — `{ onEvent? }`, invoked for every well-formed event from the pinned embed.
|
|
81
|
+
|
|
63
82
|
## License
|
|
64
83
|
|
|
65
84
|
Apache-2.0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ScenarEmbedEvent } from "./protocol.js";
|
|
2
|
+
/**
|
|
3
|
+
* The embed iframe this controller drives. Commands post to its
|
|
4
|
+
* `contentWindow`, read at send time because it changes across (re)loads.
|
|
5
|
+
*/
|
|
6
|
+
export interface ScenarEmbedHostTarget {
|
|
7
|
+
readonly iframe: HTMLIFrameElement;
|
|
8
|
+
/**
|
|
9
|
+
* The embed's exact origin — scheme + host + port, no path
|
|
10
|
+
* (e.g. `https://d-<deployId>.scenarusercontent.net`). Both inbound events
|
|
11
|
+
* and outbound commands are pinned to it. Derive it from the embed URL with
|
|
12
|
+
* `new URL(embedUrl).origin`.
|
|
13
|
+
*/
|
|
14
|
+
readonly origin: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ScenarEmbedHostOptions {
|
|
17
|
+
/** Invoked for every well-formed event received from the pinned embed. */
|
|
18
|
+
readonly onEvent?: (event: ScenarEmbedEvent) => void;
|
|
19
|
+
}
|
|
20
|
+
/** Imperative handle for driving an embedded player from its host page. */
|
|
21
|
+
export interface ScenarEmbedHostController {
|
|
22
|
+
play(): void;
|
|
23
|
+
pause(): void;
|
|
24
|
+
seek(timeMs: number): void;
|
|
25
|
+
setMuted(muted: boolean): void;
|
|
26
|
+
setVolume(volume: number): void;
|
|
27
|
+
prefetch(): void;
|
|
28
|
+
/**
|
|
29
|
+
* Tell the embed to stop (best effort) and detach the host message listener.
|
|
30
|
+
* The host still owns the iframe element; remove it separately for full
|
|
31
|
+
* teardown.
|
|
32
|
+
*/
|
|
33
|
+
destroy(): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Drive an embedded {@link https://www.npmjs.com/package/@scenar/react | ScenarioPlayer}
|
|
37
|
+
* from its host page over the `scenar` embed postMessage protocol (v1).
|
|
38
|
+
*
|
|
39
|
+
* This is the exact inverse of `useScenarEmbedBridge` (the embed-side runtime in
|
|
40
|
+
* `@scenar/react`): it sends the commands that bridge receives and receives the
|
|
41
|
+
* events that bridge sends. It is framework-free so both the React console
|
|
42
|
+
* preview and a vanilla `embed.js` loader share one host implementation.
|
|
43
|
+
*
|
|
44
|
+
* Security: unlike the embed side — which cannot know its host's origin ahead of
|
|
45
|
+
* time and so pins on the first inbound message — the host already knows the
|
|
46
|
+
* embed's exact origin, so this controller pins from the start. An inbound
|
|
47
|
+
* message is accepted only when it both comes from this iframe's window
|
|
48
|
+
* (`event.source === iframe.contentWindow`) and carries the pinned `origin`.
|
|
49
|
+
* The global `message` channel is shared with the host page and every other
|
|
50
|
+
* widget, so both checks are required.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createEmbedHostController(target: ScenarEmbedHostTarget, options?: ScenarEmbedHostOptions): ScenarEmbedHostController;
|
|
53
|
+
//# sourceMappingURL=host-controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-controller.d.ts","sourceRoot":"","sources":["../../src/embed/host-controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,gBAAgB,EAGtB,MAAM,eAAe,CAAC;AAEvB;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;CACtD;AAED,2EAA2E;AAC3E,MAAM,WAAW,yBAAyB;IACxC,IAAI,IAAI,IAAI,CAAC;IACb,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,IAAI,IAAI,CAAC;IACjB;;;;OAIG;IACH,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,qBAAqB,EAC7B,OAAO,GAAE,sBAA2B,GACnC,yBAAyB,CA+B3B"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { frameEmbedCommand, parseEmbedEvent, } from "./protocol.js";
|
|
2
|
+
/**
|
|
3
|
+
* Drive an embedded {@link https://www.npmjs.com/package/@scenar/react | ScenarioPlayer}
|
|
4
|
+
* from its host page over the `scenar` embed postMessage protocol (v1).
|
|
5
|
+
*
|
|
6
|
+
* This is the exact inverse of `useScenarEmbedBridge` (the embed-side runtime in
|
|
7
|
+
* `@scenar/react`): it sends the commands that bridge receives and receives the
|
|
8
|
+
* events that bridge sends. It is framework-free so both the React console
|
|
9
|
+
* preview and a vanilla `embed.js` loader share one host implementation.
|
|
10
|
+
*
|
|
11
|
+
* Security: unlike the embed side — which cannot know its host's origin ahead of
|
|
12
|
+
* time and so pins on the first inbound message — the host already knows the
|
|
13
|
+
* embed's exact origin, so this controller pins from the start. An inbound
|
|
14
|
+
* message is accepted only when it both comes from this iframe's window
|
|
15
|
+
* (`event.source === iframe.contentWindow`) and carries the pinned `origin`.
|
|
16
|
+
* The global `message` channel is shared with the host page and every other
|
|
17
|
+
* widget, so both checks are required.
|
|
18
|
+
*/
|
|
19
|
+
export function createEmbedHostController(target, options = {}) {
|
|
20
|
+
const { iframe, origin } = target;
|
|
21
|
+
const { onEvent } = options;
|
|
22
|
+
const canListen = typeof window !== "undefined";
|
|
23
|
+
const send = (command) => {
|
|
24
|
+
iframe.contentWindow?.postMessage(frameEmbedCommand(command), origin);
|
|
25
|
+
};
|
|
26
|
+
const onMessage = (event) => {
|
|
27
|
+
if (event.source !== iframe.contentWindow)
|
|
28
|
+
return;
|
|
29
|
+
if (event.origin !== origin)
|
|
30
|
+
return;
|
|
31
|
+
const parsed = parseEmbedEvent(event.data);
|
|
32
|
+
if (!parsed)
|
|
33
|
+
return;
|
|
34
|
+
onEvent?.(parsed);
|
|
35
|
+
};
|
|
36
|
+
if (canListen)
|
|
37
|
+
window.addEventListener("message", onMessage);
|
|
38
|
+
return {
|
|
39
|
+
play: () => send({ type: "play" }),
|
|
40
|
+
pause: () => send({ type: "pause" }),
|
|
41
|
+
seek: (timeMs) => send({ type: "seek", timeMs }),
|
|
42
|
+
setMuted: (muted) => send({ type: "setMuted", muted }),
|
|
43
|
+
setVolume: (volume) => send({ type: "setVolume", volume }),
|
|
44
|
+
prefetch: () => send({ type: "prefetch" }),
|
|
45
|
+
destroy: () => {
|
|
46
|
+
send({ type: "destroy" });
|
|
47
|
+
if (canListen)
|
|
48
|
+
window.removeEventListener("message", onMessage);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=host-controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-controller.js","sourceRoot":"","sources":["../../src/embed/host-controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,iBAAiB,EACjB,eAAe,GAChB,MAAM,eAAe,CAAC;AAsCvB;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,yBAAyB,CACvC,MAA6B,EAC7B,UAAkC,EAAE;IAEpC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;IAClC,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAC5B,MAAM,SAAS,GAAG,OAAO,MAAM,KAAK,WAAW,CAAC;IAEhD,MAAM,IAAI,GAAG,CAAC,OAA2B,EAAQ,EAAE;QACjD,MAAM,CAAC,aAAa,EAAE,WAAW,CAAC,iBAAiB,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,CAAC;IACxE,CAAC,CAAC;IAEF,MAAM,SAAS,GAAG,CAAC,KAAmB,EAAQ,EAAE;QAC9C,IAAI,KAAK,CAAC,MAAM,KAAK,MAAM,CAAC,aAAa;YAAE,OAAO;QAClD,IAAI,KAAK,CAAC,MAAM,KAAK,MAAM;YAAE,OAAO;QACpC,MAAM,MAAM,GAAG,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC3C,IAAI,CAAC,MAAM;YAAE,OAAO;QACpB,OAAO,EAAE,CAAC,MAAM,CAAC,CAAC;IACpB,CAAC,CAAC;IAEF,IAAI,SAAS;QAAE,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;IAE7D,OAAO;QACL,IAAI,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;QAClC,KAAK,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;QACpC,IAAI,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QAChD,QAAQ,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;QACtD,SAAS,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,CAAC;QAC1D,QAAQ,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC;QAC1C,OAAO,EAAE,GAAG,EAAE;YACZ,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAC;YAC1B,IAAI,SAAS;gBAAE,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;QAClE,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `scenar` embed postMessage protocol (v1).
|
|
3
|
+
*
|
|
4
|
+
* A packed scenario is delivered as a cross-origin iframe. This module is the
|
|
5
|
+
* wire contract between the embedded player and its host page: the events the
|
|
6
|
+
* player emits and the commands the host may send. It is pure (no React, no
|
|
7
|
+
* DOM) and lives in `@scenar/core` so every surface shares one definition — the
|
|
8
|
+
* React embed-side runtime (`useScenarEmbedBridge` in `@scenar/react`), the
|
|
9
|
+
* framework-free host driver ({@link createEmbedHostController}), and any future
|
|
10
|
+
* host loader (`embed.js` / oEmbed).
|
|
11
|
+
*
|
|
12
|
+
* Every message is stamped with a fixed {@link SCENAR_EMBED_SOURCE} and the
|
|
13
|
+
* protocol {@link SCENAR_EMBED_PROTOCOL_VERSION}. Receivers MUST ignore any
|
|
14
|
+
* message whose `source`/`v` do not match — the global `message` channel is
|
|
15
|
+
* shared with the host and every other framed widget on the page.
|
|
16
|
+
*/
|
|
17
|
+
/** Discriminator stamped on every scenar embed message. */
|
|
18
|
+
export declare const SCENAR_EMBED_SOURCE = "scenar-embed";
|
|
19
|
+
/** Protocol version. Bump only on a breaking change to the message shapes. */
|
|
20
|
+
export declare const SCENAR_EMBED_PROTOCOL_VERSION = 1;
|
|
21
|
+
/**
|
|
22
|
+
* Events the embedded player emits to the host (embed -> host).
|
|
23
|
+
*
|
|
24
|
+
* - `ready` — the player has mounted; carries scenario shape the host can use
|
|
25
|
+
* to size or label the frame.
|
|
26
|
+
* - `resize` — the rendered content height changed (dynamic-height hosts).
|
|
27
|
+
* - `started` / `paused` / `completed` — transport state transitions.
|
|
28
|
+
* - `stepchange` — the active step changed.
|
|
29
|
+
* - `progress` — fine-grained playback position (throttled by the emitter).
|
|
30
|
+
* - `audioBlocked` — the browser blocked narration audio; the host may surface
|
|
31
|
+
* its own affordance or call `setMuted`.
|
|
32
|
+
* - `error` — an unrecoverable runtime error, with a human-readable message.
|
|
33
|
+
*/
|
|
34
|
+
export type ScenarEmbedEvent = {
|
|
35
|
+
readonly type: "ready";
|
|
36
|
+
readonly totalSteps: number;
|
|
37
|
+
readonly hasNarration: boolean;
|
|
38
|
+
} | {
|
|
39
|
+
readonly type: "resize";
|
|
40
|
+
readonly widthPx: number;
|
|
41
|
+
readonly heightPx: number;
|
|
42
|
+
} | {
|
|
43
|
+
readonly type: "started";
|
|
44
|
+
} | {
|
|
45
|
+
readonly type: "paused";
|
|
46
|
+
} | {
|
|
47
|
+
readonly type: "stepchange";
|
|
48
|
+
readonly stepIndex: number;
|
|
49
|
+
readonly totalSteps: number;
|
|
50
|
+
} | {
|
|
51
|
+
readonly type: "progress";
|
|
52
|
+
readonly stepIndex: number;
|
|
53
|
+
readonly totalSteps: number;
|
|
54
|
+
readonly fraction: number;
|
|
55
|
+
} | {
|
|
56
|
+
readonly type: "completed";
|
|
57
|
+
} | {
|
|
58
|
+
readonly type: "audioBlocked";
|
|
59
|
+
} | {
|
|
60
|
+
readonly type: "error";
|
|
61
|
+
readonly message: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Commands the host may send to the embedded player (host -> embed).
|
|
65
|
+
*
|
|
66
|
+
* State-setting commands (`setMuted`, `setVolume`) are idempotent by design so
|
|
67
|
+
* a host never has to track the player's internal state to stay in sync.
|
|
68
|
+
*/
|
|
69
|
+
export type ScenarEmbedCommand = {
|
|
70
|
+
readonly type: "play";
|
|
71
|
+
} | {
|
|
72
|
+
readonly type: "pause";
|
|
73
|
+
} | {
|
|
74
|
+
readonly type: "seek";
|
|
75
|
+
readonly timeMs: number;
|
|
76
|
+
} | {
|
|
77
|
+
readonly type: "setMuted";
|
|
78
|
+
readonly muted: boolean;
|
|
79
|
+
} | {
|
|
80
|
+
readonly type: "setVolume";
|
|
81
|
+
readonly volume: number;
|
|
82
|
+
} | {
|
|
83
|
+
readonly type: "prefetch";
|
|
84
|
+
} | {
|
|
85
|
+
readonly type: "destroy";
|
|
86
|
+
};
|
|
87
|
+
/** Envelope fields stamped on every message of either direction. */
|
|
88
|
+
interface ScenarEmbedEnvelope {
|
|
89
|
+
readonly source: typeof SCENAR_EMBED_SOURCE;
|
|
90
|
+
readonly v: typeof SCENAR_EMBED_PROTOCOL_VERSION;
|
|
91
|
+
}
|
|
92
|
+
/** A fully-framed event message as it travels over `postMessage`. */
|
|
93
|
+
export type ScenarEmbedEventMessage = ScenarEmbedEvent & ScenarEmbedEnvelope;
|
|
94
|
+
/** A fully-framed command message as it travels over `postMessage`. */
|
|
95
|
+
export type ScenarEmbedCommandMessage = ScenarEmbedCommand & ScenarEmbedEnvelope;
|
|
96
|
+
/** Stamp an event with the source + version envelope, ready to post. */
|
|
97
|
+
export declare function frameEmbedEvent(event: ScenarEmbedEvent): ScenarEmbedEventMessage;
|
|
98
|
+
/** Stamp a command with the source + version envelope, ready to post. */
|
|
99
|
+
export declare function frameEmbedCommand(command: ScenarEmbedCommand): ScenarEmbedCommandMessage;
|
|
100
|
+
/**
|
|
101
|
+
* Parse an inbound `MessageEvent.data` into a typed command, or return `null`.
|
|
102
|
+
*
|
|
103
|
+
* This is the schema boundary: it rejects anything that is not a well-formed
|
|
104
|
+
* scenar command of the matching version, including unknown command types and
|
|
105
|
+
* commands missing or mistyping their required fields. Origin/source-window
|
|
106
|
+
* checks live in the receiver; this function validates the payload shape only.
|
|
107
|
+
*/
|
|
108
|
+
export declare function parseEmbedCommand(data: unknown): ScenarEmbedCommand | null;
|
|
109
|
+
/**
|
|
110
|
+
* Parse an inbound `MessageEvent.data` into a typed event, or return `null`.
|
|
111
|
+
*
|
|
112
|
+
* The symmetric counterpart to {@link parseEmbedCommand} for host-side code
|
|
113
|
+
* consuming the player's events.
|
|
114
|
+
*/
|
|
115
|
+
export declare function parseEmbedEvent(data: unknown): ScenarEmbedEvent | null;
|
|
116
|
+
export {};
|
|
117
|
+
//# sourceMappingURL=protocol.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../../src/embed/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,2DAA2D;AAC3D,eAAO,MAAM,mBAAmB,iBAAiB,CAAC;AAElD,8EAA8E;AAC9E,eAAO,MAAM,6BAA6B,IAAI,CAAC;AAE/C;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAA;CAAE,GACvF;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAChF;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACxF;IACE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B,GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAAE,GAC9B;IAAE,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;CAAE,GACjC;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC1B;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACtD;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;CAAE,GAC7B;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAEjC,oEAAoE;AACpE,UAAU,mBAAmB;IAC3B,QAAQ,CAAC,MAAM,EAAE,OAAO,mBAAmB,CAAC;IAC5C,QAAQ,CAAC,CAAC,EAAE,OAAO,6BAA6B,CAAC;CAClD;AAED,qEAAqE;AACrE,MAAM,MAAM,uBAAuB,GAAG,gBAAgB,GAAG,mBAAmB,CAAC;AAE7E,uEAAuE;AACvE,MAAM,MAAM,yBAAyB,GAAG,kBAAkB,GAAG,mBAAmB,CAAC;AAEjF,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,KAAK,EAAE,gBAAgB,GAAG,uBAAuB,CAEhF;AAED,yEAAyE;AACzE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,yBAAyB,CAExF;AAcD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,OAAO,GAAG,kBAAkB,GAAG,IAAI,CAkB1E;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,OAAO,GAAG,gBAAgB,GAAG,IAAI,CAqCtE"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `scenar` embed postMessage protocol (v1).
|
|
3
|
+
*
|
|
4
|
+
* A packed scenario is delivered as a cross-origin iframe. This module is the
|
|
5
|
+
* wire contract between the embedded player and its host page: the events the
|
|
6
|
+
* player emits and the commands the host may send. It is pure (no React, no
|
|
7
|
+
* DOM) and lives in `@scenar/core` so every surface shares one definition — the
|
|
8
|
+
* React embed-side runtime (`useScenarEmbedBridge` in `@scenar/react`), the
|
|
9
|
+
* framework-free host driver ({@link createEmbedHostController}), and any future
|
|
10
|
+
* host loader (`embed.js` / oEmbed).
|
|
11
|
+
*
|
|
12
|
+
* Every message is stamped with a fixed {@link SCENAR_EMBED_SOURCE} and the
|
|
13
|
+
* protocol {@link SCENAR_EMBED_PROTOCOL_VERSION}. Receivers MUST ignore any
|
|
14
|
+
* message whose `source`/`v` do not match — the global `message` channel is
|
|
15
|
+
* shared with the host and every other framed widget on the page.
|
|
16
|
+
*/
|
|
17
|
+
/** Discriminator stamped on every scenar embed message. */
|
|
18
|
+
export const SCENAR_EMBED_SOURCE = "scenar-embed";
|
|
19
|
+
/** Protocol version. Bump only on a breaking change to the message shapes. */
|
|
20
|
+
export const SCENAR_EMBED_PROTOCOL_VERSION = 1;
|
|
21
|
+
/** Stamp an event with the source + version envelope, ready to post. */
|
|
22
|
+
export function frameEmbedEvent(event) {
|
|
23
|
+
return { source: SCENAR_EMBED_SOURCE, v: SCENAR_EMBED_PROTOCOL_VERSION, ...event };
|
|
24
|
+
}
|
|
25
|
+
/** Stamp a command with the source + version envelope, ready to post. */
|
|
26
|
+
export function frameEmbedCommand(command) {
|
|
27
|
+
return { source: SCENAR_EMBED_SOURCE, v: SCENAR_EMBED_PROTOCOL_VERSION, ...command };
|
|
28
|
+
}
|
|
29
|
+
function isScenarEnvelope(data) {
|
|
30
|
+
if (typeof data !== "object" || data === null)
|
|
31
|
+
return false;
|
|
32
|
+
const record = data;
|
|
33
|
+
return (record["source"] === SCENAR_EMBED_SOURCE && record["v"] === SCENAR_EMBED_PROTOCOL_VERSION);
|
|
34
|
+
}
|
|
35
|
+
function isFiniteNumber(value) {
|
|
36
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parse an inbound `MessageEvent.data` into a typed command, or return `null`.
|
|
40
|
+
*
|
|
41
|
+
* This is the schema boundary: it rejects anything that is not a well-formed
|
|
42
|
+
* scenar command of the matching version, including unknown command types and
|
|
43
|
+
* commands missing or mistyping their required fields. Origin/source-window
|
|
44
|
+
* checks live in the receiver; this function validates the payload shape only.
|
|
45
|
+
*/
|
|
46
|
+
export function parseEmbedCommand(data) {
|
|
47
|
+
if (!isScenarEnvelope(data))
|
|
48
|
+
return null;
|
|
49
|
+
const type = data["type"];
|
|
50
|
+
switch (type) {
|
|
51
|
+
case "play":
|
|
52
|
+
case "pause":
|
|
53
|
+
case "prefetch":
|
|
54
|
+
case "destroy":
|
|
55
|
+
return { type };
|
|
56
|
+
case "seek":
|
|
57
|
+
return isFiniteNumber(data["timeMs"]) ? { type, timeMs: data["timeMs"] } : null;
|
|
58
|
+
case "setMuted":
|
|
59
|
+
return typeof data["muted"] === "boolean" ? { type, muted: data["muted"] } : null;
|
|
60
|
+
case "setVolume":
|
|
61
|
+
return isFiniteNumber(data["volume"]) ? { type, volume: data["volume"] } : null;
|
|
62
|
+
default:
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse an inbound `MessageEvent.data` into a typed event, or return `null`.
|
|
68
|
+
*
|
|
69
|
+
* The symmetric counterpart to {@link parseEmbedCommand} for host-side code
|
|
70
|
+
* consuming the player's events.
|
|
71
|
+
*/
|
|
72
|
+
export function parseEmbedEvent(data) {
|
|
73
|
+
if (!isScenarEnvelope(data))
|
|
74
|
+
return null;
|
|
75
|
+
const type = data["type"];
|
|
76
|
+
switch (type) {
|
|
77
|
+
case "started":
|
|
78
|
+
case "paused":
|
|
79
|
+
case "completed":
|
|
80
|
+
case "audioBlocked":
|
|
81
|
+
return { type };
|
|
82
|
+
case "ready":
|
|
83
|
+
return isFiniteNumber(data["totalSteps"]) && typeof data["hasNarration"] === "boolean"
|
|
84
|
+
? { type, totalSteps: data["totalSteps"], hasNarration: data["hasNarration"] }
|
|
85
|
+
: null;
|
|
86
|
+
case "resize":
|
|
87
|
+
return isFiniteNumber(data["widthPx"]) && isFiniteNumber(data["heightPx"])
|
|
88
|
+
? { type, widthPx: data["widthPx"], heightPx: data["heightPx"] }
|
|
89
|
+
: null;
|
|
90
|
+
case "stepchange":
|
|
91
|
+
return isFiniteNumber(data["stepIndex"]) && isFiniteNumber(data["totalSteps"])
|
|
92
|
+
? { type, stepIndex: data["stepIndex"], totalSteps: data["totalSteps"] }
|
|
93
|
+
: null;
|
|
94
|
+
case "progress":
|
|
95
|
+
return isFiniteNumber(data["stepIndex"]) &&
|
|
96
|
+
isFiniteNumber(data["totalSteps"]) &&
|
|
97
|
+
isFiniteNumber(data["fraction"])
|
|
98
|
+
? {
|
|
99
|
+
type,
|
|
100
|
+
stepIndex: data["stepIndex"],
|
|
101
|
+
totalSteps: data["totalSteps"],
|
|
102
|
+
fraction: data["fraction"],
|
|
103
|
+
}
|
|
104
|
+
: null;
|
|
105
|
+
case "error":
|
|
106
|
+
return typeof data["message"] === "string" ? { type, message: data["message"] } : null;
|
|
107
|
+
default:
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../src/embed/protocol.ts"],"names":[],"mappings":"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"}
|
package/index.d.ts
CHANGED
|
@@ -13,4 +13,8 @@ export { getStepDurationMs } from "./timeline/step-duration.js";
|
|
|
13
13
|
export { CLICK_DELAY_MS, TYPE_CHAR_DELAY_MS, HOVER_HOLD_MS, DRAG_SETTLE_MS, VIEWPORT_SETTLE_MS, } from "./timing/constants.js";
|
|
14
14
|
export { findScrollParent, scrollTargetIntoView, scrollTargetIntoViewInstant, } from "./dom/scroll.js";
|
|
15
15
|
export { CURSOR_TARGET_ATTRIBUTE, SCROLL_TARGET_ATTRIBUTE, HOVER_STATE_ATTRIBUTE, DRAG_STATE_ATTRIBUTE, cursorTargetSelector, scrollTargetSelector, } from "./targeting/data-attributes.js";
|
|
16
|
+
export { SCENAR_EMBED_SOURCE, SCENAR_EMBED_PROTOCOL_VERSION, frameEmbedEvent, frameEmbedCommand, parseEmbedCommand, parseEmbedEvent, } from "./embed/protocol.js";
|
|
17
|
+
export type { ScenarEmbedEvent, ScenarEmbedCommand, ScenarEmbedEventMessage, ScenarEmbedCommandMessage, } from "./embed/protocol.js";
|
|
18
|
+
export { createEmbedHostController } from "./embed/host-controller.js";
|
|
19
|
+
export type { ScenarEmbedHostController, ScenarEmbedHostTarget, ScenarEmbedHostOptions, } from "./embed/host-controller.js";
|
|
16
20
|
//# sourceMappingURL=index.d.ts.map
|
package/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,YAAY,EACV,UAAU,EACV,UAAU,EACV,0BAA0B,GAC3B,MAAM,2BAA2B,CAAC;AAGnC,YAAY,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAG9E,YAAY,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,2BAA2B,EAAE,MAAM,yBAAyB,CAAC;AAGtE,YAAY,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAGrE,YAAY,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAC;AACxE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAGhE,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,aAAa,EACb,cAAc,EACd,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,2BAA2B,GAC5B,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EACL,uBAAuB,EACvB,uBAAuB,EACvB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,gCAAgC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,YAAY,EACV,UAAU,EACV,UAAU,EACV,0BAA0B,GAC3B,MAAM,2BAA2B,CAAC;AAGnC,YAAY,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAG9E,YAAY,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,2BAA2B,EAAE,MAAM,yBAAyB,CAAC;AAGtE,YAAY,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAGrE,YAAY,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAC;AACxE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAGhE,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,aAAa,EACb,cAAc,EACd,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,2BAA2B,GAC5B,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EACL,uBAAuB,EACvB,uBAAuB,EACvB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,gCAAgC,CAAC;AAGxC,OAAO,EACL,mBAAmB,EACnB,6BAA6B,EAC7B,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,GAChB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,gBAAgB,EAChB,kBAAkB,EAClB,uBAAuB,EACvB,yBAAyB,GAC1B,MAAM,qBAAqB,CAAC;AAG7B,OAAO,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AACvE,YAAY,EACV,yBAAyB,EACzB,qBAAqB,EACrB,sBAAsB,GACvB,MAAM,4BAA4B,CAAC"}
|
package/index.js
CHANGED
|
@@ -11,4 +11,8 @@ export { CLICK_DELAY_MS, TYPE_CHAR_DELAY_MS, HOVER_HOLD_MS, DRAG_SETTLE_MS, VIEW
|
|
|
11
11
|
export { findScrollParent, scrollTargetIntoView, scrollTargetIntoViewInstant, } from "./dom/scroll.js";
|
|
12
12
|
// Data-attribute targeting contract
|
|
13
13
|
export { CURSOR_TARGET_ATTRIBUTE, SCROLL_TARGET_ATTRIBUTE, HOVER_STATE_ATTRIBUTE, DRAG_STATE_ATTRIBUTE, cursorTargetSelector, scrollTargetSelector, } from "./targeting/data-attributes.js";
|
|
14
|
+
// Embed postMessage protocol (v1) — the cross-origin iframe wire contract
|
|
15
|
+
export { SCENAR_EMBED_SOURCE, SCENAR_EMBED_PROTOCOL_VERSION, frameEmbedEvent, frameEmbedCommand, parseEmbedCommand, parseEmbedEvent, } from "./embed/protocol.js";
|
|
16
|
+
// Embed host controller — framework-free driver for an embedded player
|
|
17
|
+
export { createEmbedHostController } from "./embed/host-controller.js";
|
|
14
18
|
//# sourceMappingURL=index.js.map
|
package/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,4BAA4B;AAC5B,sEAAsE;AAgBtE,OAAO,EAAE,2BAA2B,EAAE,MAAM,yBAAyB,CAAC;AAItE,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAIrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAEhE,mBAAmB;AACnB,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,aAAa,EACb,cAAc,EACd,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAE/B,uBAAuB;AACvB,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,2BAA2B,GAC5B,MAAM,iBAAiB,CAAC;AAEzB,oCAAoC;AACpC,OAAO,EACL,uBAAuB,EACvB,uBAAuB,EACvB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,gCAAgC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,4BAA4B;AAC5B,sEAAsE;AAgBtE,OAAO,EAAE,2BAA2B,EAAE,MAAM,yBAAyB,CAAC;AAItE,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAIrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAEhE,mBAAmB;AACnB,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,aAAa,EACb,cAAc,EACd,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAE/B,uBAAuB;AACvB,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,2BAA2B,GAC5B,MAAM,iBAAiB,CAAC;AAEzB,oCAAoC;AACpC,OAAO,EACL,uBAAuB,EACvB,uBAAuB,EACvB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,gCAAgC,CAAC;AAExC,0EAA0E;AAC1E,OAAO,EACL,mBAAmB,EACnB,6BAA6B,EAC7B,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,GAChB,MAAM,qBAAqB,CAAC;AAQ7B,uEAAuE;AACvE,OAAO,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createEmbedHostController } from "./host-controller.js";
|
|
3
|
+
import {
|
|
4
|
+
SCENAR_EMBED_PROTOCOL_VERSION,
|
|
5
|
+
SCENAR_EMBED_SOURCE,
|
|
6
|
+
type ScenarEmbedEvent,
|
|
7
|
+
frameEmbedEvent,
|
|
8
|
+
} from "./protocol.js";
|
|
9
|
+
|
|
10
|
+
const EMBED_ORIGIN = "https://d-abc123.scenarusercontent.net";
|
|
11
|
+
|
|
12
|
+
let iframe: HTMLIFrameElement;
|
|
13
|
+
let post: ReturnType<typeof vi.spyOn>;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
iframe = document.createElement("iframe");
|
|
17
|
+
document.body.appendChild(iframe);
|
|
18
|
+
// An attached jsdom iframe has a real contentWindow; spy on its postMessage
|
|
19
|
+
// so command framing/target-origin are observable without a live navigation.
|
|
20
|
+
post = vi.spyOn(iframe.contentWindow as Window & typeof globalThis, "postMessage");
|
|
21
|
+
post.mockImplementation(() => {});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
iframe.remove();
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function dispatchFromEmbed(
|
|
30
|
+
data: unknown,
|
|
31
|
+
origin: string = EMBED_ORIGIN,
|
|
32
|
+
source: Window | null = iframe.contentWindow,
|
|
33
|
+
): void {
|
|
34
|
+
window.dispatchEvent(new MessageEvent("message", { data, origin, source }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("createEmbedHostController — commands", () => {
|
|
38
|
+
it("frames each command and posts it to the pinned embed origin", () => {
|
|
39
|
+
const controller = createEmbedHostController({ iframe, origin: EMBED_ORIGIN });
|
|
40
|
+
|
|
41
|
+
controller.play();
|
|
42
|
+
expect(post).toHaveBeenLastCalledWith(
|
|
43
|
+
{ source: SCENAR_EMBED_SOURCE, v: SCENAR_EMBED_PROTOCOL_VERSION, type: "play" },
|
|
44
|
+
EMBED_ORIGIN,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
controller.seek(1500);
|
|
48
|
+
expect(post).toHaveBeenLastCalledWith(
|
|
49
|
+
expect.objectContaining({ type: "seek", timeMs: 1500 }),
|
|
50
|
+
EMBED_ORIGIN,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
controller.setMuted(true);
|
|
54
|
+
expect(post).toHaveBeenLastCalledWith(
|
|
55
|
+
expect.objectContaining({ type: "setMuted", muted: true }),
|
|
56
|
+
EMBED_ORIGIN,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
controller.setVolume(0.25);
|
|
60
|
+
expect(post).toHaveBeenLastCalledWith(
|
|
61
|
+
expect.objectContaining({ type: "setVolume", volume: 0.25 }),
|
|
62
|
+
EMBED_ORIGIN,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
controller.destroy();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("createEmbedHostController — inbound events", () => {
|
|
70
|
+
it("relays well-formed events from the pinned embed", () => {
|
|
71
|
+
const onEvent = vi.fn<(event: ScenarEmbedEvent) => void>();
|
|
72
|
+
createEmbedHostController({ iframe, origin: EMBED_ORIGIN }, { onEvent });
|
|
73
|
+
|
|
74
|
+
dispatchFromEmbed(frameEmbedEvent({ type: "ready", totalSteps: 3, hasNarration: true }));
|
|
75
|
+
expect(onEvent).toHaveBeenCalledWith({ type: "ready", totalSteps: 3, hasNarration: true });
|
|
76
|
+
|
|
77
|
+
dispatchFromEmbed(frameEmbedEvent({ type: "progress", stepIndex: 1, totalSteps: 3, fraction: 0.5 }));
|
|
78
|
+
expect(onEvent).toHaveBeenLastCalledWith({
|
|
79
|
+
type: "progress",
|
|
80
|
+
stepIndex: 1,
|
|
81
|
+
totalSteps: 3,
|
|
82
|
+
fraction: 0.5,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("ignores events from a foreign origin", () => {
|
|
87
|
+
const onEvent = vi.fn<(event: ScenarEmbedEvent) => void>();
|
|
88
|
+
createEmbedHostController({ iframe, origin: EMBED_ORIGIN }, { onEvent });
|
|
89
|
+
|
|
90
|
+
dispatchFromEmbed(frameEmbedEvent({ type: "started" }), "https://evil.example");
|
|
91
|
+
expect(onEvent).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ignores events from a different source window", () => {
|
|
95
|
+
const onEvent = vi.fn<(event: ScenarEmbedEvent) => void>();
|
|
96
|
+
createEmbedHostController({ iframe, origin: EMBED_ORIGIN }, { onEvent });
|
|
97
|
+
|
|
98
|
+
// Correct origin but the message comes from the host window, not the iframe.
|
|
99
|
+
dispatchFromEmbed(frameEmbedEvent({ type: "started" }), EMBED_ORIGIN, window);
|
|
100
|
+
expect(onEvent).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("ignores malformed or foreign-protocol payloads", () => {
|
|
104
|
+
const onEvent = vi.fn<(event: ScenarEmbedEvent) => void>();
|
|
105
|
+
createEmbedHostController({ iframe, origin: EMBED_ORIGIN }, { onEvent });
|
|
106
|
+
|
|
107
|
+
dispatchFromEmbed({ source: "other-widget", v: 1, type: "ready", totalSteps: 1, hasNarration: false });
|
|
108
|
+
dispatchFromEmbed({ source: SCENAR_EMBED_SOURCE, v: 999, type: "started" });
|
|
109
|
+
dispatchFromEmbed({ hello: "world" });
|
|
110
|
+
dispatchFromEmbed("started");
|
|
111
|
+
expect(onEvent).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("createEmbedHostController — teardown", () => {
|
|
116
|
+
it("destroy() tells the embed to stop and detaches the listener", () => {
|
|
117
|
+
const onEvent = vi.fn<(event: ScenarEmbedEvent) => void>();
|
|
118
|
+
const controller = createEmbedHostController({ iframe, origin: EMBED_ORIGIN }, { onEvent });
|
|
119
|
+
|
|
120
|
+
controller.destroy();
|
|
121
|
+
expect(post).toHaveBeenLastCalledWith(
|
|
122
|
+
expect.objectContaining({ type: "destroy" }),
|
|
123
|
+
EMBED_ORIGIN,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
dispatchFromEmbed(frameEmbedEvent({ type: "started" }));
|
|
127
|
+
expect(onEvent).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ScenarEmbedCommand,
|
|
3
|
+
type ScenarEmbedEvent,
|
|
4
|
+
frameEmbedCommand,
|
|
5
|
+
parseEmbedEvent,
|
|
6
|
+
} from "./protocol.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The embed iframe this controller drives. Commands post to its
|
|
10
|
+
* `contentWindow`, read at send time because it changes across (re)loads.
|
|
11
|
+
*/
|
|
12
|
+
export interface ScenarEmbedHostTarget {
|
|
13
|
+
readonly iframe: HTMLIFrameElement;
|
|
14
|
+
/**
|
|
15
|
+
* The embed's exact origin — scheme + host + port, no path
|
|
16
|
+
* (e.g. `https://d-<deployId>.scenarusercontent.net`). Both inbound events
|
|
17
|
+
* and outbound commands are pinned to it. Derive it from the embed URL with
|
|
18
|
+
* `new URL(embedUrl).origin`.
|
|
19
|
+
*/
|
|
20
|
+
readonly origin: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ScenarEmbedHostOptions {
|
|
24
|
+
/** Invoked for every well-formed event received from the pinned embed. */
|
|
25
|
+
readonly onEvent?: (event: ScenarEmbedEvent) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Imperative handle for driving an embedded player from its host page. */
|
|
29
|
+
export interface ScenarEmbedHostController {
|
|
30
|
+
play(): void;
|
|
31
|
+
pause(): void;
|
|
32
|
+
seek(timeMs: number): void;
|
|
33
|
+
setMuted(muted: boolean): void;
|
|
34
|
+
setVolume(volume: number): void;
|
|
35
|
+
prefetch(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Tell the embed to stop (best effort) and detach the host message listener.
|
|
38
|
+
* The host still owns the iframe element; remove it separately for full
|
|
39
|
+
* teardown.
|
|
40
|
+
*/
|
|
41
|
+
destroy(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Drive an embedded {@link https://www.npmjs.com/package/@scenar/react | ScenarioPlayer}
|
|
46
|
+
* from its host page over the `scenar` embed postMessage protocol (v1).
|
|
47
|
+
*
|
|
48
|
+
* This is the exact inverse of `useScenarEmbedBridge` (the embed-side runtime in
|
|
49
|
+
* `@scenar/react`): it sends the commands that bridge receives and receives the
|
|
50
|
+
* events that bridge sends. It is framework-free so both the React console
|
|
51
|
+
* preview and a vanilla `embed.js` loader share one host implementation.
|
|
52
|
+
*
|
|
53
|
+
* Security: unlike the embed side — which cannot know its host's origin ahead of
|
|
54
|
+
* time and so pins on the first inbound message — the host already knows the
|
|
55
|
+
* embed's exact origin, so this controller pins from the start. An inbound
|
|
56
|
+
* message is accepted only when it both comes from this iframe's window
|
|
57
|
+
* (`event.source === iframe.contentWindow`) and carries the pinned `origin`.
|
|
58
|
+
* The global `message` channel is shared with the host page and every other
|
|
59
|
+
* widget, so both checks are required.
|
|
60
|
+
*/
|
|
61
|
+
export function createEmbedHostController(
|
|
62
|
+
target: ScenarEmbedHostTarget,
|
|
63
|
+
options: ScenarEmbedHostOptions = {},
|
|
64
|
+
): ScenarEmbedHostController {
|
|
65
|
+
const { iframe, origin } = target;
|
|
66
|
+
const { onEvent } = options;
|
|
67
|
+
const canListen = typeof window !== "undefined";
|
|
68
|
+
|
|
69
|
+
const send = (command: ScenarEmbedCommand): void => {
|
|
70
|
+
iframe.contentWindow?.postMessage(frameEmbedCommand(command), origin);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const onMessage = (event: MessageEvent): void => {
|
|
74
|
+
if (event.source !== iframe.contentWindow) return;
|
|
75
|
+
if (event.origin !== origin) return;
|
|
76
|
+
const parsed = parseEmbedEvent(event.data);
|
|
77
|
+
if (!parsed) return;
|
|
78
|
+
onEvent?.(parsed);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (canListen) window.addEventListener("message", onMessage);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
play: () => send({ type: "play" }),
|
|
85
|
+
pause: () => send({ type: "pause" }),
|
|
86
|
+
seek: (timeMs) => send({ type: "seek", timeMs }),
|
|
87
|
+
setMuted: (muted) => send({ type: "setMuted", muted }),
|
|
88
|
+
setVolume: (volume) => send({ type: "setVolume", volume }),
|
|
89
|
+
prefetch: () => send({ type: "prefetch" }),
|
|
90
|
+
destroy: () => {
|
|
91
|
+
send({ type: "destroy" });
|
|
92
|
+
if (canListen) window.removeEventListener("message", onMessage);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
SCENAR_EMBED_PROTOCOL_VERSION,
|
|
4
|
+
SCENAR_EMBED_SOURCE,
|
|
5
|
+
frameEmbedCommand,
|
|
6
|
+
frameEmbedEvent,
|
|
7
|
+
parseEmbedCommand,
|
|
8
|
+
parseEmbedEvent,
|
|
9
|
+
} from "./protocol.js";
|
|
10
|
+
|
|
11
|
+
describe("frameEmbedEvent / frameEmbedCommand", () => {
|
|
12
|
+
it("stamps the source and version envelope", () => {
|
|
13
|
+
const msg = frameEmbedEvent({ type: "started" });
|
|
14
|
+
expect(msg).toEqual({
|
|
15
|
+
source: SCENAR_EMBED_SOURCE,
|
|
16
|
+
v: SCENAR_EMBED_PROTOCOL_VERSION,
|
|
17
|
+
type: "started",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("preserves the command payload fields", () => {
|
|
22
|
+
const msg = frameEmbedCommand({ type: "seek", timeMs: 1500 });
|
|
23
|
+
expect(msg).toMatchObject({ source: SCENAR_EMBED_SOURCE, type: "seek", timeMs: 1500 });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("parseEmbedCommand", () => {
|
|
28
|
+
const wrap = (payload: Record<string, unknown>) => ({
|
|
29
|
+
source: SCENAR_EMBED_SOURCE,
|
|
30
|
+
v: SCENAR_EMBED_PROTOCOL_VERSION,
|
|
31
|
+
...payload,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("accepts well-formed parameterless commands", () => {
|
|
35
|
+
for (const type of ["play", "pause", "prefetch", "destroy"] as const) {
|
|
36
|
+
expect(parseEmbedCommand(wrap({ type }))).toEqual({ type });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("accepts seek with a finite timeMs", () => {
|
|
41
|
+
expect(parseEmbedCommand(wrap({ type: "seek", timeMs: 250 }))).toEqual({
|
|
42
|
+
type: "seek",
|
|
43
|
+
timeMs: 250,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("accepts setMuted with a boolean and setVolume with a number", () => {
|
|
48
|
+
expect(parseEmbedCommand(wrap({ type: "setMuted", muted: true }))).toEqual({
|
|
49
|
+
type: "setMuted",
|
|
50
|
+
muted: true,
|
|
51
|
+
});
|
|
52
|
+
expect(parseEmbedCommand(wrap({ type: "setVolume", volume: 0.4 }))).toEqual({
|
|
53
|
+
type: "setVolume",
|
|
54
|
+
volume: 0.4,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects a foreign source", () => {
|
|
59
|
+
expect(parseEmbedCommand({ source: "other-widget", v: 1, type: "play" })).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects a mismatched protocol version", () => {
|
|
63
|
+
expect(parseEmbedCommand({ source: SCENAR_EMBED_SOURCE, v: 999, type: "play" })).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects an unknown command type", () => {
|
|
67
|
+
expect(parseEmbedCommand(wrap({ type: "explode" }))).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects commands missing or mistyping required fields", () => {
|
|
71
|
+
expect(parseEmbedCommand(wrap({ type: "seek" }))).toBeNull();
|
|
72
|
+
expect(parseEmbedCommand(wrap({ type: "seek", timeMs: "soon" }))).toBeNull();
|
|
73
|
+
expect(parseEmbedCommand(wrap({ type: "seek", timeMs: Number.NaN }))).toBeNull();
|
|
74
|
+
expect(parseEmbedCommand(wrap({ type: "setMuted", muted: "yes" }))).toBeNull();
|
|
75
|
+
expect(parseEmbedCommand(wrap({ type: "setVolume" }))).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects non-object inputs", () => {
|
|
79
|
+
expect(parseEmbedCommand(null)).toBeNull();
|
|
80
|
+
expect(parseEmbedCommand("play")).toBeNull();
|
|
81
|
+
expect(parseEmbedCommand(42)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("parseEmbedEvent", () => {
|
|
86
|
+
const wrap = (payload: Record<string, unknown>) => ({
|
|
87
|
+
source: SCENAR_EMBED_SOURCE,
|
|
88
|
+
v: SCENAR_EMBED_PROTOCOL_VERSION,
|
|
89
|
+
...payload,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("round-trips an event through frame + parse", () => {
|
|
93
|
+
const framed = frameEmbedEvent({ type: "stepchange", stepIndex: 2, totalSteps: 5 });
|
|
94
|
+
expect(parseEmbedEvent(framed)).toEqual({ type: "stepchange", stepIndex: 2, totalSteps: 5 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("validates the ready payload", () => {
|
|
98
|
+
expect(parseEmbedEvent(wrap({ type: "ready", totalSteps: 3, hasNarration: true }))).toEqual({
|
|
99
|
+
type: "ready",
|
|
100
|
+
totalSteps: 3,
|
|
101
|
+
hasNarration: true,
|
|
102
|
+
});
|
|
103
|
+
expect(parseEmbedEvent(wrap({ type: "ready", totalSteps: 3 }))).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects foreign source and unknown event types", () => {
|
|
107
|
+
expect(parseEmbedEvent({ source: "x", v: 1, type: "started" })).toBeNull();
|
|
108
|
+
expect(parseEmbedEvent(wrap({ type: "imploded" }))).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `scenar` embed postMessage protocol (v1).
|
|
3
|
+
*
|
|
4
|
+
* A packed scenario is delivered as a cross-origin iframe. This module is the
|
|
5
|
+
* wire contract between the embedded player and its host page: the events the
|
|
6
|
+
* player emits and the commands the host may send. It is pure (no React, no
|
|
7
|
+
* DOM) and lives in `@scenar/core` so every surface shares one definition — the
|
|
8
|
+
* React embed-side runtime (`useScenarEmbedBridge` in `@scenar/react`), the
|
|
9
|
+
* framework-free host driver ({@link createEmbedHostController}), and any future
|
|
10
|
+
* host loader (`embed.js` / oEmbed).
|
|
11
|
+
*
|
|
12
|
+
* Every message is stamped with a fixed {@link SCENAR_EMBED_SOURCE} and the
|
|
13
|
+
* protocol {@link SCENAR_EMBED_PROTOCOL_VERSION}. Receivers MUST ignore any
|
|
14
|
+
* message whose `source`/`v` do not match — the global `message` channel is
|
|
15
|
+
* shared with the host and every other framed widget on the page.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Discriminator stamped on every scenar embed message. */
|
|
19
|
+
export const SCENAR_EMBED_SOURCE = "scenar-embed";
|
|
20
|
+
|
|
21
|
+
/** Protocol version. Bump only on a breaking change to the message shapes. */
|
|
22
|
+
export const SCENAR_EMBED_PROTOCOL_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Events the embedded player emits to the host (embed -> host).
|
|
26
|
+
*
|
|
27
|
+
* - `ready` — the player has mounted; carries scenario shape the host can use
|
|
28
|
+
* to size or label the frame.
|
|
29
|
+
* - `resize` — the rendered content height changed (dynamic-height hosts).
|
|
30
|
+
* - `started` / `paused` / `completed` — transport state transitions.
|
|
31
|
+
* - `stepchange` — the active step changed.
|
|
32
|
+
* - `progress` — fine-grained playback position (throttled by the emitter).
|
|
33
|
+
* - `audioBlocked` — the browser blocked narration audio; the host may surface
|
|
34
|
+
* its own affordance or call `setMuted`.
|
|
35
|
+
* - `error` — an unrecoverable runtime error, with a human-readable message.
|
|
36
|
+
*/
|
|
37
|
+
export type ScenarEmbedEvent =
|
|
38
|
+
| { readonly type: "ready"; readonly totalSteps: number; readonly hasNarration: boolean }
|
|
39
|
+
| { readonly type: "resize"; readonly widthPx: number; readonly heightPx: number }
|
|
40
|
+
| { readonly type: "started" }
|
|
41
|
+
| { readonly type: "paused" }
|
|
42
|
+
| { readonly type: "stepchange"; readonly stepIndex: number; readonly totalSteps: number }
|
|
43
|
+
| {
|
|
44
|
+
readonly type: "progress";
|
|
45
|
+
readonly stepIndex: number;
|
|
46
|
+
readonly totalSteps: number;
|
|
47
|
+
readonly fraction: number;
|
|
48
|
+
}
|
|
49
|
+
| { readonly type: "completed" }
|
|
50
|
+
| { readonly type: "audioBlocked" }
|
|
51
|
+
| { readonly type: "error"; readonly message: string };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Commands the host may send to the embedded player (host -> embed).
|
|
55
|
+
*
|
|
56
|
+
* State-setting commands (`setMuted`, `setVolume`) are idempotent by design so
|
|
57
|
+
* a host never has to track the player's internal state to stay in sync.
|
|
58
|
+
*/
|
|
59
|
+
export type ScenarEmbedCommand =
|
|
60
|
+
| { readonly type: "play" }
|
|
61
|
+
| { readonly type: "pause" }
|
|
62
|
+
| { readonly type: "seek"; readonly timeMs: number }
|
|
63
|
+
| { readonly type: "setMuted"; readonly muted: boolean }
|
|
64
|
+
| { readonly type: "setVolume"; readonly volume: number }
|
|
65
|
+
| { readonly type: "prefetch" }
|
|
66
|
+
| { readonly type: "destroy" };
|
|
67
|
+
|
|
68
|
+
/** Envelope fields stamped on every message of either direction. */
|
|
69
|
+
interface ScenarEmbedEnvelope {
|
|
70
|
+
readonly source: typeof SCENAR_EMBED_SOURCE;
|
|
71
|
+
readonly v: typeof SCENAR_EMBED_PROTOCOL_VERSION;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A fully-framed event message as it travels over `postMessage`. */
|
|
75
|
+
export type ScenarEmbedEventMessage = ScenarEmbedEvent & ScenarEmbedEnvelope;
|
|
76
|
+
|
|
77
|
+
/** A fully-framed command message as it travels over `postMessage`. */
|
|
78
|
+
export type ScenarEmbedCommandMessage = ScenarEmbedCommand & ScenarEmbedEnvelope;
|
|
79
|
+
|
|
80
|
+
/** Stamp an event with the source + version envelope, ready to post. */
|
|
81
|
+
export function frameEmbedEvent(event: ScenarEmbedEvent): ScenarEmbedEventMessage {
|
|
82
|
+
return { source: SCENAR_EMBED_SOURCE, v: SCENAR_EMBED_PROTOCOL_VERSION, ...event };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stamp a command with the source + version envelope, ready to post. */
|
|
86
|
+
export function frameEmbedCommand(command: ScenarEmbedCommand): ScenarEmbedCommandMessage {
|
|
87
|
+
return { source: SCENAR_EMBED_SOURCE, v: SCENAR_EMBED_PROTOCOL_VERSION, ...command };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isScenarEnvelope(data: unknown): data is Record<string, unknown> & ScenarEmbedEnvelope {
|
|
91
|
+
if (typeof data !== "object" || data === null) return false;
|
|
92
|
+
const record = data as Record<string, unknown>;
|
|
93
|
+
return (
|
|
94
|
+
record["source"] === SCENAR_EMBED_SOURCE && record["v"] === SCENAR_EMBED_PROTOCOL_VERSION
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
99
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse an inbound `MessageEvent.data` into a typed command, or return `null`.
|
|
104
|
+
*
|
|
105
|
+
* This is the schema boundary: it rejects anything that is not a well-formed
|
|
106
|
+
* scenar command of the matching version, including unknown command types and
|
|
107
|
+
* commands missing or mistyping their required fields. Origin/source-window
|
|
108
|
+
* checks live in the receiver; this function validates the payload shape only.
|
|
109
|
+
*/
|
|
110
|
+
export function parseEmbedCommand(data: unknown): ScenarEmbedCommand | null {
|
|
111
|
+
if (!isScenarEnvelope(data)) return null;
|
|
112
|
+
const type = data["type"];
|
|
113
|
+
switch (type) {
|
|
114
|
+
case "play":
|
|
115
|
+
case "pause":
|
|
116
|
+
case "prefetch":
|
|
117
|
+
case "destroy":
|
|
118
|
+
return { type };
|
|
119
|
+
case "seek":
|
|
120
|
+
return isFiniteNumber(data["timeMs"]) ? { type, timeMs: data["timeMs"] } : null;
|
|
121
|
+
case "setMuted":
|
|
122
|
+
return typeof data["muted"] === "boolean" ? { type, muted: data["muted"] } : null;
|
|
123
|
+
case "setVolume":
|
|
124
|
+
return isFiniteNumber(data["volume"]) ? { type, volume: data["volume"] } : null;
|
|
125
|
+
default:
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse an inbound `MessageEvent.data` into a typed event, or return `null`.
|
|
132
|
+
*
|
|
133
|
+
* The symmetric counterpart to {@link parseEmbedCommand} for host-side code
|
|
134
|
+
* consuming the player's events.
|
|
135
|
+
*/
|
|
136
|
+
export function parseEmbedEvent(data: unknown): ScenarEmbedEvent | null {
|
|
137
|
+
if (!isScenarEnvelope(data)) return null;
|
|
138
|
+
const type = data["type"];
|
|
139
|
+
switch (type) {
|
|
140
|
+
case "started":
|
|
141
|
+
case "paused":
|
|
142
|
+
case "completed":
|
|
143
|
+
case "audioBlocked":
|
|
144
|
+
return { type };
|
|
145
|
+
case "ready":
|
|
146
|
+
return isFiniteNumber(data["totalSteps"]) && typeof data["hasNarration"] === "boolean"
|
|
147
|
+
? { type, totalSteps: data["totalSteps"], hasNarration: data["hasNarration"] }
|
|
148
|
+
: null;
|
|
149
|
+
case "resize":
|
|
150
|
+
return isFiniteNumber(data["widthPx"]) && isFiniteNumber(data["heightPx"])
|
|
151
|
+
? { type, widthPx: data["widthPx"], heightPx: data["heightPx"] }
|
|
152
|
+
: null;
|
|
153
|
+
case "stepchange":
|
|
154
|
+
return isFiniteNumber(data["stepIndex"]) && isFiniteNumber(data["totalSteps"])
|
|
155
|
+
? { type, stepIndex: data["stepIndex"], totalSteps: data["totalSteps"] }
|
|
156
|
+
: null;
|
|
157
|
+
case "progress":
|
|
158
|
+
return isFiniteNumber(data["stepIndex"]) &&
|
|
159
|
+
isFiniteNumber(data["totalSteps"]) &&
|
|
160
|
+
isFiniteNumber(data["fraction"])
|
|
161
|
+
? {
|
|
162
|
+
type,
|
|
163
|
+
stepIndex: data["stepIndex"],
|
|
164
|
+
totalSteps: data["totalSteps"],
|
|
165
|
+
fraction: data["fraction"],
|
|
166
|
+
}
|
|
167
|
+
: null;
|
|
168
|
+
case "error":
|
|
169
|
+
return typeof data["message"] === "string" ? { type, message: data["message"] } : null;
|
|
170
|
+
default:
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -52,3 +52,27 @@ export {
|
|
|
52
52
|
cursorTargetSelector,
|
|
53
53
|
scrollTargetSelector,
|
|
54
54
|
} from "./targeting/data-attributes.js";
|
|
55
|
+
|
|
56
|
+
// Embed postMessage protocol (v1) — the cross-origin iframe wire contract
|
|
57
|
+
export {
|
|
58
|
+
SCENAR_EMBED_SOURCE,
|
|
59
|
+
SCENAR_EMBED_PROTOCOL_VERSION,
|
|
60
|
+
frameEmbedEvent,
|
|
61
|
+
frameEmbedCommand,
|
|
62
|
+
parseEmbedCommand,
|
|
63
|
+
parseEmbedEvent,
|
|
64
|
+
} from "./embed/protocol.js";
|
|
65
|
+
export type {
|
|
66
|
+
ScenarEmbedEvent,
|
|
67
|
+
ScenarEmbedCommand,
|
|
68
|
+
ScenarEmbedEventMessage,
|
|
69
|
+
ScenarEmbedCommandMessage,
|
|
70
|
+
} from "./embed/protocol.js";
|
|
71
|
+
|
|
72
|
+
// Embed host controller — framework-free driver for an embedded player
|
|
73
|
+
export { createEmbedHostController } from "./embed/host-controller.js";
|
|
74
|
+
export type {
|
|
75
|
+
ScenarEmbedHostController,
|
|
76
|
+
ScenarEmbedHostTarget,
|
|
77
|
+
ScenarEmbedHostOptions,
|
|
78
|
+
} from "./embed/host-controller.js";
|