@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
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
|
+
}
|