@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/dist/messages.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol message types from the AppStrata specification.
|
|
3
|
+
*
|
|
4
|
+
* Defines all message types used in the HELLO/READY/READY_ACK handshake
|
|
5
|
+
* and subsequent RPC and event communication.
|
|
6
|
+
*/
|
|
7
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
// TYPE GUARDS
|
|
9
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
/**
|
|
11
|
+
* Type guard to check if data is a valid protocol message.
|
|
12
|
+
*
|
|
13
|
+
* Validates the base structure: protocol identifier, version, and type.
|
|
14
|
+
*/
|
|
15
|
+
export function isProtocolMessage(data) {
|
|
16
|
+
if (typeof data !== "object" || data === null) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const msg = data;
|
|
20
|
+
return (msg.protocol === "appstrata-protocol" &&
|
|
21
|
+
msg.version === "1.0" &&
|
|
22
|
+
typeof msg.type === "string" &&
|
|
23
|
+
typeof msg.timestamp === "number");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Type guard for HELLO message.
|
|
27
|
+
*/
|
|
28
|
+
export function isHelloMessage(msg) {
|
|
29
|
+
return msg.type === "HELLO";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Type guard for READY message.
|
|
33
|
+
*/
|
|
34
|
+
export function isReadyMessage(msg) {
|
|
35
|
+
return msg.type === "READY";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Type guard for READY_ACK message.
|
|
39
|
+
*/
|
|
40
|
+
export function isReadyAckMessage(msg) {
|
|
41
|
+
return msg.type === "READY_ACK";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Type guard for REQUEST message.
|
|
45
|
+
*/
|
|
46
|
+
export function isRequestMessage(msg) {
|
|
47
|
+
return msg.type === "REQUEST";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Type guard for RESPONSE message.
|
|
51
|
+
*/
|
|
52
|
+
export function isResponseMessage(msg) {
|
|
53
|
+
return msg.type === "RESPONSE";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Type guard for EVENT message.
|
|
57
|
+
*/
|
|
58
|
+
export function isEventMessage(msg) {
|
|
59
|
+
return msg.type === "EVENT";
|
|
60
|
+
}
|
|
61
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
62
|
+
// UTILITY FUNCTIONS
|
|
63
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
+
/**
|
|
65
|
+
* Generate a unique request ID (UUID v4).
|
|
66
|
+
*
|
|
67
|
+
* @returns Unique request identifier
|
|
68
|
+
*/
|
|
69
|
+
export function generateRequestId() {
|
|
70
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
71
|
+
return crypto.randomUUID();
|
|
72
|
+
}
|
|
73
|
+
// Fallback: manual UUID v4
|
|
74
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
75
|
+
const r = (Math.random() * 16) | 0;
|
|
76
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
77
|
+
return v.toString(16);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Convert wire format context to AppContext.
|
|
82
|
+
*
|
|
83
|
+
* Adds the hasCapability() method to the context.
|
|
84
|
+
*
|
|
85
|
+
* @param wireContext - Context from wire protocol
|
|
86
|
+
* @returns AppContext with hasCapability method
|
|
87
|
+
*/
|
|
88
|
+
export function wireContextToAppContext(wireContext) {
|
|
89
|
+
const capabilities = wireContext.capabilities || [];
|
|
90
|
+
let duration;
|
|
91
|
+
if (wireContext.duration === -1) {
|
|
92
|
+
duration = undefined;
|
|
93
|
+
}
|
|
94
|
+
else if (wireContext.duration === -2) {
|
|
95
|
+
duration = Infinity;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
duration = wireContext.duration;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
...wireContext,
|
|
102
|
+
duration,
|
|
103
|
+
hasCapability: (capability) => capabilities.includes(capability)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Convert AppContext to wire format.
|
|
108
|
+
*
|
|
109
|
+
* Extracts capabilities array from hasCapability method.
|
|
110
|
+
*
|
|
111
|
+
* @param context - AppContext with hasCapability method
|
|
112
|
+
* @returns Wire format context with capabilities array
|
|
113
|
+
*/
|
|
114
|
+
export function appContextToWire(context) {
|
|
115
|
+
// Extract capabilities by checking hasCapability for known capabilities
|
|
116
|
+
const capabilities = [];
|
|
117
|
+
const knownCapabilities = ["storage", "proxy", "mediaCache", "static", "interaction"];
|
|
118
|
+
for (const cap of knownCapabilities) {
|
|
119
|
+
if (context.hasCapability(cap)) {
|
|
120
|
+
capabilities.push(cap);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Convert duration: null/undefined -> -1, Infinity -> -2
|
|
124
|
+
let wireDuration;
|
|
125
|
+
if (context.duration == null) {
|
|
126
|
+
wireDuration = -1;
|
|
127
|
+
}
|
|
128
|
+
else if (context.duration === Infinity) {
|
|
129
|
+
wireDuration = -2;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
wireDuration = context.duration;
|
|
133
|
+
}
|
|
134
|
+
// Copy all properties except hasCapability, then add capabilities array
|
|
135
|
+
const { hasCapability, ...rest } = context;
|
|
136
|
+
return {
|
|
137
|
+
...rest,
|
|
138
|
+
duration: wireDuration,
|
|
139
|
+
capabilities,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-polling client-side HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Sends messages via HTTP POST, receives messages by long-polling a
|
|
5
|
+
* server endpoint. The server holds the request until messages are
|
|
6
|
+
* available or a timeout elapses.
|
|
7
|
+
*/
|
|
8
|
+
import type { Transport } from "../types.js";
|
|
9
|
+
import type { HttpClientTransportOptions } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Create a long-polling client-side HTTP transport.
|
|
12
|
+
*
|
|
13
|
+
* Sends messages via HTTP POST, receives by polling.
|
|
14
|
+
*
|
|
15
|
+
* @param options - Transport configuration options
|
|
16
|
+
* @returns Transport instance
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const transport = createHttpPollingTransport({
|
|
21
|
+
* sendUrl: "https://player.example.com/api/send",
|
|
22
|
+
* receiveUrl: "https://player.example.com/api/receive",
|
|
23
|
+
* sessionId: "abc-123",
|
|
24
|
+
* authToken: "secret-token"
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* transport.onMessage((msg) => console.log("Received:", msg));
|
|
28
|
+
* transport.send({ protocol: "appstrata-protocol", version: "1.0", timestamp: Date.now(), type: "HELLO" });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function createHttpPollingTransport(options: HttpClientTransportOptions): Transport;
|
|
32
|
+
//# sourceMappingURL=client-polling.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client-polling.d.ts","sourceRoot":"","sources":["../../../src/transport/http/client-polling.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAG7C,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,0BAA0B,GAAG,SAAS,CAiLzF"}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-polling client-side HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Sends messages via HTTP POST, receives messages by long-polling a
|
|
5
|
+
* server endpoint. The server holds the request until messages are
|
|
6
|
+
* available or a timeout elapses.
|
|
7
|
+
*/
|
|
8
|
+
import { createLogger } from "@appstrata/core";
|
|
9
|
+
import { isProtocolMessage } from "../../messages.js";
|
|
10
|
+
/**
|
|
11
|
+
* Create a long-polling client-side HTTP transport.
|
|
12
|
+
*
|
|
13
|
+
* Sends messages via HTTP POST, receives by polling.
|
|
14
|
+
*
|
|
15
|
+
* @param options - Transport configuration options
|
|
16
|
+
* @returns Transport instance
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const transport = createHttpPollingTransport({
|
|
21
|
+
* sendUrl: "https://player.example.com/api/send",
|
|
22
|
+
* receiveUrl: "https://player.example.com/api/receive",
|
|
23
|
+
* sessionId: "abc-123",
|
|
24
|
+
* authToken: "secret-token"
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* transport.onMessage((msg) => console.log("Received:", msg));
|
|
28
|
+
* transport.send({ protocol: "appstrata-protocol", version: "1.0", timestamp: Date.now(), type: "HELLO" });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function createHttpPollingTransport(options) {
|
|
32
|
+
const { sendUrl, receiveUrl, sessionId, authToken, timeout = 30000, headers: customHeaders = {}, } = options;
|
|
33
|
+
const logger = createLogger("HttpPolling");
|
|
34
|
+
const handlers = new Set();
|
|
35
|
+
let closed = false;
|
|
36
|
+
let pollAbortController = null;
|
|
37
|
+
/**
|
|
38
|
+
* Build common headers for POST requests.
|
|
39
|
+
*/
|
|
40
|
+
function buildHeaders() {
|
|
41
|
+
const headers = {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"X-Session-Id": sessionId,
|
|
44
|
+
...customHeaders,
|
|
45
|
+
};
|
|
46
|
+
if (authToken) {
|
|
47
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
48
|
+
}
|
|
49
|
+
return headers;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Dispatch message to all handlers.
|
|
53
|
+
*/
|
|
54
|
+
function dispatchMessage(message) {
|
|
55
|
+
for (const handler of [...handlers]) {
|
|
56
|
+
try {
|
|
57
|
+
handler(message);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
logger.error("Error in message handler", error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Run a single poll cycle: fetch messages, dispatch, repeat.
|
|
66
|
+
*/
|
|
67
|
+
async function pollLoop() {
|
|
68
|
+
while (!closed) {
|
|
69
|
+
try {
|
|
70
|
+
pollAbortController = new AbortController();
|
|
71
|
+
const pollUrl = new URL(receiveUrl);
|
|
72
|
+
pollUrl.searchParams.set("sessionId", sessionId);
|
|
73
|
+
if (authToken) {
|
|
74
|
+
pollUrl.searchParams.set("token", authToken);
|
|
75
|
+
}
|
|
76
|
+
logger.debug(`Polling: ${pollUrl.toString()}`);
|
|
77
|
+
const response = await fetch(pollUrl.toString(), {
|
|
78
|
+
method: "GET",
|
|
79
|
+
headers: {
|
|
80
|
+
Accept: "application/json",
|
|
81
|
+
...customHeaders,
|
|
82
|
+
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
|
83
|
+
},
|
|
84
|
+
signal: pollAbortController.signal,
|
|
85
|
+
});
|
|
86
|
+
if (closed)
|
|
87
|
+
break;
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
logger.debug(`Poll failed: HTTP ${response.status}, retrying in 3s...`);
|
|
90
|
+
await delay(3000, pollAbortController.signal);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const data = await response.json();
|
|
94
|
+
// Server returns { messages: [...] }
|
|
95
|
+
const messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
96
|
+
for (const msg of messages) {
|
|
97
|
+
if (isProtocolMessage(msg)) {
|
|
98
|
+
dispatchMessage(msg);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Immediately poll again (no delay — the server holds the request)
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (closed)
|
|
105
|
+
break;
|
|
106
|
+
const isAbort = error instanceof Error && error.name === "AbortError";
|
|
107
|
+
if (!isAbort) {
|
|
108
|
+
logger.debug("Poll error, retrying in 3s...", error);
|
|
109
|
+
// Re-use the same controller for the delay; if close() was
|
|
110
|
+
// called while the fetch was in-flight the signal is already
|
|
111
|
+
// aborted and delay() resolves immediately.
|
|
112
|
+
if (pollAbortController) {
|
|
113
|
+
await delay(3000, pollAbortController.signal);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Delay that resolves after `ms`, or immediately if `signal` is aborted.
|
|
121
|
+
* Used for retry backoff on poll errors. The signal comes from
|
|
122
|
+
* `pollAbortController`, so `close()` cancels any in-progress delay
|
|
123
|
+
* via the same `abort()` call that cancels the fetch.
|
|
124
|
+
*/
|
|
125
|
+
function delay(ms, signal) {
|
|
126
|
+
if (signal.aborted)
|
|
127
|
+
return Promise.resolve();
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
const timer = setTimeout(resolve, ms);
|
|
130
|
+
signal.addEventListener("abort", () => {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
resolve();
|
|
133
|
+
}, { once: true });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Start polling immediately
|
|
137
|
+
pollLoop();
|
|
138
|
+
return {
|
|
139
|
+
send(message) {
|
|
140
|
+
if (closed) {
|
|
141
|
+
logger.debug("Cannot send message: transport is closed");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
146
|
+
fetch(sendUrl, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: buildHeaders(),
|
|
149
|
+
body: JSON.stringify(message),
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
})
|
|
152
|
+
.then((response) => {
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
logger.error(`Send failed: HTTP ${response.status}`);
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
.catch((error) => {
|
|
159
|
+
clearTimeout(timeoutId);
|
|
160
|
+
if (error.name === "AbortError") {
|
|
161
|
+
logger.error("Send request timed out");
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
logger.error("Send error", error);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
onMessage(handler) {
|
|
169
|
+
handlers.add(handler);
|
|
170
|
+
return () => handlers.delete(handler);
|
|
171
|
+
},
|
|
172
|
+
close() {
|
|
173
|
+
closed = true;
|
|
174
|
+
if (pollAbortController) {
|
|
175
|
+
pollAbortController.abort();
|
|
176
|
+
pollAbortController = null;
|
|
177
|
+
}
|
|
178
|
+
handlers.clear();
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE client-side HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Sends messages via HTTP POST, receives messages via Server-Sent Events (SSE).
|
|
5
|
+
*/
|
|
6
|
+
import type { Transport } from "../types.js";
|
|
7
|
+
import type { HttpClientTransportOptions } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Create an SSE-based client-side HTTP transport.
|
|
10
|
+
*
|
|
11
|
+
* Sends messages via HTTP POST, receives via SSE.
|
|
12
|
+
*
|
|
13
|
+
* @param options - Transport configuration options
|
|
14
|
+
* @returns Transport instance
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const transport = createHttpSseTransport({
|
|
19
|
+
* sendUrl: "https://player.example.com/api/send",
|
|
20
|
+
* receiveUrl: "https://player.example.com/api/receive",
|
|
21
|
+
* sessionId: "abc-123",
|
|
22
|
+
* authToken: "secret-token"
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* transport.onMessage((msg) => console.log("Received:", msg));
|
|
26
|
+
* transport.send({ protocol: "appstrata-protocol", version: "1.0", timestamp: Date.now(), type: "HELLO" });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare function createHttpSseTransport(options: HttpClientTransportOptions): Transport;
|
|
30
|
+
//# sourceMappingURL=client-sse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client-sse.d.ts","sourceRoot":"","sources":["../../../src/transport/http/client-sse.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAG7C,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,0BAA0B,GAAG,SAAS,CAuJrF"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE client-side HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Sends messages via HTTP POST, receives messages via Server-Sent Events (SSE).
|
|
5
|
+
*/
|
|
6
|
+
import { createLogger } from "@appstrata/core";
|
|
7
|
+
import { isProtocolMessage } from "../../messages.js";
|
|
8
|
+
/**
|
|
9
|
+
* Create an SSE-based client-side HTTP transport.
|
|
10
|
+
*
|
|
11
|
+
* Sends messages via HTTP POST, receives via SSE.
|
|
12
|
+
*
|
|
13
|
+
* @param options - Transport configuration options
|
|
14
|
+
* @returns Transport instance
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const transport = createHttpSseTransport({
|
|
19
|
+
* sendUrl: "https://player.example.com/api/send",
|
|
20
|
+
* receiveUrl: "https://player.example.com/api/receive",
|
|
21
|
+
* sessionId: "abc-123",
|
|
22
|
+
* authToken: "secret-token"
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* transport.onMessage((msg) => console.log("Received:", msg));
|
|
26
|
+
* transport.send({ protocol: "appstrata-protocol", version: "1.0", timestamp: Date.now(), type: "HELLO" });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function createHttpSseTransport(options) {
|
|
30
|
+
const { sendUrl, receiveUrl, sessionId, authToken, timeout = 30000, headers: customHeaders = {}, } = options;
|
|
31
|
+
const logger = createLogger("HttpSSE");
|
|
32
|
+
const handlers = new Set();
|
|
33
|
+
let eventSource = null;
|
|
34
|
+
let closed = false;
|
|
35
|
+
let reconnectTimer = null;
|
|
36
|
+
/**
|
|
37
|
+
* Build common headers for requests.
|
|
38
|
+
*/
|
|
39
|
+
function buildHeaders() {
|
|
40
|
+
const headers = {
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
"X-Session-Id": sessionId,
|
|
43
|
+
...customHeaders,
|
|
44
|
+
};
|
|
45
|
+
if (authToken) {
|
|
46
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
47
|
+
}
|
|
48
|
+
return headers;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Dispatch message to all handlers.
|
|
52
|
+
*/
|
|
53
|
+
function dispatchMessage(message) {
|
|
54
|
+
for (const handler of [...handlers]) {
|
|
55
|
+
try {
|
|
56
|
+
handler(message);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
logger.error("Error in message handler", error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Start SSE connection for receiving messages.
|
|
65
|
+
*/
|
|
66
|
+
function startSSE() {
|
|
67
|
+
if (closed || eventSource)
|
|
68
|
+
return;
|
|
69
|
+
const eventsUrl = new URL(receiveUrl);
|
|
70
|
+
eventsUrl.searchParams.set("sessionId", sessionId);
|
|
71
|
+
// Note: EventSource doesn't support custom headers directly.
|
|
72
|
+
// For auth, we pass the token as a query param (or use cookies).
|
|
73
|
+
if (authToken) {
|
|
74
|
+
eventsUrl.searchParams.set("token", authToken);
|
|
75
|
+
}
|
|
76
|
+
logger.debug(`Connecting to SSE: ${eventsUrl.toString()}`);
|
|
77
|
+
eventSource = new EventSource(eventsUrl.toString());
|
|
78
|
+
eventSource.onopen = () => {
|
|
79
|
+
logger.debug(`SSE connected (session ${sessionId})`);
|
|
80
|
+
};
|
|
81
|
+
eventSource.onmessage = (event) => {
|
|
82
|
+
try {
|
|
83
|
+
const data = JSON.parse(event.data);
|
|
84
|
+
if (isProtocolMessage(data)) {
|
|
85
|
+
dispatchMessage(data);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
logger.error("Failed to parse SSE message", error);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
eventSource.onerror = () => {
|
|
93
|
+
if (!closed && eventSource) {
|
|
94
|
+
logger.debug(`SSE disconnected (session ${sessionId}), reconnecting in 3s...`);
|
|
95
|
+
eventSource.close();
|
|
96
|
+
eventSource = null;
|
|
97
|
+
reconnectTimer = setTimeout(() => {
|
|
98
|
+
reconnectTimer = null;
|
|
99
|
+
if (!closed) {
|
|
100
|
+
startSSE();
|
|
101
|
+
}
|
|
102
|
+
}, 3000);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Start SSE connection immediately
|
|
107
|
+
startSSE();
|
|
108
|
+
return {
|
|
109
|
+
send(message) {
|
|
110
|
+
if (closed) {
|
|
111
|
+
logger.debug("Cannot send message: transport is closed");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
116
|
+
fetch(sendUrl, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: buildHeaders(),
|
|
119
|
+
body: JSON.stringify(message),
|
|
120
|
+
signal: controller.signal,
|
|
121
|
+
})
|
|
122
|
+
.then((response) => {
|
|
123
|
+
clearTimeout(timeoutId);
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
logger.error(`Send failed: HTTP ${response.status}`);
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.catch((error) => {
|
|
129
|
+
clearTimeout(timeoutId);
|
|
130
|
+
if (error.name === "AbortError") {
|
|
131
|
+
logger.error("Send request timed out");
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
logger.error("Send error", error);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
onMessage(handler) {
|
|
139
|
+
handlers.add(handler);
|
|
140
|
+
return () => handlers.delete(handler);
|
|
141
|
+
},
|
|
142
|
+
close() {
|
|
143
|
+
closed = true;
|
|
144
|
+
if (reconnectTimer) {
|
|
145
|
+
clearTimeout(reconnectTimer);
|
|
146
|
+
reconnectTimer = null;
|
|
147
|
+
}
|
|
148
|
+
if (eventSource) {
|
|
149
|
+
eventSource.close();
|
|
150
|
+
eventSource = null;
|
|
151
|
+
}
|
|
152
|
+
handlers.clear();
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP host transport session manager.
|
|
3
|
+
*
|
|
4
|
+
* Manages multiple `HttpHostTransport` instances (one per client session).
|
|
5
|
+
* Uses inversion of control: the caller supplies a factory function that
|
|
6
|
+
* creates the concrete transport (SSE, long-polling, or any future variant).
|
|
7
|
+
*
|
|
8
|
+
* ## Usage
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createHttpHostTransportManager } from "./host-manager.js";
|
|
12
|
+
* import { createHttpSseHostTransport } from "./host-sse.js";
|
|
13
|
+
*
|
|
14
|
+
* const manager = createHttpHostTransportManager(
|
|
15
|
+
* (sessionId) => createHttpSseHostTransport({ sessionId })
|
|
16
|
+
* );
|
|
17
|
+
*
|
|
18
|
+
* const transport = manager.getTransport("session-123");
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import type { ProtocolMessage } from "../../messages.js";
|
|
22
|
+
import type { HttpHostTransport } from "./types.js";
|
|
23
|
+
/**
|
|
24
|
+
* Factory function that creates an `HttpHostTransport` for a given session.
|
|
25
|
+
*/
|
|
26
|
+
export type HttpHostTransportFactory = (sessionId: string) => HttpHostTransport;
|
|
27
|
+
/**
|
|
28
|
+
* Manages multiple HTTP host transports, one per client session.
|
|
29
|
+
*
|
|
30
|
+
* Useful for server implementations that handle multiple concurrent clients.
|
|
31
|
+
*/
|
|
32
|
+
export interface HttpHostTransportManager {
|
|
33
|
+
/**
|
|
34
|
+
* Get or create a transport for a session.
|
|
35
|
+
*/
|
|
36
|
+
getTransport(sessionId: string): HttpHostTransport;
|
|
37
|
+
/**
|
|
38
|
+
* Check if a session exists.
|
|
39
|
+
*/
|
|
40
|
+
hasSession(sessionId: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Close and remove a session.
|
|
43
|
+
*/
|
|
44
|
+
closeSession(sessionId: string): void;
|
|
45
|
+
/**
|
|
46
|
+
* Register a handler for messages from any session.
|
|
47
|
+
*/
|
|
48
|
+
onMessage(handler: (sessionId: string, message: ProtocolMessage) => void): () => void;
|
|
49
|
+
/**
|
|
50
|
+
* Close all sessions.
|
|
51
|
+
*/
|
|
52
|
+
closeAll(): void;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create a manager for multiple sessions.
|
|
56
|
+
*
|
|
57
|
+
* Uses the provided `factory` to create transports on demand.
|
|
58
|
+
* `getTransport(sessionId)` returns an existing transport or creates one.
|
|
59
|
+
*
|
|
60
|
+
* @param factory - Function that creates an HttpHostTransport for a session ID
|
|
61
|
+
*/
|
|
62
|
+
export declare function createHttpHostTransportManager(factory: HttpHostTransportFactory): HttpHostTransportManager;
|
|
63
|
+
//# sourceMappingURL=host-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-manager.d.ts","sourceRoot":"","sources":["../../../src/transport/http/host-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD;;GAEG;AACH,MAAM,MAAM,wBAAwB,GAAG,CAAC,SAAS,EAAE,MAAM,KAAK,iBAAiB,CAAC;AAEhF;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;OAEG;IACH,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,iBAAiB,CAAC;IAEnD;;OAEG;IACH,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IAEvC;;OAEG;IACH,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAEtC;;OAEG;IACH,SAAS,CAAC,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAEtF;;OAEG;IACH,QAAQ,IAAI,IAAI,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,wBAAwB,GAChC,wBAAwB,CAoD1B"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP host transport session manager.
|
|
3
|
+
*
|
|
4
|
+
* Manages multiple `HttpHostTransport` instances (one per client session).
|
|
5
|
+
* Uses inversion of control: the caller supplies a factory function that
|
|
6
|
+
* creates the concrete transport (SSE, long-polling, or any future variant).
|
|
7
|
+
*
|
|
8
|
+
* ## Usage
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createHttpHostTransportManager } from "./host-manager.js";
|
|
12
|
+
* import { createHttpSseHostTransport } from "./host-sse.js";
|
|
13
|
+
*
|
|
14
|
+
* const manager = createHttpHostTransportManager(
|
|
15
|
+
* (sessionId) => createHttpSseHostTransport({ sessionId })
|
|
16
|
+
* );
|
|
17
|
+
*
|
|
18
|
+
* const transport = manager.getTransport("session-123");
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Create a manager for multiple sessions.
|
|
23
|
+
*
|
|
24
|
+
* Uses the provided `factory` to create transports on demand.
|
|
25
|
+
* `getTransport(sessionId)` returns an existing transport or creates one.
|
|
26
|
+
*
|
|
27
|
+
* @param factory - Function that creates an HttpHostTransport for a session ID
|
|
28
|
+
*/
|
|
29
|
+
export function createHttpHostTransportManager(factory) {
|
|
30
|
+
const transports = new Map();
|
|
31
|
+
const globalHandlers = new Set();
|
|
32
|
+
return {
|
|
33
|
+
getTransport(sessionId) {
|
|
34
|
+
let transport = transports.get(sessionId);
|
|
35
|
+
if (!transport) {
|
|
36
|
+
transport = factory(sessionId);
|
|
37
|
+
transports.set(sessionId, transport);
|
|
38
|
+
// Wire up global handlers
|
|
39
|
+
transport.onMessage((message) => {
|
|
40
|
+
for (const handler of [...globalHandlers]) {
|
|
41
|
+
try {
|
|
42
|
+
handler(sessionId, message);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.error(`[AppStrata Host] Error in global handler:`, error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return transport;
|
|
51
|
+
},
|
|
52
|
+
hasSession(sessionId) {
|
|
53
|
+
return transports.has(sessionId);
|
|
54
|
+
},
|
|
55
|
+
closeSession(sessionId) {
|
|
56
|
+
const transport = transports.get(sessionId);
|
|
57
|
+
if (transport) {
|
|
58
|
+
transport.close();
|
|
59
|
+
transports.delete(sessionId);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
onMessage(handler) {
|
|
63
|
+
globalHandlers.add(handler);
|
|
64
|
+
return () => globalHandlers.delete(handler);
|
|
65
|
+
},
|
|
66
|
+
closeAll() {
|
|
67
|
+
for (const transport of transports.values()) {
|
|
68
|
+
transport.close();
|
|
69
|
+
}
|
|
70
|
+
transports.clear();
|
|
71
|
+
globalHandlers.clear();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|