@appstrata/protocol 0.1.0
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 +106 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +5 -0
- package/dist/client/proxy.d.ts +72 -0
- package/dist/client/proxy.d.ts.map +1 -0
- package/dist/client/proxy.js +446 -0
- package/dist/client/rpc.d.ts +62 -0
- package/dist/client/rpc.d.ts.map +1 -0
- package/dist/client/rpc.js +138 -0
- package/dist/host/index.d.ts +5 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +4 -0
- package/dist/host/message-bridge.d.ts +146 -0
- package/dist/host/message-bridge.d.ts.map +1 -0
- package/dist/host/message-bridge.js +360 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/messages.d.ts +197 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +141 -0
- package/dist/transport/http/client-polling.d.ts +32 -0
- package/dist/transport/http/client-polling.d.ts.map +1 -0
- package/dist/transport/http/client-polling.js +181 -0
- package/dist/transport/http/client-sse.d.ts +30 -0
- package/dist/transport/http/client-sse.d.ts.map +1 -0
- package/dist/transport/http/client-sse.js +155 -0
- package/dist/transport/http/host-manager.d.ts +63 -0
- package/dist/transport/http/host-manager.d.ts.map +1 -0
- package/dist/transport/http/host-manager.js +74 -0
- package/dist/transport/http/host-polling.d.ts +65 -0
- package/dist/transport/http/host-polling.d.ts.map +1 -0
- package/dist/transport/http/host-polling.js +143 -0
- package/dist/transport/http/host-sse.d.ts +56 -0
- package/dist/transport/http/host-sse.d.ts.map +1 -0
- package/dist/transport/http/host-sse.js +149 -0
- package/dist/transport/http/index.d.ts +13 -0
- package/dist/transport/http/index.d.ts.map +1 -0
- package/dist/transport/http/index.js +12 -0
- package/dist/transport/http/types.d.ts +73 -0
- package/dist/transport/http/types.d.ts.map +1 -0
- package/dist/transport/http/types.js +8 -0
- package/dist/transport/index.d.ts +33 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +37 -0
- package/dist/transport/postMessage.d.ts +70 -0
- package/dist/transport/postMessage.d.ts.map +1 -0
- package/dist/transport/postMessage.js +94 -0
- package/dist/transport/relay.d.ts +118 -0
- package/dist/transport/relay.d.ts.map +1 -0
- package/dist/transport/relay.js +216 -0
- package/dist/transport/types.d.ts +30 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +6 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @appstrata/protocol
|
|
2
|
+
|
|
3
|
+
Protocol implementation for AppStrata Digital Signage SDK.
|
|
4
|
+
|
|
5
|
+
This package contains the complete protocol implementation used by both app-side (`@appstrata/web`) and host-side (`@appstrata/player-lib`) SDKs.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- **Messages**: Protocol message types (HELLO, READY, REQUEST, RESPONSE, EVENT)
|
|
10
|
+
- **Transport**: Message delivery abstraction
|
|
11
|
+
- **Client**: App-side protocol implementation (ProxyPlayer, RpcClient)
|
|
12
|
+
- **Host**: Player-side protocol implementation (MessageBridge)
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
> **Note:** This is a private npm package. Requires an npm access token — contact the AppStrata team to get one, then add it to your `~/.npmrc`:
|
|
17
|
+
>
|
|
18
|
+
> ```
|
|
19
|
+
> //registry.npmjs.org/:_authToken=YOUR_TOKEN
|
|
20
|
+
> ```
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @appstrata/protocol
|
|
24
|
+
# or
|
|
25
|
+
pnpm add @appstrata/protocol
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
This is a low-level package. Most developers should use:
|
|
31
|
+
- `@appstrata/web` for building apps
|
|
32
|
+
- `@appstrata/player-lib` for building players
|
|
33
|
+
|
|
34
|
+
### For Advanced Use Cases
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { ProxyPlayer, createAppTransport } from "@appstrata/protocol";
|
|
38
|
+
|
|
39
|
+
// App side
|
|
40
|
+
const transport = createAppTransport(); // uses window.parent.postMessage
|
|
41
|
+
const player = new ProxyPlayer(transport);
|
|
42
|
+
|
|
43
|
+
player.onInit((context) => {
|
|
44
|
+
console.log("App initialized:", context);
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { MessageBridge, createPostMessageTransport } from "@appstrata/protocol";
|
|
50
|
+
|
|
51
|
+
// Host side
|
|
52
|
+
const transport = createPostMessageTransport({
|
|
53
|
+
targetWindow: iframe.contentWindow!,
|
|
54
|
+
targetOrigin: "*",
|
|
55
|
+
});
|
|
56
|
+
const bridge = new MessageBridge({
|
|
57
|
+
transport,
|
|
58
|
+
player: myPlayer,
|
|
59
|
+
getContext: async () => buildAppContext(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Start handshake
|
|
63
|
+
bridge.start();
|
|
64
|
+
|
|
65
|
+
// Fire lifecycle events
|
|
66
|
+
bridge.fireShow();
|
|
67
|
+
bridge.fireStart();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Relay Transport (Advanced)
|
|
71
|
+
|
|
72
|
+
For scenarios where you need to bridge between different transport mechanisms:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { createRelayTransport, createPostMessageTransport, createHttpTransport } from "@appstrata/protocol";
|
|
76
|
+
|
|
77
|
+
// Relay between local app (postMessage) and remote host (HTTP)
|
|
78
|
+
const relay = createRelayTransport({
|
|
79
|
+
appTransport: createPostMessageTransport({
|
|
80
|
+
targetWindow: iframe.contentWindow,
|
|
81
|
+
targetOrigin: "*"
|
|
82
|
+
}),
|
|
83
|
+
hostTransport: createHttpTransport({
|
|
84
|
+
sendUrl: "https://player.example.com/api/send",
|
|
85
|
+
receiveUrl: "https://player.example.com/api/receive",
|
|
86
|
+
sessionId: "abc-123"
|
|
87
|
+
}),
|
|
88
|
+
debug: true
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Relay automatically forwards messages between transports
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Architecture
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
@appstrata/protocol
|
|
98
|
+
├── messages - Protocol message types and utilities
|
|
99
|
+
├── transport - Transport layer
|
|
100
|
+
├── client - App-side (ProxyPlayer, RpcClient)
|
|
101
|
+
└── host - Player-side (MessageBridge)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProxyPlayer - App-side SignagePlayer implementation over the message protocol.
|
|
3
|
+
*
|
|
4
|
+
* Uses the message protocol for handshake and RPC-based capability operations.
|
|
5
|
+
*/
|
|
6
|
+
import { LifecyclePhase, type SignagePlayer, type AppContext, type Capability, type LogLevel, type EstimatedEndOptions, type NoContentOptions, type StorageApi, type ProxyApi, type MediaCacheApi, type StaticApi, type InteractionApi } from "@appstrata/core";
|
|
7
|
+
import type { Transport } from "../transport/index.js";
|
|
8
|
+
/**
|
|
9
|
+
* ProxyPlayer - App-side SignagePlayer that communicates with the Host via the message protocol.
|
|
10
|
+
*
|
|
11
|
+
* Handles the HELLO/READY/READY_ACK handshake and uses RPC for capability operations.
|
|
12
|
+
*/
|
|
13
|
+
export declare class ProxyPlayer implements SignagePlayer {
|
|
14
|
+
readonly apiVersion = "1.0.0";
|
|
15
|
+
lifecyclePhase: LifecyclePhase;
|
|
16
|
+
private transport;
|
|
17
|
+
private rpc;
|
|
18
|
+
private context;
|
|
19
|
+
private readyPromise;
|
|
20
|
+
private resolveReady;
|
|
21
|
+
private isReady;
|
|
22
|
+
private _terminated;
|
|
23
|
+
private _startTime;
|
|
24
|
+
private _pendingTotalDuration;
|
|
25
|
+
private initHandlers;
|
|
26
|
+
private showHandlers;
|
|
27
|
+
private startHandlers;
|
|
28
|
+
private hideHandlers;
|
|
29
|
+
private stopHandlers;
|
|
30
|
+
private contextChangeHandlers;
|
|
31
|
+
private interactionHandlers;
|
|
32
|
+
/**
|
|
33
|
+
* Create a ProxyPlayer instance.
|
|
34
|
+
*
|
|
35
|
+
* @param transport - Transport for sending/receiving messages
|
|
36
|
+
*/
|
|
37
|
+
constructor(transport: Transport);
|
|
38
|
+
/**
|
|
39
|
+
* Send the HELLO message to initiate the handshake.
|
|
40
|
+
*/
|
|
41
|
+
private sendHello;
|
|
42
|
+
/**
|
|
43
|
+
* Handle READY message from host.
|
|
44
|
+
*/
|
|
45
|
+
private handleReady;
|
|
46
|
+
/**
|
|
47
|
+
* Handle EVENT message from host.
|
|
48
|
+
*/
|
|
49
|
+
private handleEvent;
|
|
50
|
+
private fireLifecycleHandlers;
|
|
51
|
+
private fireContextHandlers;
|
|
52
|
+
private fireInteractionHandlers;
|
|
53
|
+
getContext(): Promise<AppContext>;
|
|
54
|
+
hasCapability(capability: Capability): Promise<boolean>;
|
|
55
|
+
onInit(handler: (context: AppContext) => void): void;
|
|
56
|
+
onShow(handler: () => void): void;
|
|
57
|
+
onStart(handler: () => void): void;
|
|
58
|
+
onHide(handler: () => void): void;
|
|
59
|
+
onStop(handler: () => void): void;
|
|
60
|
+
private invokeLateSubscriber;
|
|
61
|
+
onContextChange(handler: (context: AppContext) => void): void;
|
|
62
|
+
notifyComplete(): void;
|
|
63
|
+
notifyEstimatedEnd(options: EstimatedEndOptions): void;
|
|
64
|
+
notifyNoContent(options?: NoContentOptions): void;
|
|
65
|
+
get storage(): StorageApi | undefined;
|
|
66
|
+
get proxy(): ProxyApi | undefined;
|
|
67
|
+
get mediaCache(): MediaCacheApi | undefined;
|
|
68
|
+
get static(): StaticApi | undefined;
|
|
69
|
+
get interaction(): InteractionApi | undefined;
|
|
70
|
+
log(level: LogLevel, message: string, data?: unknown): void;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=proxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/client/proxy.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,cAAc,EAEd,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,QAAQ,EACb,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,QAAQ,EAIb,KAAK,aAAa,EAGlB,KAAK,SAAS,EACd,KAAK,cAAc,EAEpB,MAAM,iBAAiB,CAAC;AAWzB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAavD;;;;GAIG;AACH,qBAAa,WAAY,YAAW,aAAa;IAC/C,QAAQ,CAAC,UAAU,WAAW;IAC9B,cAAc,EAAE,cAAc,CAAuB;IAErD,OAAO,CAAC,SAAS,CAAY;IAC7B,OAAO,CAAC,GAAG,CAAY;IAGvB,OAAO,CAAC,OAAO,CAA2B;IAC1C,OAAO,CAAC,YAAY,CAAgB;IACpC,OAAO,CAAC,YAAY,CAAc;IAClC,OAAO,CAAC,OAAO,CAAS;IAGxB,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,qBAAqB,CAAuB;IAGpD,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,YAAY,CAAyB;IAC7C,OAAO,CAAC,aAAa,CAAyB;IAC9C,OAAO,CAAC,YAAY,CAAyB;IAC7C,OAAO,CAAC,YAAY,CAAyB;IAC7C,OAAO,CAAC,qBAAqB,CAA4C;IACzE,OAAO,CAAC,mBAAmB,CAAgD;IAE3E;;;;OAIG;gBACS,SAAS,EAAE,SAAS;IAuChC;;OAEG;IACH,OAAO,CAAC,SAAS;IAmBjB;;OAEG;IACH,OAAO,CAAC,WAAW;IAgCnB;;OAEG;IACH,OAAO,CAAC,WAAW;IAqCnB,OAAO,CAAC,qBAAqB;IAU7B,OAAO,CAAC,mBAAmB;IAU3B,OAAO,CAAC,uBAAuB;IAczB,UAAU,IAAI,OAAO,CAAC,UAAU,CAAC;IAKjC,aAAa,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC;IAS7D,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI;IAcpD,MAAM,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAOjC,OAAO,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAOlC,MAAM,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAOjC,MAAM,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAOjC,OAAO,CAAC,oBAAoB;IAY5B,eAAe,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI;IAQ7D,cAAc,IAAI,IAAI;IAStB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI;IAkCtD,eAAe,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,IAAI;IA+BjD,IAAI,OAAO,IAAI,UAAU,GAAG,SAAS,CAwBpC;IAED,IAAI,KAAK,IAAI,QAAQ,GAAG,SAAS,CAqBhC;IAED,IAAI,UAAU,IAAI,aAAa,GAAG,SAAS,CA8B1C;IAED,IAAI,MAAM,IAAI,SAAS,GAAG,SAAS,CAoBlC;IAED,IAAI,WAAW,IAAI,cAAc,GAAG,SAAS,CAW5C;IAMD,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,IAAI;CAI5D"}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProxyPlayer - App-side SignagePlayer implementation over the message protocol.
|
|
3
|
+
*
|
|
4
|
+
* Uses the message protocol for handshake and RPC-based capability operations.
|
|
5
|
+
*/
|
|
6
|
+
import { LifecyclePhase, createLogger } from "@appstrata/core";
|
|
7
|
+
const PHASE_ORDER = {
|
|
8
|
+
[LifecyclePhase.None]: 0,
|
|
9
|
+
[LifecyclePhase.Init]: 1,
|
|
10
|
+
[LifecyclePhase.Show]: 2,
|
|
11
|
+
[LifecyclePhase.Start]: 3,
|
|
12
|
+
[LifecyclePhase.Hide]: 4,
|
|
13
|
+
[LifecyclePhase.Stop]: 5,
|
|
14
|
+
};
|
|
15
|
+
import { RpcClient } from "./rpc.js";
|
|
16
|
+
const logger = createLogger("ProxyPlayer");
|
|
17
|
+
import { wireContextToAppContext, isReadyMessage, isEventMessage, generateRequestId, } from "../messages.js";
|
|
18
|
+
/**
|
|
19
|
+
* ProxyPlayer - App-side SignagePlayer that communicates with the Host via the message protocol.
|
|
20
|
+
*
|
|
21
|
+
* Handles the HELLO/READY/READY_ACK handshake and uses RPC for capability operations.
|
|
22
|
+
*/
|
|
23
|
+
export class ProxyPlayer {
|
|
24
|
+
/**
|
|
25
|
+
* Create a ProxyPlayer instance.
|
|
26
|
+
*
|
|
27
|
+
* @param transport - Transport for sending/receiving messages
|
|
28
|
+
*/
|
|
29
|
+
constructor(transport) {
|
|
30
|
+
this.apiVersion = "1.0.0";
|
|
31
|
+
this.lifecyclePhase = LifecyclePhase.None;
|
|
32
|
+
// Context state
|
|
33
|
+
this.context = null;
|
|
34
|
+
this.isReady = false;
|
|
35
|
+
// Playback control state
|
|
36
|
+
this._terminated = false;
|
|
37
|
+
this._startTime = null;
|
|
38
|
+
this._pendingTotalDuration = null;
|
|
39
|
+
// Lifecycle handlers
|
|
40
|
+
this.initHandlers = new Set();
|
|
41
|
+
this.showHandlers = new Set();
|
|
42
|
+
this.startHandlers = new Set();
|
|
43
|
+
this.hideHandlers = new Set();
|
|
44
|
+
this.stopHandlers = new Set();
|
|
45
|
+
this.contextChangeHandlers = new Set();
|
|
46
|
+
this.interactionHandlers = new Set();
|
|
47
|
+
this.transport = transport;
|
|
48
|
+
// Create ready promise
|
|
49
|
+
this.readyPromise = new Promise((resolve) => {
|
|
50
|
+
this.resolveReady = resolve;
|
|
51
|
+
});
|
|
52
|
+
// RPC client gates calls on readyPromise (spec §2.2)
|
|
53
|
+
this.rpc = new RpcClient(transport, this.readyPromise);
|
|
54
|
+
// Install message listener immediately so we're ready to receive READY
|
|
55
|
+
this.transport.onMessage((msg) => {
|
|
56
|
+
if (isReadyMessage(msg)) {
|
|
57
|
+
logger.debug(`↓ Received READY (requestId=${msg.requestId}, timestamp=${msg.timestamp})`);
|
|
58
|
+
this.handleReady(msg);
|
|
59
|
+
}
|
|
60
|
+
else if (isEventMessage(msg)) {
|
|
61
|
+
// Spec §2.2: Apps MUST ignore EVENT messages received before READY
|
|
62
|
+
if (!this.isReady) {
|
|
63
|
+
logger.debug("↓ Ignoring EVENT before READY");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
logger.debug(`↓ Received EVENT: ${msg.name} (timestamp=${msg.timestamp})`);
|
|
67
|
+
this.handleEvent(msg);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
// Defer HELLO by one microtask so synchronous handler registrations
|
|
71
|
+
// (e.g. getPlayer().onStart(...)) complete before the handshake begins.
|
|
72
|
+
// With sync transports this prevents the entire handshake from completing inside
|
|
73
|
+
// the constructor (even though send READY is already asynchronous but this adds
|
|
74
|
+
// an extra safety measure).
|
|
75
|
+
queueMicrotask(() => this.sendHello());
|
|
76
|
+
}
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
78
|
+
// HANDSHAKE
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
80
|
+
/**
|
|
81
|
+
* Send the HELLO message to initiate the handshake.
|
|
82
|
+
*/
|
|
83
|
+
sendHello() {
|
|
84
|
+
if (this.isReady) {
|
|
85
|
+
logger.debug("Skipping HELLO — handshake already completed via proactive READY");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const hello = {
|
|
89
|
+
protocol: "appstrata-protocol",
|
|
90
|
+
version: "1.0",
|
|
91
|
+
type: "HELLO",
|
|
92
|
+
timestamp: Date.now(),
|
|
93
|
+
requestId: generateRequestId(),
|
|
94
|
+
};
|
|
95
|
+
logger.debug(`↑ Sending HELLO (requestId=${hello.requestId}, timestamp=${hello.timestamp})`);
|
|
96
|
+
this.transport.send(hello);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Handle READY message from host.
|
|
100
|
+
*/
|
|
101
|
+
handleReady(msg) {
|
|
102
|
+
// Convert wire format to AppContext
|
|
103
|
+
this.context = wireContextToAppContext(msg.context);
|
|
104
|
+
// Send READY_ACK, always echoing requestId from READY
|
|
105
|
+
const requestId = msg.requestId;
|
|
106
|
+
const ack = {
|
|
107
|
+
protocol: "appstrata-protocol",
|
|
108
|
+
version: "1.0",
|
|
109
|
+
type: "READY_ACK",
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
requestId,
|
|
112
|
+
};
|
|
113
|
+
logger.debug(`↑ Sending READY_ACK (requestId=${requestId}, timestamp=${ack.timestamp})`);
|
|
114
|
+
// Set isReady BEFORE sending READY_ACK so that EVENT messages flushed
|
|
115
|
+
// synchronously by the host in response to READY_ACK are not dropped
|
|
116
|
+
// by the isReady guard (in case the transport is synchronous).
|
|
117
|
+
const firstReady = !this.isReady;
|
|
118
|
+
this.isReady = true;
|
|
119
|
+
this.lifecyclePhase = LifecyclePhase.Init;
|
|
120
|
+
this.transport.send(ack);
|
|
121
|
+
if (firstReady) {
|
|
122
|
+
this.resolveReady();
|
|
123
|
+
// Fire onInit handlers (late subscriber pattern)
|
|
124
|
+
this.fireContextHandlers(this.initHandlers, this.context);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Handle EVENT message from host.
|
|
129
|
+
*/
|
|
130
|
+
handleEvent(msg) {
|
|
131
|
+
const eventName = msg.name;
|
|
132
|
+
const suppressible = this._terminated && this.context?.duration == null;
|
|
133
|
+
if (eventName === "show") {
|
|
134
|
+
if (suppressible)
|
|
135
|
+
return;
|
|
136
|
+
this.lifecyclePhase = LifecyclePhase.Show;
|
|
137
|
+
this.fireLifecycleHandlers(this.showHandlers);
|
|
138
|
+
}
|
|
139
|
+
else if (eventName === "start") {
|
|
140
|
+
if (suppressible)
|
|
141
|
+
return;
|
|
142
|
+
this.lifecyclePhase = LifecyclePhase.Start;
|
|
143
|
+
this._startTime = Date.now();
|
|
144
|
+
if (this._pendingTotalDuration != null) {
|
|
145
|
+
const expectedFinishTime = this._startTime + this._pendingTotalDuration * 1000;
|
|
146
|
+
this._pendingTotalDuration = null;
|
|
147
|
+
this.rpc.notify("notifyEstimatedEnd", { expectedFinishTime });
|
|
148
|
+
}
|
|
149
|
+
this.fireLifecycleHandlers(this.startHandlers);
|
|
150
|
+
}
|
|
151
|
+
else if (eventName === "hide") {
|
|
152
|
+
if (suppressible && PHASE_ORDER[this.lifecyclePhase] < PHASE_ORDER[LifecyclePhase.Show])
|
|
153
|
+
return;
|
|
154
|
+
this.lifecyclePhase = LifecyclePhase.Hide;
|
|
155
|
+
this.fireLifecycleHandlers(this.hideHandlers);
|
|
156
|
+
}
|
|
157
|
+
else if (eventName === "stop") {
|
|
158
|
+
this.lifecyclePhase = LifecyclePhase.Stop;
|
|
159
|
+
this.fireLifecycleHandlers(this.stopHandlers);
|
|
160
|
+
}
|
|
161
|
+
else if (eventName === "contextChange") {
|
|
162
|
+
const newContext = wireContextToAppContext(msg.payload);
|
|
163
|
+
this.context = newContext;
|
|
164
|
+
this.fireContextHandlers(this.contextChangeHandlers, newContext);
|
|
165
|
+
}
|
|
166
|
+
else if (eventName === "interaction") {
|
|
167
|
+
// Interaction event
|
|
168
|
+
const interactionEvent = msg.payload;
|
|
169
|
+
this.fireInteractionHandlers(this.interactionHandlers, interactionEvent);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
fireLifecycleHandlers(handlers) {
|
|
173
|
+
handlers.forEach((handler) => {
|
|
174
|
+
try {
|
|
175
|
+
handler();
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
logger.error("Error in handler", error);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
fireContextHandlers(handlers, context) {
|
|
183
|
+
handlers.forEach((handler) => {
|
|
184
|
+
try {
|
|
185
|
+
handler(context);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
logger.error("Error in handler", error);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
fireInteractionHandlers(handlers, event) {
|
|
193
|
+
handlers.forEach((handler) => {
|
|
194
|
+
try {
|
|
195
|
+
handler(event);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
logger.error("Error in interaction handler", error);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
203
|
+
// CONTEXT ACCESS (async - waits for READY)
|
|
204
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
205
|
+
async getContext() {
|
|
206
|
+
await this.readyPromise;
|
|
207
|
+
return this.context;
|
|
208
|
+
}
|
|
209
|
+
async hasCapability(capability) {
|
|
210
|
+
const context = await this.getContext();
|
|
211
|
+
return context.hasCapability(capability);
|
|
212
|
+
}
|
|
213
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
214
|
+
// LIFECYCLE
|
|
215
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
216
|
+
onInit(handler) {
|
|
217
|
+
// Late subscriber pattern: fire immediately if ready
|
|
218
|
+
if (this.context) {
|
|
219
|
+
try {
|
|
220
|
+
handler(this.context);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
logger.error("Error in onInit late subscriber handler", error);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Register for future (though READY typically fires once per session)
|
|
227
|
+
this.initHandlers.add(handler);
|
|
228
|
+
}
|
|
229
|
+
onShow(handler) {
|
|
230
|
+
this.showHandlers.add(handler);
|
|
231
|
+
if (this.lifecyclePhase === LifecyclePhase.Show && !(this._terminated && this.context?.duration == null)) {
|
|
232
|
+
this.invokeLateSubscriber(handler);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
onStart(handler) {
|
|
236
|
+
this.startHandlers.add(handler);
|
|
237
|
+
if (this.lifecyclePhase === LifecyclePhase.Start && !(this._terminated && this.context?.duration == null)) {
|
|
238
|
+
this.invokeLateSubscriber(handler);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
onHide(handler) {
|
|
242
|
+
this.hideHandlers.add(handler);
|
|
243
|
+
if (this.lifecyclePhase === LifecyclePhase.Hide) {
|
|
244
|
+
this.invokeLateSubscriber(handler);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
onStop(handler) {
|
|
248
|
+
this.stopHandlers.add(handler);
|
|
249
|
+
if (this.lifecyclePhase === LifecyclePhase.Stop) {
|
|
250
|
+
this.invokeLateSubscriber(handler);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
invokeLateSubscriber(handler) {
|
|
254
|
+
try {
|
|
255
|
+
handler();
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
logger.error("Error in late subscriber handler", error);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
262
|
+
// CONTEXT CHANGES
|
|
263
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
264
|
+
onContextChange(handler) {
|
|
265
|
+
this.contextChangeHandlers.add(handler);
|
|
266
|
+
}
|
|
267
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
268
|
+
// PLAYBACK CONTROL
|
|
269
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
270
|
+
notifyComplete() {
|
|
271
|
+
if (this._terminated) {
|
|
272
|
+
logger.warn("ignoring notifyComplete() call after session ended");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this._terminated = true;
|
|
276
|
+
this.rpc.notify("notifyComplete");
|
|
277
|
+
}
|
|
278
|
+
notifyEstimatedEnd(options) {
|
|
279
|
+
if (this._terminated) {
|
|
280
|
+
logger.warn("ignoring notifyEstimatedEnd() call after session ended");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if ('finishTime' in options && 'totalDuration' in options) {
|
|
284
|
+
logger.warn("EstimatedEndOptions has both finishTime and totalDuration, using finishTime");
|
|
285
|
+
}
|
|
286
|
+
if ('finishTime' in options && options.finishTime != null) {
|
|
287
|
+
if (options.finishTime <= Date.now()) {
|
|
288
|
+
logger.warn("notifyEstimatedEnd: finishTime must not be in the past");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
this._pendingTotalDuration = null;
|
|
292
|
+
this.rpc.notify("notifyEstimatedEnd", {
|
|
293
|
+
expectedFinishTime: options.finishTime,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
else if ('totalDuration' in options && options.totalDuration != null) {
|
|
297
|
+
if (options.totalDuration <= 0) {
|
|
298
|
+
logger.warn("notifyEstimatedEnd: totalDuration must be positive");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (this._startTime != null) {
|
|
302
|
+
this.rpc.notify("notifyEstimatedEnd", {
|
|
303
|
+
expectedFinishTime: this._startTime + options.totalDuration * 1000,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
this._pendingTotalDuration = options.totalDuration;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
notifyNoContent(options) {
|
|
312
|
+
if (this._terminated) {
|
|
313
|
+
logger.warn("ignoring notifyNoContent() call after session ended");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this._terminated = true;
|
|
317
|
+
const wirePayload = {};
|
|
318
|
+
if (options?.reason) {
|
|
319
|
+
wirePayload.reason = options.reason;
|
|
320
|
+
}
|
|
321
|
+
if (options && 'retryIn' in options && options.retryIn != null) {
|
|
322
|
+
if (options.retryIn <= 0) {
|
|
323
|
+
logger.warn("notifyNoContent: retryIn must be positive");
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
wirePayload.retryNotBefore = Date.now() + options.retryIn * 1000;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else if (options && 'retryAt' in options && options.retryAt != null) {
|
|
330
|
+
if (options.retryAt <= Date.now()) {
|
|
331
|
+
logger.warn("notifyNoContent: retryAt must not be in the past");
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
wirePayload.retryNotBefore = options.retryAt;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
this.rpc.notify("notifyNoContent", wirePayload);
|
|
338
|
+
}
|
|
339
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
340
|
+
// CAPABILITY APIS
|
|
341
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
342
|
+
get storage() {
|
|
343
|
+
return {
|
|
344
|
+
get: async (key) => {
|
|
345
|
+
const result = await this.rpc.call("storage.get", { key });
|
|
346
|
+
return result?.value;
|
|
347
|
+
},
|
|
348
|
+
set: async (key, value) => {
|
|
349
|
+
await this.rpc.call("storage.set", { key, value });
|
|
350
|
+
},
|
|
351
|
+
remove: async (key) => {
|
|
352
|
+
await this.rpc.call("storage.remove", { key });
|
|
353
|
+
},
|
|
354
|
+
list: async () => {
|
|
355
|
+
const result = await this.rpc.call("storage.list");
|
|
356
|
+
return result?.keys || [];
|
|
357
|
+
},
|
|
358
|
+
clear: async () => {
|
|
359
|
+
await this.rpc.call("storage.clear");
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
get proxy() {
|
|
364
|
+
return {
|
|
365
|
+
fetch: async (request) => {
|
|
366
|
+
const wireResponse = await this.rpc.call("proxy.fetch", request);
|
|
367
|
+
// Reconstruct ProxyResponse from wire format
|
|
368
|
+
const response = {
|
|
369
|
+
ok: wireResponse.ok,
|
|
370
|
+
status: wireResponse.status,
|
|
371
|
+
statusText: wireResponse.statusText,
|
|
372
|
+
headers: wireResponse.headers,
|
|
373
|
+
cached: wireResponse.cached,
|
|
374
|
+
stale: wireResponse.stale,
|
|
375
|
+
cachedAt: wireResponse.cachedAt,
|
|
376
|
+
json: async () => JSON.parse(wireResponse.body),
|
|
377
|
+
text: async () => wireResponse.body,
|
|
378
|
+
};
|
|
379
|
+
return response;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
get mediaCache() {
|
|
384
|
+
return {
|
|
385
|
+
download: async (url) => {
|
|
386
|
+
const result = await this.rpc.call("mediaCache.download", { url });
|
|
387
|
+
return result;
|
|
388
|
+
},
|
|
389
|
+
get: async (url) => {
|
|
390
|
+
const result = await this.rpc.call("mediaCache.get", { url });
|
|
391
|
+
return result;
|
|
392
|
+
},
|
|
393
|
+
list: async () => {
|
|
394
|
+
const result = await this.rpc.call("mediaCache.list");
|
|
395
|
+
return result?.files || [];
|
|
396
|
+
},
|
|
397
|
+
delete: async (fileName) => {
|
|
398
|
+
await this.rpc.call("mediaCache.delete", { fileName });
|
|
399
|
+
},
|
|
400
|
+
clear: async () => {
|
|
401
|
+
await this.rpc.call("mediaCache.clear");
|
|
402
|
+
},
|
|
403
|
+
getStorageInfo: async () => {
|
|
404
|
+
const result = await this.rpc.call("mediaCache.getStorageInfo");
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
get static() {
|
|
410
|
+
return {
|
|
411
|
+
markStatic: () => {
|
|
412
|
+
this.rpc.call("static.markStatic").catch((error) => {
|
|
413
|
+
logger.error("static.markStatic failed", error);
|
|
414
|
+
});
|
|
415
|
+
},
|
|
416
|
+
markSemiStatic: (refreshIntervalSeconds) => {
|
|
417
|
+
this.rpc.call("static.markSemiStatic", { refreshIntervalSeconds }).catch((error) => {
|
|
418
|
+
logger.error("static.markSemiStatic failed", error);
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
disableStatic: () => {
|
|
422
|
+
this.rpc.call("static.disableStatic").catch((error) => {
|
|
423
|
+
logger.error("static.disableStatic failed", error);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
get interaction() {
|
|
429
|
+
return {
|
|
430
|
+
onEvent: (handler) => {
|
|
431
|
+
this.interactionHandlers.add(handler);
|
|
432
|
+
// Return unsubscribe function
|
|
433
|
+
return () => {
|
|
434
|
+
this.interactionHandlers.delete(handler);
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
440
|
+
// LOGGING
|
|
441
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
442
|
+
log(level, message, data) {
|
|
443
|
+
// Fire-and-forget: no RESPONSE expected (spec §4.4, Logging spec §10.1)
|
|
444
|
+
this.rpc.notify("log", { level, message, data }).catch(() => { });
|
|
445
|
+
}
|
|
446
|
+
}
|