@scenar/core 0.1.19 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenar/core",
3
- "version": "0.1.19",
3
+ "version": "0.1.20-rc.1",
4
4
  "description": "Pure TypeScript types, timing, and utilities for the Scenar scenario engine.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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";