@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.
@@ -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")