@appstrata/cli 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 +304 -0
- package/dist/commands/dev-http-player.d.ts +23 -0
- package/dist/commands/dev-http-player.d.ts.map +1 -0
- package/dist/commands/dev-http-player.js +360 -0
- package/dist/commands/dev.d.ts +21 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +114 -0
- package/dist/commands/package.d.ts +18 -0
- package/dist/commands/package.d.ts.map +1 -0
- package/dist/commands/package.js +161 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/runtimes/index.d.ts +19 -0
- package/dist/runtimes/index.d.ts.map +1 -0
- package/dist/runtimes/index.js +29 -0
- package/dist/runtimes/python.d.ts +13 -0
- package/dist/runtimes/python.d.ts.map +1 -0
- package/dist/runtimes/python.js +120 -0
- package/dist/runtimes/types.d.ts +74 -0
- package/dist/runtimes/types.d.ts.map +1 -0
- package/dist/runtimes/types.js +8 -0
- package/dist/schema-generator.d.ts +41 -0
- package/dist/schema-generator.d.ts.map +1 -0
- package/dist/schema-generator.js +239 -0
- package/dist/status-page.d.ts +5 -0
- package/dist/status-page.d.ts.map +1 -0
- package/dist/status-page.js +71 -0
- package/package.json +50 -0
- package/python/appstrata_dev_player/__init__.py +1 -0
- package/python/appstrata_dev_player/__main__.py +102 -0
- package/python/appstrata_dev_player/config.py +124 -0
- package/python/appstrata_dev_player/server.py +508 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP server for the AppStrata Python player.
|
|
3
|
+
|
|
4
|
+
Exposes the same endpoints as the Node dev-http-player:
|
|
5
|
+
POST /api/send — client sends messages to the player
|
|
6
|
+
GET /api/receive — player sends messages to the client (SSE or polling)
|
|
7
|
+
GET / — status page
|
|
8
|
+
|
|
9
|
+
Port of: packages/cli/src/commands/dev-http-player.ts
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
from aiohttp import web
|
|
20
|
+
|
|
21
|
+
from appstrata_player.app_host import AppHost, CapabilityServices
|
|
22
|
+
from appstrata_player.transport.http_sse import HttpSseHostTransport
|
|
23
|
+
from appstrata_player.transport.http_polling import HttpPollingHostTransport
|
|
24
|
+
from appstrata_player.transport.manager import HttpHostTransportManager
|
|
25
|
+
from appstrata_player.services.storage import MockStorage
|
|
26
|
+
from appstrata_player.services.proxy import MockProxy
|
|
27
|
+
from appstrata_player.services.media_cache import MockMediaCache
|
|
28
|
+
from appstrata_player.services.static_api import MockStatic
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
POLL_IDLE_TIMEOUT = 60 # seconds
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_capabilities(capabilities: list[str] | None) -> list[str]:
|
|
36
|
+
"""Resolve capability config into a flat list of capability names."""
|
|
37
|
+
if not capabilities:
|
|
38
|
+
return ["storage", "proxy"]
|
|
39
|
+
if "all" in capabilities:
|
|
40
|
+
return ["storage", "proxy", "mediaCache", "static"]
|
|
41
|
+
return list(capabilities)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_capability_services(capabilities: list[str]) -> CapabilityServices:
|
|
45
|
+
"""Create mock service instances for the given capabilities."""
|
|
46
|
+
return CapabilityServices(
|
|
47
|
+
storage=MockStorage() if "storage" in capabilities else None,
|
|
48
|
+
proxy=MockProxy() if "proxy" in capabilities else None,
|
|
49
|
+
media_cache=MockMediaCache() if "mediaCache" in capabilities else None,
|
|
50
|
+
static=MockStatic() if "static" in capabilities else None,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_app_context(
|
|
55
|
+
context_config: dict[str, Any], capabilities: list[str]
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""Build an AppContext dict from dev config and capabilities."""
|
|
58
|
+
default_device = {
|
|
59
|
+
"id": "dev-player",
|
|
60
|
+
"name": "Development Player",
|
|
61
|
+
"type": "web-player",
|
|
62
|
+
"platformName": "AppStrata Dev",
|
|
63
|
+
"locale": "en-US",
|
|
64
|
+
"timezone": "UTC",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
device_cfg = context_config.get("device", {})
|
|
68
|
+
device = {**default_device, **device_cfg}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
"instanceId": context_config.get("instanceId", "dev-instance"),
|
|
72
|
+
"duration": context_config.get("duration", 30),
|
|
73
|
+
"mode": context_config.get("mode", "development"),
|
|
74
|
+
"environment": context_config.get("environment", "development"),
|
|
75
|
+
"viewportWidth": context_config.get("viewportWidth", 1920),
|
|
76
|
+
"viewportHeight": context_config.get("viewportHeight", 1080),
|
|
77
|
+
"config": context_config.get("config", {}),
|
|
78
|
+
"resources": context_config.get("resources", []),
|
|
79
|
+
"device": device,
|
|
80
|
+
"hasCapability": lambda cap: cap in capabilities,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
85
|
+
# SERVER
|
|
86
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DevHttpPlayerServer:
|
|
90
|
+
"""
|
|
91
|
+
HTTP dev player server backed by aiohttp.
|
|
92
|
+
|
|
93
|
+
Manages sessions, transports, and the aiohttp web application.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
port: int,
|
|
100
|
+
host: bool = False,
|
|
101
|
+
transport_mode: str = "sse",
|
|
102
|
+
config: dict[str, Any],
|
|
103
|
+
) -> None:
|
|
104
|
+
self._port = port
|
|
105
|
+
self._bind_host = "0.0.0.0" if host else "127.0.0.1"
|
|
106
|
+
self._transport_mode = transport_mode
|
|
107
|
+
self._config = config
|
|
108
|
+
self._sessions: dict[str, AppHost] = {}
|
|
109
|
+
|
|
110
|
+
# Transport manager
|
|
111
|
+
factory: Callable[[str], Any]
|
|
112
|
+
if transport_mode == "sse":
|
|
113
|
+
factory = lambda sid: HttpSseHostTransport(sid)
|
|
114
|
+
else:
|
|
115
|
+
factory = lambda sid: HttpPollingHostTransport(sid)
|
|
116
|
+
self._manager = HttpHostTransportManager(factory)
|
|
117
|
+
|
|
118
|
+
# Shutdown event — SSE handlers wait on this to exit promptly
|
|
119
|
+
self._shutdown_event = asyncio.Event()
|
|
120
|
+
|
|
121
|
+
# SSE bookkeeping
|
|
122
|
+
self._sse_connections: list[dict[str, Any]] = []
|
|
123
|
+
|
|
124
|
+
# Polling idle timers
|
|
125
|
+
self._poll_idle_timers: dict[str, asyncio.TimerHandle] = {}
|
|
126
|
+
|
|
127
|
+
# Proxy sessions that need cleanup
|
|
128
|
+
self._proxies: list[MockProxy] = []
|
|
129
|
+
|
|
130
|
+
# Global message logger
|
|
131
|
+
self._manager.on_message(
|
|
132
|
+
lambda sid, msg: logger.info(
|
|
133
|
+
"[AppStrata Dev HTTP Player] [%s] <- %s", sid, msg.get("type")
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ── Config updates ────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def update_config(self, new_config: dict[str, Any]) -> None:
|
|
140
|
+
"""Handle a config update (hot reload from Node CLI)."""
|
|
141
|
+
self._config = new_config
|
|
142
|
+
capabilities = resolve_capabilities(new_config.get("capabilities"))
|
|
143
|
+
new_services = build_capability_services(capabilities)
|
|
144
|
+
new_context = build_app_context(
|
|
145
|
+
new_config.get("context", {}), capabilities
|
|
146
|
+
)
|
|
147
|
+
for session_id, app_host in self._sessions.items():
|
|
148
|
+
logger.info(
|
|
149
|
+
"[AppStrata Dev HTTP Player] [%s] Updating context", session_id
|
|
150
|
+
)
|
|
151
|
+
app_host.update_services(new_services)
|
|
152
|
+
app_host.update_context(new_context)
|
|
153
|
+
|
|
154
|
+
# ── Session management ────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
def _ensure_session(self, session_id: str) -> None:
|
|
157
|
+
if session_id in self._sessions:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
logger.info("[%s] New session", session_id)
|
|
161
|
+
|
|
162
|
+
transport = self._manager.get_transport(session_id)
|
|
163
|
+
capabilities = resolve_capabilities(self._config.get("capabilities"))
|
|
164
|
+
context = build_app_context(self._config.get("context", {}), capabilities)
|
|
165
|
+
services = build_capability_services(capabilities)
|
|
166
|
+
|
|
167
|
+
# Track proxy for cleanup
|
|
168
|
+
if services.proxy:
|
|
169
|
+
self._proxies.append(services.proxy)
|
|
170
|
+
|
|
171
|
+
lifecycle = self._config.get("lifecycle", {})
|
|
172
|
+
init_delay = lifecycle.get("initDelay", 0) / 1000.0
|
|
173
|
+
show_delay = lifecycle.get("showDelay", 50) / 1000.0
|
|
174
|
+
start_delay = lifecycle.get("startDelay", 50) / 1000.0
|
|
175
|
+
hide_delay = lifecycle.get("hideDelay", 100) / 1000.0
|
|
176
|
+
stop_delay = lifecycle.get("stopDelay", 50) / 1000.0
|
|
177
|
+
|
|
178
|
+
app_host = AppHost(
|
|
179
|
+
services=services,
|
|
180
|
+
transport=transport,
|
|
181
|
+
debug=True,
|
|
182
|
+
retry_interval=0.5,
|
|
183
|
+
mark_complete=lambda: self._on_mark_complete(
|
|
184
|
+
session_id, hide_delay, stop_delay
|
|
185
|
+
),
|
|
186
|
+
log_fn=lambda level, message, data: logger.info(
|
|
187
|
+
"[AppStrata Dev HTTP Player] [%s] [%s] %s %s",
|
|
188
|
+
session_id,
|
|
189
|
+
level,
|
|
190
|
+
message,
|
|
191
|
+
data or "",
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
loop = asyncio.get_event_loop()
|
|
196
|
+
|
|
197
|
+
def _fire_init() -> None:
|
|
198
|
+
logger.info(
|
|
199
|
+
"[AppStrata Dev HTTP Player] [%s] fireInit", session_id
|
|
200
|
+
)
|
|
201
|
+
app_host.fire_init(context)
|
|
202
|
+
|
|
203
|
+
def _fire_show() -> None:
|
|
204
|
+
logger.info(
|
|
205
|
+
"[AppStrata Dev HTTP Player] [%s] fireShow", session_id
|
|
206
|
+
)
|
|
207
|
+
app_host.fire_show()
|
|
208
|
+
|
|
209
|
+
def _fire_start() -> None:
|
|
210
|
+
logger.info(
|
|
211
|
+
"[AppStrata Dev HTTP Player] [%s] fireStart", session_id
|
|
212
|
+
)
|
|
213
|
+
app_host.fire_start()
|
|
214
|
+
|
|
215
|
+
loop.call_later(init_delay, _fire_init)
|
|
216
|
+
loop.call_later(init_delay + show_delay, _fire_show)
|
|
217
|
+
loop.call_later(init_delay + show_delay + start_delay, _fire_start)
|
|
218
|
+
|
|
219
|
+
self._sessions[session_id] = app_host
|
|
220
|
+
|
|
221
|
+
def _on_mark_complete(
|
|
222
|
+
self, session_id: str, hide_delay: float, stop_delay: float
|
|
223
|
+
) -> None:
|
|
224
|
+
logger.info(
|
|
225
|
+
"[AppStrata Dev HTTP Player] [%s] App marked complete", session_id
|
|
226
|
+
)
|
|
227
|
+
app_host = self._sessions.get(session_id)
|
|
228
|
+
if not app_host:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
loop = asyncio.get_event_loop()
|
|
232
|
+
loop.call_later(
|
|
233
|
+
hide_delay,
|
|
234
|
+
lambda: (
|
|
235
|
+
logger.info(
|
|
236
|
+
"[AppStrata Dev HTTP Player] [%s] fireHide", session_id
|
|
237
|
+
),
|
|
238
|
+
app_host.fire_hide(),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
loop.call_later(
|
|
242
|
+
hide_delay + stop_delay,
|
|
243
|
+
lambda: (
|
|
244
|
+
logger.info(
|
|
245
|
+
"[AppStrata Dev HTTP Player] [%s] fireStop", session_id
|
|
246
|
+
),
|
|
247
|
+
app_host.fire_stop(),
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _destroy_session(self, session_id: str) -> None:
|
|
252
|
+
app_host = self._sessions.pop(session_id, None)
|
|
253
|
+
if app_host:
|
|
254
|
+
app_host.destroy()
|
|
255
|
+
self._manager.close_session(session_id)
|
|
256
|
+
|
|
257
|
+
# ── Polling idle timer ────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
def _reset_poll_idle_timer(self, session_id: str) -> None:
|
|
260
|
+
existing = self._poll_idle_timers.pop(session_id, None)
|
|
261
|
+
if existing:
|
|
262
|
+
existing.cancel()
|
|
263
|
+
|
|
264
|
+
loop = asyncio.get_event_loop()
|
|
265
|
+
self._poll_idle_timers[session_id] = loop.call_later(
|
|
266
|
+
POLL_IDLE_TIMEOUT,
|
|
267
|
+
lambda: (
|
|
268
|
+
logger.info(
|
|
269
|
+
"[AppStrata Dev HTTP Player] [%s] No poll received in %ds — destroying session",
|
|
270
|
+
session_id,
|
|
271
|
+
POLL_IDLE_TIMEOUT,
|
|
272
|
+
),
|
|
273
|
+
self._poll_idle_timers.pop(session_id, None),
|
|
274
|
+
self._destroy_session(session_id),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# ── HTTP routes ───────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
async def _handle_send(self, request: web.Request) -> web.Response:
|
|
281
|
+
"""POST /api/send — client sends messages to the player."""
|
|
282
|
+
try:
|
|
283
|
+
body = await request.json()
|
|
284
|
+
except Exception:
|
|
285
|
+
return web.json_response(
|
|
286
|
+
{"ok": False, "error": "Invalid JSON"}, status=400
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
session_id = request.headers.get("X-Session-Id")
|
|
290
|
+
if not session_id:
|
|
291
|
+
return web.json_response(
|
|
292
|
+
{"ok": False, "error": "Missing X-Session-Id header"}, status=400
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
self._ensure_session(session_id)
|
|
296
|
+
self._manager.get_transport(session_id).handle_incoming_message(body)
|
|
297
|
+
return web.json_response({"ok": True})
|
|
298
|
+
|
|
299
|
+
async def _handle_receive(self, request: web.Request) -> web.StreamResponse:
|
|
300
|
+
"""GET /api/receive — player sends messages to the client."""
|
|
301
|
+
session_id = request.query.get("sessionId")
|
|
302
|
+
if not session_id:
|
|
303
|
+
return web.Response(text="Missing sessionId", status=400)
|
|
304
|
+
|
|
305
|
+
self._ensure_session(session_id)
|
|
306
|
+
|
|
307
|
+
if self._transport_mode == "sse":
|
|
308
|
+
return await self._handle_receive_sse(request, session_id)
|
|
309
|
+
else:
|
|
310
|
+
return await self._handle_receive_polling(request, session_id)
|
|
311
|
+
|
|
312
|
+
async def _handle_receive_sse(
|
|
313
|
+
self, request: web.Request, session_id: str
|
|
314
|
+
) -> web.StreamResponse:
|
|
315
|
+
logger.info(
|
|
316
|
+
"[AppStrata Dev HTTP Player] [%s] SSE connection opened", session_id
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
response = web.StreamResponse(
|
|
320
|
+
headers={
|
|
321
|
+
"Content-Type": "text/event-stream",
|
|
322
|
+
"Cache-Control": "no-cache",
|
|
323
|
+
"Connection": "keep-alive",
|
|
324
|
+
"Access-Control-Allow-Origin": "*",
|
|
325
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
326
|
+
"Access-Control-Allow-Headers": "Content-Type, X-Session-Id",
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
await response.prepare(request)
|
|
330
|
+
await response.write(b": connected\n\n")
|
|
331
|
+
|
|
332
|
+
transport = self._manager.get_transport(session_id)
|
|
333
|
+
assert isinstance(transport, HttpSseHostTransport)
|
|
334
|
+
|
|
335
|
+
# Use a queue to serialize all writes to the response.
|
|
336
|
+
# The sse_writer (called synchronously by the transport) puts data
|
|
337
|
+
# into the queue; the loop below drains it and writes to the response.
|
|
338
|
+
# This avoids concurrent writes to the same StreamResponse.
|
|
339
|
+
write_queue: asyncio.Queue[str] = asyncio.Queue()
|
|
340
|
+
|
|
341
|
+
def sse_writer(data: str) -> None:
|
|
342
|
+
write_queue.put_nowait(data)
|
|
343
|
+
|
|
344
|
+
cleanup = transport.set_sse_writer(sse_writer)
|
|
345
|
+
conn_info = {"res": response, "cleanup": cleanup}
|
|
346
|
+
self._sse_connections.append(conn_info)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
shutdown_wait = asyncio.ensure_future(self._shutdown_event.wait())
|
|
350
|
+
while True:
|
|
351
|
+
get_data = asyncio.ensure_future(write_queue.get())
|
|
352
|
+
done, _ = await asyncio.wait(
|
|
353
|
+
[get_data, shutdown_wait],
|
|
354
|
+
timeout=15,
|
|
355
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
356
|
+
)
|
|
357
|
+
if shutdown_wait in done:
|
|
358
|
+
get_data.cancel()
|
|
359
|
+
break
|
|
360
|
+
if get_data in done:
|
|
361
|
+
await response.write(get_data.result().encode())
|
|
362
|
+
else:
|
|
363
|
+
# Timeout — send heartbeat
|
|
364
|
+
get_data.cancel()
|
|
365
|
+
await response.write(b": heartbeat\n\n")
|
|
366
|
+
except (
|
|
367
|
+
ConnectionResetError,
|
|
368
|
+
ConnectionAbortedError,
|
|
369
|
+
ConnectionError,
|
|
370
|
+
OSError,
|
|
371
|
+
asyncio.CancelledError,
|
|
372
|
+
):
|
|
373
|
+
pass
|
|
374
|
+
finally:
|
|
375
|
+
logger.info(
|
|
376
|
+
"[AppStrata Dev HTTP Player] [%s] SSE connection closed — destroying session",
|
|
377
|
+
session_id,
|
|
378
|
+
)
|
|
379
|
+
cleanup()
|
|
380
|
+
self._sse_connections = [
|
|
381
|
+
c for c in self._sse_connections if c["res"] is not response
|
|
382
|
+
]
|
|
383
|
+
self._destroy_session(session_id)
|
|
384
|
+
|
|
385
|
+
return response
|
|
386
|
+
|
|
387
|
+
async def _handle_receive_polling(
|
|
388
|
+
self, request: web.Request, session_id: str
|
|
389
|
+
) -> web.Response:
|
|
390
|
+
self._reset_poll_idle_timer(session_id)
|
|
391
|
+
|
|
392
|
+
transport = self._manager.get_transport(session_id)
|
|
393
|
+
assert isinstance(transport, HttpPollingHostTransport)
|
|
394
|
+
|
|
395
|
+
messages = await transport.handle_poll_request(30_000)
|
|
396
|
+
if messages is None:
|
|
397
|
+
return web.json_response(
|
|
398
|
+
{"error": "Poll already active for this session"}, status=409
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return web.json_response({"messages": messages})
|
|
402
|
+
|
|
403
|
+
async def _handle_status(self, request: web.Request) -> web.Response:
|
|
404
|
+
"""GET / — status page."""
|
|
405
|
+
sessions = list(self._sessions.keys())
|
|
406
|
+
session_html = (
|
|
407
|
+
"<p>No active sessions</p>"
|
|
408
|
+
if not sessions
|
|
409
|
+
else "".join(f'<div class="session">{s}</div>' for s in sessions)
|
|
410
|
+
)
|
|
411
|
+
html = f"""<!DOCTYPE html>
|
|
412
|
+
<html>
|
|
413
|
+
<head>
|
|
414
|
+
<meta charset="UTF-8">
|
|
415
|
+
<title>AppStrata HTTP Dev Player (Python)</title>
|
|
416
|
+
<style>
|
|
417
|
+
body {{ font-family: system-ui, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; background: #f5f5f5; }}
|
|
418
|
+
h1 {{ color: #333; border-bottom: 2px solid #667eea; padding-bottom: 10px; }}
|
|
419
|
+
.info {{ background: white; padding: 20px; border-radius: 8px; margin: 20px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
|
|
420
|
+
.session {{ background: #e8f5e9; padding: 10px; margin: 5px 0; border-radius: 4px; font-family: monospace; font-size: 14px; }}
|
|
421
|
+
.badge {{ display: inline-block; background: #667eea; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px; }}
|
|
422
|
+
</style>
|
|
423
|
+
</head>
|
|
424
|
+
<body>
|
|
425
|
+
<h1>AppStrata HTTP Dev Player <span class="badge">Python</span></h1>
|
|
426
|
+
<div class="info">
|
|
427
|
+
<h3>Status: Running</h3>
|
|
428
|
+
<p>This server implements the AppStrata protocol over HTTP (Python runtime).</p>
|
|
429
|
+
<p>Transport mode: <strong>{self._transport_mode}</strong></p>
|
|
430
|
+
<div class="sessions">
|
|
431
|
+
<h3>Active Sessions:</h3>
|
|
432
|
+
{session_html}
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
</body>
|
|
436
|
+
</html>"""
|
|
437
|
+
return web.Response(text=html, content_type="text/html")
|
|
438
|
+
|
|
439
|
+
# ── CORS middleware ────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
@web.middleware
|
|
442
|
+
async def _cors_middleware(
|
|
443
|
+
self,
|
|
444
|
+
request: web.Request,
|
|
445
|
+
handler: Callable[..., Any],
|
|
446
|
+
) -> web.StreamResponse:
|
|
447
|
+
if request.method == "OPTIONS":
|
|
448
|
+
response = web.Response(status=204)
|
|
449
|
+
else:
|
|
450
|
+
response = await handler(request)
|
|
451
|
+
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
452
|
+
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
453
|
+
response.headers["Access-Control-Allow-Headers"] = "Content-Type, X-Session-Id"
|
|
454
|
+
return response
|
|
455
|
+
|
|
456
|
+
# ── Application setup ─────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
def create_app(self) -> web.Application:
|
|
459
|
+
app = web.Application(middlewares=[self._cors_middleware])
|
|
460
|
+
app.router.add_post("/api/send", self._handle_send)
|
|
461
|
+
app.router.add_get("/api/receive", self._handle_receive)
|
|
462
|
+
app.router.add_get("/", self._handle_status)
|
|
463
|
+
return app
|
|
464
|
+
|
|
465
|
+
async def start(self) -> web.AppRunner:
|
|
466
|
+
"""Start the server and return the runner (for clean shutdown)."""
|
|
467
|
+
app = self.create_app()
|
|
468
|
+
runner = web.AppRunner(app)
|
|
469
|
+
await runner.setup()
|
|
470
|
+
site = web.TCPSite(runner, self._bind_host, self._port)
|
|
471
|
+
await site.start()
|
|
472
|
+
|
|
473
|
+
logger.info("")
|
|
474
|
+
logger.info(
|
|
475
|
+
"[AppStrata Dev HTTP Player] HTTP Dev Player (Python) running on http://localhost:%d/",
|
|
476
|
+
self._port,
|
|
477
|
+
)
|
|
478
|
+
logger.info(" Transport mode: %s", self._transport_mode)
|
|
479
|
+
logger.info(
|
|
480
|
+
" POST http://localhost:%d/api/send", self._port
|
|
481
|
+
)
|
|
482
|
+
logger.info(
|
|
483
|
+
" GET http://localhost:%d/api/receive", self._port
|
|
484
|
+
)
|
|
485
|
+
logger.info("")
|
|
486
|
+
|
|
487
|
+
return runner
|
|
488
|
+
|
|
489
|
+
async def shutdown(self) -> None:
|
|
490
|
+
"""Clean shutdown."""
|
|
491
|
+
logger.info("[AppStrata Dev HTTP Player] Shutting down...")
|
|
492
|
+
# Signal all SSE handler loops to exit
|
|
493
|
+
self._shutdown_event.set()
|
|
494
|
+
# Give handlers a moment to exit cleanly
|
|
495
|
+
await asyncio.sleep(0.1)
|
|
496
|
+
# Close SSE connections
|
|
497
|
+
for conn in self._sse_connections:
|
|
498
|
+
conn["cleanup"]()
|
|
499
|
+
# Cancel poll timers
|
|
500
|
+
for timer in self._poll_idle_timers.values():
|
|
501
|
+
timer.cancel()
|
|
502
|
+
self._poll_idle_timers.clear()
|
|
503
|
+
# Close transports
|
|
504
|
+
self._manager.close_all()
|
|
505
|
+
# Close proxy sessions
|
|
506
|
+
for proxy in self._proxies:
|
|
507
|
+
await proxy.close()
|
|
508
|
+
logger.info("[AppStrata Dev HTTP Player] Server closed")
|