@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 ADDED
@@ -0,0 +1,304 @@
1
+ # @appstrata/cli
2
+
3
+ Command-line interface for AppStrata Digital Signage SDK.
4
+
5
+ ## Installation
6
+
7
+ > **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`:
8
+ >
9
+ > ```
10
+ > //registry.npmjs.org/:_authToken=YOUR_TOKEN
11
+ > ```
12
+
13
+ ```bash
14
+ npm install -g @appstrata/cli
15
+ # or
16
+ pnpm add -g @appstrata/cli
17
+ ```
18
+
19
+ Or use via `npx`:
20
+
21
+ ```bash
22
+ npx @appstrata/cli dev
23
+ ```
24
+
25
+ ## Monorepo development
26
+
27
+ When working inside the AppStrata monorepo, use `npm link` to install the CLI globally from local source without publishing:
28
+
29
+ ```bash
30
+ cd packages/cli
31
+ npm link # creates a global symlink to this package
32
+ npm -g list # verify the link was created
33
+ appstrata --help
34
+
35
+ # to remove the link later
36
+ npm unlink -g @appstrata/cli
37
+ ```
38
+
39
+ To pick up code changes without relinking:
40
+
41
+ ```bash
42
+ pnpm build
43
+ # or watch mode (rebuilds automatically on change)
44
+ pnpm --filter @appstrata/cli dev
45
+ ```
46
+
47
+ ## Commands
48
+
49
+ ### `appstrata dev`
50
+
51
+ Start development player with App loaded in iframe.
52
+
53
+ Loads the user's app in an iframe and provides a mock player environment. Optionally relays messages to a remote HTTP player instead of using a local mock host.
54
+
55
+ ```bash
56
+ appstrata dev -u <app-url> [options]
57
+ ```
58
+
59
+ **Options:**
60
+
61
+ | Option | Required | Default | Description |
62
+ |--------|----------|---------|-------------|
63
+ | `-u, --url <url>` | ✓ | - | URL of app to load in player iframe |
64
+ | `-p, --port <port>` | - | `5173` | Port to run dev server on |
65
+ | `-c, --config <path>` | - | `appstrata.config.ts` | Path to config file |
66
+ | `--host` | - | - | Expose server to network |
67
+ | `-r, --relay <url>` | - | - | URL of remote HTTP player to relay to |
68
+ | `-t, --transport <mode>` | - | `sse` | HTTP transport mode for relay (`sse` or `polling`) |
69
+
70
+ **Examples:**
71
+
72
+ ```bash
73
+ # Local mock player (app must already be running on port 5174)
74
+ appstrata dev -u http://localhost:5174
75
+
76
+ # Custom port, exposed to network
77
+ appstrata dev -u http://localhost:5174 --port 3000 --host
78
+
79
+ # Relay to a remote HTTP player
80
+ # Terminal 1: start your app
81
+ cd my-app && npm run dev -- --port 5174
82
+
83
+ # Terminal 2: start the HTTP player host
84
+ appstrata dev-http-player --port 5175
85
+
86
+ # Terminal 3: start the dev player with relay
87
+ appstrata dev -u http://localhost:5174 --relay http://localhost:5175
88
+
89
+ # Use long-polling transport instead of SSE (both sides must match)
90
+ appstrata dev-http-player --port 5175 --transport polling
91
+ appstrata dev -u http://localhost:5174 --relay http://localhost:5175 --transport polling
92
+ ```
93
+
94
+ **Architecture (relay mode):**
95
+ ```
96
+ [App iframe] ◄─ postMessage ─► [RelayTransport] ◄─ HTTP ─► [Remote Player]
97
+ ```
98
+
99
+ **Note**: Your app doesn't need special configuration - it just uses `getPlayer()` normally. In relay mode, the relay handles all HTTP communication transparently.
100
+
101
+ ### `appstrata dev-http-player`
102
+
103
+ Start Node dev player with HTTP transport only.
104
+
105
+ This command starts an HTTP server that implements the AppStrata protocol via HTTP POST (receive) and either Server-Sent Events or long polling (send). Use this for testing HTTP transport or as a backend for relay scenarios.
106
+
107
+ ```bash
108
+ appstrata dev-http-player [options]
109
+ ```
110
+
111
+ **Options:**
112
+
113
+ | Option | Default | Description |
114
+ |--------|---------|-------------|
115
+ | `-p, --port <port>` | `5175` | Port for HTTP server |
116
+ | `--host` | - | Expose server to network |
117
+ | `-c, --config <path>` | - | Path to appstrata.config.ts |
118
+ | `-t, --transport <mode>` | `sse` | HTTP transport mode (`sse` or `polling`) |
119
+ | `--runtime <runtime>` | `node` | Player runtime (`node` or `python`) |
120
+ | `-s, --session-id <id>` | - | Session ID (optional) |
121
+
122
+ **Example:**
123
+
124
+ ```bash
125
+ # Start on default port (SSE mode, Node runtime)
126
+ appstrata dev-http-player
127
+
128
+ # Use long-polling transport
129
+ appstrata dev-http-player --transport polling
130
+
131
+ # Custom port with config
132
+ appstrata dev-http-player --port 3000 --config ./my-config.ts
133
+
134
+ # Expose to network
135
+ appstrata dev-http-player --host
136
+ ```
137
+
138
+ #### Python runtime
139
+
140
+ The dev HTTP player can also run on a Python runtime, which reimplements the same HTTP server using `aiohttp`. This requires Python 3.10+ on your system.
141
+
142
+ ```bash
143
+ # Use the Python runtime
144
+ appstrata dev-http-player --runtime python
145
+
146
+ # With options
147
+ appstrata dev-http-player --runtime python --port 5175 --transport sse
148
+ ```
149
+
150
+ On first run, the CLI will automatically:
151
+ 1. Find Python 3.10+ on your system
152
+ 2. Create a virtual environment at `.appstrata/python-venv/`
153
+ 3. Install dependencies into the venv
154
+
155
+ Subsequent runs reuse the existing venv. The CLI handles config loading from `appstrata.config.ts` and streams config updates to the Python process via stdin.
156
+
157
+ **API Endpoints:**
158
+ - `POST /api/send` - Client sends messages to the player (requires `X-Session-Id` header)
159
+ - `GET /api/receive?sessionId={id}` - Player sends messages to the client (SSE or long-polling, depending on transport mode)
160
+
161
+ **Status Page:**
162
+ - `GET /` - View server status and active sessions
163
+
164
+ ### `appstrata package`
165
+
166
+ Package an app into a zip bundle for CMS deployment (e.g., Yodeck).
167
+
168
+ ```bash
169
+ appstrata package [options]
170
+ ```
171
+
172
+ **Options:**
173
+
174
+ | Option | Default | Description |
175
+ |--------|---------|-------------|
176
+ | `-c, --config <path>` | `appstrata.config.ts` | Path to config file |
177
+ | `-o, --outDir <dir>` | `dist` | Build output directory |
178
+
179
+ **Inputs** (convention-based, resolved from CWD):
180
+
181
+ - `appstrata.config.ts` - app metadata and configuration inputs
182
+ - `dist/` - pre-built app output (must contain `index.html`)
183
+ - `.appstrata/package/` - marketplace assets (logo, screenshots, optional manual schema.json)
184
+
185
+ **Output:**
186
+
187
+ - `dist/<app-id>.zip` - self-contained bundle ready for CMS upload
188
+
189
+ **Required directory structure:**
190
+
191
+ ```
192
+ my-app/
193
+ .appstrata/
194
+ package/
195
+ logo.png # REQUIRED: app logo
196
+ screenshots/ # REQUIRED: at least one screenshot
197
+ 1.png
198
+ 2.png
199
+ schema.json # Optional: manual schema override
200
+ dist/ # Pre-built app (run your build first)
201
+ index.html
202
+ assets/
203
+ appstrata.config.ts
204
+ ```
205
+
206
+ **Example:**
207
+
208
+ ```bash
209
+ # Build your app first
210
+ npm run build
211
+
212
+ # Package into a zip bundle
213
+ appstrata package
214
+
215
+ # Custom output directory
216
+ appstrata package --outDir build
217
+ ```
218
+
219
+ **Schema generation:** If `.appstrata/package/schema.json` exists, it is copied as-is. Otherwise, a Yodeck-compatible schema is auto-generated from `app.configuration.inputs` in your config.
220
+
221
+ **Asset mapping to Yodeck:** The CLI maps assets to Yodeck's `{usage}` naming convention:
222
+ - `logo.png` -> `_fileassets/<app-id>{logo}.png`
223
+ - `screenshots/<name>.png` -> `_fileassets/<name>{slides}.png`
224
+
225
+ ## What It Does
226
+
227
+ The `dev` command starts a Vite dev server with the AppStrata plugin, which:
228
+
229
+ 1. Injects a mock player into your app
230
+ 2. Serves mock context from `/__appstrata__/context`
231
+ 3. Fires lifecycle events (`onInit`, `onShow`, `onStart`)
232
+ 4. Provides mock storage and proxy APIs
233
+ 5. Supports HMR for config changes
234
+
235
+ ## Configuration
236
+
237
+ The CLI supports two config formats. Choose the one that fits your project.
238
+
239
+ ### Option A: TypeScript config (recommended for projects with `package.json`)
240
+
241
+ ```bash
242
+ npm install -D @appstrata/dev
243
+ ```
244
+
245
+ ```typescript
246
+ // appstrata.config.ts
247
+ import { defineConfig } from "@appstrata/dev";
248
+
249
+ export default defineConfig({
250
+ app: {
251
+ id: "my-app",
252
+ name: "My Signage App",
253
+ version: "1.0.0",
254
+ },
255
+ dev: {
256
+ context: {
257
+ config: { title: "Hello World" },
258
+ viewportWidth: 1920,
259
+ viewportHeight: 1080,
260
+ },
261
+ },
262
+ });
263
+ ```
264
+
265
+ With `@appstrata/dev` installed, you get full IDE autocomplete and type checking. You can also import any other package from your project's `node_modules` in the config file.
266
+
267
+ > **Note**: The TypeScript config also works without installing `@appstrata/dev` — the CLI resolves the `defineConfig` import automatically. However, your IDE won't provide autocomplete without the package installed locally.
268
+
269
+ ### Option B: JSON config (zero setup, no dependencies)
270
+
271
+ ```json
272
+ // appstrata.config.json
273
+ {
274
+ "app": {
275
+ "id": "my-app",
276
+ "name": "My Signage App",
277
+ "version": "1.0.0"
278
+ },
279
+ "dev": {
280
+ "context": {
281
+ "config": { "title": "Hello World" },
282
+ "viewportWidth": 1920,
283
+ "viewportHeight": 1080
284
+ }
285
+ }
286
+ }
287
+ ```
288
+
289
+ No `package.json`, no `node_modules`, no build tools required. Just create the JSON file and run `appstrata dev`.
290
+
291
+ ### Config file resolution
292
+
293
+ The CLI looks for config files in this order:
294
+
295
+ 1. `appstrata.config.ts` (TypeScript)
296
+ 2. `appstrata.config.json` (JSON fallback)
297
+
298
+ Use `-c` / `--config` to specify a custom path.
299
+
300
+ See `@appstrata/dev` for full configuration reference.
301
+
302
+ ## License
303
+
304
+ MIT
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `appstrata dev-http-player` — Start a dev player with HTTP transport.
3
+ *
4
+ * Exposes endpoints based on the selected transport mode:
5
+ *
6
+ * **SSE mode (default):**
7
+ * POST /api/send — client sends messages to the player
8
+ * GET /api/receive — player sends messages to the client (SSE)
9
+ *
10
+ * **Polling mode:**
11
+ * POST /api/send — client sends messages to the player
12
+ * GET /api/receive — player sends messages to the client (long-polling)
13
+ */
14
+ interface DevHttpPlayerOptions {
15
+ port: string;
16
+ host?: boolean;
17
+ config: string;
18
+ transport?: "sse" | "polling";
19
+ runtime?: string;
20
+ }
21
+ export declare function devHttpPlayerCommand(options: DevHttpPlayerOptions): Promise<void>;
22
+ export {};
23
+ //# sourceMappingURL=dev-http-player.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dev-http-player.d.ts","sourceRoot":"","sources":["../../src/commands/dev-http-player.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAkDH,UAAU,oBAAoB;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAmFD,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmSvF"}
@@ -0,0 +1,360 @@
1
+ /**
2
+ * `appstrata dev-http-player` — Start a dev player with HTTP transport.
3
+ *
4
+ * Exposes endpoints based on the selected transport mode:
5
+ *
6
+ * **SSE mode (default):**
7
+ * POST /api/send — client sends messages to the player
8
+ * GET /api/receive — player sends messages to the client (SSE)
9
+ *
10
+ * **Polling mode:**
11
+ * POST /api/send — client sends messages to the player
12
+ * GET /api/receive — player sends messages to the client (long-polling)
13
+ */
14
+ import * as http from "node:http";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { createAppHost } from "@appstrata/player-lib";
18
+ import { createHttpHostTransportManager, createHttpSseHostTransport, createHttpPollingHostTransport, } from "@appstrata/protocol";
19
+ import { buildAppContext, resolveCapabilities, buildCapabilityServices, DEFAULT_CONTEXT, DEFAULT_LIFECYCLE, } from "@appstrata/dev";
20
+ import { loadConfig } from "@appstrata/dev";
21
+ import { renderStatusPage } from "../status-page.js";
22
+ import { getRuntime } from "../runtimes/index.js";
23
+ // ── Transport handlers ───────────────────────────────────────────────────
24
+ function createSseHandler(deps) {
25
+ const { manager, destroySession } = deps;
26
+ const sseConnections = [];
27
+ return {
28
+ handleReceiveRequest(req, res, sessionId) {
29
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] SSE connection opened`);
30
+ res.writeHead(200, {
31
+ "Content-Type": "text/event-stream",
32
+ "Cache-Control": "no-cache",
33
+ "Connection": "keep-alive",
34
+ });
35
+ res.write(": connected\n\n");
36
+ const transport = manager.getTransport(sessionId);
37
+ const cleanup = transport.setSseWriter((data) => res.write(data));
38
+ sseConnections.push({ res, cleanup });
39
+ req.on("close", () => {
40
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] SSE connection closed — destroying session`);
41
+ cleanup();
42
+ const idx = sseConnections.findIndex((c) => c.res === res);
43
+ if (idx >= 0)
44
+ sseConnections.splice(idx, 1);
45
+ destroySession(sessionId);
46
+ });
47
+ },
48
+ cleanup() {
49
+ for (const conn of sseConnections) {
50
+ conn.cleanup();
51
+ conn.res.end();
52
+ }
53
+ },
54
+ };
55
+ }
56
+ function createPollingHandler(deps) {
57
+ const { manager, destroySession } = deps;
58
+ const pollIdleTimers = new Map();
59
+ const POLL_IDLE_TIMEOUT = 60000; // 1 minute without a poll = session dead
60
+ function resetPollIdleTimer(sessionId) {
61
+ const existing = pollIdleTimers.get(sessionId);
62
+ if (existing)
63
+ clearTimeout(existing);
64
+ pollIdleTimers.set(sessionId, setTimeout(() => {
65
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] No poll received in ${POLL_IDLE_TIMEOUT / 1000}s — destroying session`);
66
+ pollIdleTimers.delete(sessionId);
67
+ destroySession(sessionId);
68
+ }, POLL_IDLE_TIMEOUT));
69
+ }
70
+ return {
71
+ handleReceiveRequest(_req, res, sessionId) {
72
+ resetPollIdleTimer(sessionId);
73
+ const transport = manager.getTransport(sessionId);
74
+ transport.handlePollRequest(30000).then((messages) => {
75
+ if (messages === null) {
76
+ res.writeHead(409, { "Content-Type": "application/json" });
77
+ res.end(JSON.stringify({ error: "Poll already active for this session" }));
78
+ }
79
+ else {
80
+ res.writeHead(200, { "Content-Type": "application/json" });
81
+ res.end(JSON.stringify({ messages }));
82
+ }
83
+ });
84
+ },
85
+ cleanup() {
86
+ for (const timer of pollIdleTimers.values())
87
+ clearTimeout(timer);
88
+ pollIdleTimers.clear();
89
+ },
90
+ };
91
+ }
92
+ export async function devHttpPlayerCommand(options) {
93
+ const port = parseInt(options.port, 10);
94
+ const transportMode = options.transport ?? "sse";
95
+ const runtimeName = options.runtime ?? "node";
96
+ // ── External runtime delegation ──────────────────────────────────────
97
+ // When a non-Node runtime is selected, delegate to it entirely.
98
+ // The Node process becomes a thin wrapper: it loads the .ts config,
99
+ // watches for changes, and streams JSON updates to the runtime's stdin.
100
+ if (runtimeName !== "node") {
101
+ const runtime = getRuntime(runtimeName);
102
+ const projectRoot = process.cwd();
103
+ console.log(`\n Runtime: ${runtime.name}`);
104
+ await runtime.ensureReady(projectRoot);
105
+ const result = await loadConfig(options.config);
106
+ let config = result?.config ?? null;
107
+ const configPath = result?.filePath ?? null;
108
+ if (configPath) {
109
+ console.log(` Loaded config from ${configPath}`);
110
+ }
111
+ else {
112
+ console.log(` No config file found, using defaults`);
113
+ }
114
+ // Spawn the external runtime process
115
+ const proc = runtime.spawn({
116
+ port,
117
+ host: !!options.host,
118
+ transport: transportMode,
119
+ });
120
+ // Send initial config via stdin
121
+ const devConfig = config?.dev ?? {};
122
+ proc.sendConfig(devConfig);
123
+ // Watch config for hot reload and stream updates
124
+ let debounceTimer = null;
125
+ const watcher = configPath ? fs.watch(configPath, () => {
126
+ if (debounceTimer)
127
+ clearTimeout(debounceTimer);
128
+ debounceTimer = setTimeout(async () => {
129
+ console.log(`[AppStrata Dev HTTP Player] Config changed, reloading...`);
130
+ const reloaded = await loadConfig(configPath);
131
+ if (reloaded) {
132
+ config = reloaded.config;
133
+ proc.sendConfig(config?.dev ?? {});
134
+ }
135
+ else {
136
+ console.warn(` Could not reload config from ${configPath}`);
137
+ }
138
+ }, 100);
139
+ }) : null;
140
+ // Forward SIGINT to the runtime process
141
+ process.once("SIGINT", () => {
142
+ console.log(`\n[AppStrata Dev HTTP Player] Shutting down ${runtime.name} runtime...`);
143
+ watcher?.close();
144
+ if (debounceTimer)
145
+ clearTimeout(debounceTimer);
146
+ proc.kill();
147
+ });
148
+ // Wait for the process to exit
149
+ const exitCode = await proc.exited;
150
+ console.log(`[AppStrata Dev HTTP Player] ${runtime.name} process exited (code: ${exitCode})`);
151
+ return;
152
+ }
153
+ // ── Node runtime (original implementation) ───────────────────────────
154
+ const result = await loadConfig(options.config);
155
+ let config = result?.config ?? null;
156
+ const configPath = result?.filePath ?? null;
157
+ if (configPath) {
158
+ console.log(` Loaded config from ${configPath}`);
159
+ }
160
+ else {
161
+ console.log(` No config file found, using defaults`);
162
+ }
163
+ // Create transport manager with the appropriate factory for the selected mode
164
+ const transportFactory = transportMode === "sse"
165
+ ? (sessionId) => createHttpSseHostTransport({ sessionId })
166
+ : (sessionId) => createHttpPollingHostTransport({ sessionId });
167
+ const manager = createHttpHostTransportManager(transportFactory);
168
+ const sessions = new Map();
169
+ /** Create an AppHost on first contact with a session. */
170
+ function ensureSession(sessionId) {
171
+ if (sessions.has(sessionId))
172
+ return;
173
+ console.log(`[${sessionId}] New session`);
174
+ const transport = manager.getTransport(sessionId);
175
+ const capabilities = resolveCapabilities(config?.dev?.capabilities);
176
+ const context = buildAppContext(config?.dev?.context ?? DEFAULT_CONTEXT, capabilities);
177
+ const services = buildCapabilityServices(capabilities);
178
+ const lifecycle = config?.dev?.lifecycle ?? DEFAULT_LIFECYCLE;
179
+ const initDelay = lifecycle.initDelay ?? 0;
180
+ const showDelay = lifecycle.showDelay ?? 50;
181
+ const startDelay = lifecycle.startDelay ?? 50;
182
+ const hideDelay = lifecycle.hideDelay ?? 100;
183
+ const stopDelay = lifecycle.stopDelay ?? 50;
184
+ const appHost = createAppHost({
185
+ services,
186
+ target: {},
187
+ transport,
188
+ retryInterval: 500,
189
+ notifyComplete: () => {
190
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] App called notifyComplete()`);
191
+ setTimeout(() => {
192
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] fireHide`);
193
+ appHost.fireHide();
194
+ }, hideDelay);
195
+ setTimeout(() => {
196
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] fireStop`);
197
+ appHost.fireStop();
198
+ }, hideDelay + stopDelay);
199
+ },
200
+ notifyEstimatedEnd: (expectedFinishTime) => {
201
+ const secsUntil = Math.round((expectedFinishTime - Date.now()) / 1000);
202
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] App called notifyEstimatedEnd() — finishes in ~${secsUntil}s (${new Date(expectedFinishTime).toISOString()})`);
203
+ },
204
+ notifyNoContent: (options) => {
205
+ const parts = [`[AppStrata Dev HTTP Player] [${sessionId}] App called notifyNoContent()`];
206
+ if (options?.reason)
207
+ parts.push(`reason="${options.reason}"`);
208
+ if (options?.retryNotBefore) {
209
+ const secsUntil = Math.round((options.retryNotBefore - Date.now()) / 1000);
210
+ parts.push(`retry in ~${secsUntil}s`);
211
+ }
212
+ console.log(parts.join(" — "));
213
+ setTimeout(() => {
214
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] fireHide`);
215
+ appHost.fireHide();
216
+ }, hideDelay);
217
+ setTimeout(() => {
218
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] fireStop`);
219
+ appHost.fireStop();
220
+ }, hideDelay + stopDelay);
221
+ },
222
+ log: (level, message, data) => {
223
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] [${level}] ${message}`, data || "");
224
+ },
225
+ });
226
+ setTimeout(() => {
227
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] fireInit`);
228
+ appHost.fireInit(context);
229
+ }, initDelay);
230
+ setTimeout(() => {
231
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] fireShow`);
232
+ appHost.fireShow();
233
+ }, initDelay + showDelay);
234
+ setTimeout(() => {
235
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] fireStart`);
236
+ appHost.fireStart();
237
+ }, initDelay + showDelay + startDelay);
238
+ sessions.set(sessionId, appHost);
239
+ }
240
+ /** Destroy an AppHost and close the transport for a session. */
241
+ function destroySession(sessionId) {
242
+ const appHost = sessions.get(sessionId);
243
+ if (appHost) {
244
+ appHost.destroy();
245
+ sessions.delete(sessionId);
246
+ }
247
+ manager.closeSession(sessionId);
248
+ }
249
+ // Create transport handler for the selected mode
250
+ const handler = transportMode === "sse"
251
+ ? createSseHandler({ manager, destroySession })
252
+ : createPollingHandler({ manager, destroySession });
253
+ manager.onMessage((sessionId, msg) => {
254
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] <- ${msg.type}`);
255
+ });
256
+ // --- HTTP server ---
257
+ const server = http.createServer((req, res) => {
258
+ const url = new URL(req.url || "/", `http://localhost`);
259
+ // CORS
260
+ res.setHeader("Access-Control-Allow-Origin", "*");
261
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
262
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Session-Id");
263
+ if (req.method === "OPTIONS") {
264
+ res.writeHead(204);
265
+ res.end();
266
+ return;
267
+ }
268
+ // POST /api/send (client -> server)
269
+ if (req.method === "POST" && url.pathname === "/api/send") {
270
+ let body = "";
271
+ req.on("data", (chunk) => { body += chunk; });
272
+ req.on("end", () => {
273
+ try {
274
+ const message = JSON.parse(body);
275
+ const sessionId = req.headers["x-session-id"];
276
+ if (!sessionId) {
277
+ res.writeHead(400, { "Content-Type": "application/json" });
278
+ res.end(JSON.stringify({ ok: false, error: "Missing X-Session-Id header" }));
279
+ return;
280
+ }
281
+ ensureSession(sessionId);
282
+ manager.getTransport(sessionId).handleIncomingMessage(message);
283
+ res.writeHead(200, { "Content-Type": "application/json" });
284
+ res.end(JSON.stringify({ ok: true }));
285
+ }
286
+ catch {
287
+ res.writeHead(400, { "Content-Type": "application/json" });
288
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
289
+ }
290
+ });
291
+ return;
292
+ }
293
+ // GET /api/receive (server -> client)
294
+ if (req.method === "GET" && url.pathname === "/api/receive") {
295
+ const sessionId = url.searchParams.get("sessionId");
296
+ if (!sessionId) {
297
+ res.writeHead(400);
298
+ res.end("Missing sessionId");
299
+ return;
300
+ }
301
+ ensureSession(sessionId);
302
+ handler.handleReceiveRequest(req, res, sessionId);
303
+ return;
304
+ }
305
+ // GET / — status page
306
+ if (req.method === "GET" && url.pathname === "/") {
307
+ res.writeHead(200, { "Content-Type": "text/html" });
308
+ res.end(renderStatusPage(port, Array.from(sessions.keys())));
309
+ return;
310
+ }
311
+ res.writeHead(404);
312
+ res.end("Not Found");
313
+ });
314
+ // --- Start ---
315
+ await new Promise((resolve) => {
316
+ server.listen(port, options.host ? "0.0.0.0" : "127.0.0.1", resolve);
317
+ });
318
+ console.log(`\n[AppStrata Dev HTTP Player] HTTP Dev Player running on http://localhost:${port}/`);
319
+ console.log(` Transport mode: ${transportMode}`);
320
+ console.log(` Config file: ${configPath ?? options.config}`);
321
+ console.log(` POST http://localhost:${port}/api/send`);
322
+ console.log(` GET http://localhost:${port}/api/receive`);
323
+ console.log(`\n Edit ${configPath ? path.basename(configPath) : "appstrata.config.ts"} to change mock context (hot reload supported)\n`);
324
+ // Watch config file for hot reload
325
+ let debounceTimer = null;
326
+ const watcher = configPath ? fs.watch(configPath, () => {
327
+ if (debounceTimer)
328
+ clearTimeout(debounceTimer);
329
+ debounceTimer = setTimeout(async () => {
330
+ console.log("[AppStrata Dev HTTP Player] Config changed, reloading...");
331
+ const reloaded = await loadConfig(configPath);
332
+ if (!reloaded) {
333
+ console.warn(` Could not reload config from ${configPath}`);
334
+ return;
335
+ }
336
+ config = reloaded.config;
337
+ const capabilities = resolveCapabilities(config?.dev?.capabilities);
338
+ const newServices = buildCapabilityServices(capabilities);
339
+ const newContext = buildAppContext(config?.dev?.context ?? DEFAULT_CONTEXT, capabilities);
340
+ for (const [sessionId, appHost] of sessions) {
341
+ console.log(`[AppStrata Dev HTTP Player] [${sessionId}] Updating context`);
342
+ appHost.updateServices(newServices);
343
+ appHost.updateContext(newContext);
344
+ }
345
+ }, 100);
346
+ }) : null;
347
+ process.once("SIGINT", () => {
348
+ console.log("\n[AppStrata Dev HTTP Player] Shutting down...");
349
+ watcher?.close();
350
+ if (debounceTimer)
351
+ clearTimeout(debounceTimer);
352
+ handler.cleanup();
353
+ manager.closeAll();
354
+ server.close(() => { console.log("[AppStrata Dev HTTP Player] Server closed\n"); });
355
+ setTimeout(() => {
356
+ console.log("[AppStrata Dev HTTP Player] Exiting...");
357
+ process.exit(0);
358
+ }, 2000);
359
+ });
360
+ }