@jetstart/core 1.7.0 → 2.0.1

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.
Files changed (40) hide show
  1. package/README.md +189 -74
  2. package/dist/build/dex-generator.d.ts +27 -0
  3. package/dist/build/dex-generator.js +202 -0
  4. package/dist/build/dsl-parser.d.ts +3 -30
  5. package/dist/build/dsl-parser.js +67 -240
  6. package/dist/build/dsl-types.d.ts +8 -0
  7. package/dist/build/gradle.d.ts +51 -0
  8. package/dist/build/gradle.js +233 -1
  9. package/dist/build/hot-reload-service.d.ts +36 -0
  10. package/dist/build/hot-reload-service.js +179 -0
  11. package/dist/build/js-compiler-service.d.ts +61 -0
  12. package/dist/build/js-compiler-service.js +421 -0
  13. package/dist/build/kotlin-compiler.d.ts +54 -0
  14. package/dist/build/kotlin-compiler.js +450 -0
  15. package/dist/build/kotlin-parser.d.ts +91 -0
  16. package/dist/build/kotlin-parser.js +1030 -0
  17. package/dist/build/override-generator.d.ts +54 -0
  18. package/dist/build/override-generator.js +430 -0
  19. package/dist/server/index.d.ts +16 -1
  20. package/dist/server/index.js +147 -42
  21. package/dist/websocket/handler.d.ts +20 -4
  22. package/dist/websocket/handler.js +73 -38
  23. package/dist/websocket/index.d.ts +8 -0
  24. package/dist/websocket/index.js +15 -11
  25. package/dist/websocket/manager.d.ts +2 -2
  26. package/dist/websocket/manager.js +1 -1
  27. package/package.json +3 -3
  28. package/src/build/dex-generator.ts +197 -0
  29. package/src/build/dsl-parser.ts +73 -272
  30. package/src/build/dsl-types.ts +9 -0
  31. package/src/build/gradle.ts +259 -1
  32. package/src/build/hot-reload-service.ts +178 -0
  33. package/src/build/js-compiler-service.ts +411 -0
  34. package/src/build/kotlin-compiler.ts +460 -0
  35. package/src/build/kotlin-parser.ts +1043 -0
  36. package/src/build/override-generator.ts +478 -0
  37. package/src/server/index.ts +162 -54
  38. package/src/websocket/handler.ts +94 -56
  39. package/src/websocket/index.ts +27 -14
  40. package/src/websocket/manager.ts +2 -2
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import * as path from 'path';
7
+ import * as fs from 'fs';
7
8
  import * as os from 'os';
8
9
  import { EventEmitter } from 'events';
9
10
  import { createHttpServer } from './http';
@@ -14,14 +15,20 @@ import { DEFAULT_CORE_PORT, DEFAULT_WS_PORT } from '@jetstart/shared';
14
15
  import { SessionManager } from '../utils/session';
15
16
  import { BuildService } from '../build';
16
17
  import { ServerSession } from '../types';
17
- import { DSLParser } from '../build/dsl-parser';
18
18
  import { injectBuildConfigFields } from '../build/gradle-injector';
19
+ import { HotReloadService } from '../build/hot-reload-service';
20
+ import { JsCompilerService } from '../build/js-compiler-service';
21
+ import { AdbHelper } from '../build/gradle';
19
22
 
20
23
  export interface ServerConfig {
21
24
  httpPort?: number;
22
25
  wsPort?: number;
23
26
  host?: string;
24
27
  displayHost?: string; // IP address to display in logs/QR codes (for client connections)
28
+ /** Override the URL injected into BuildConfig for emulator builds. Emulators reach the host at 10.0.2.2, not the local network IP. */
29
+ emulatorHost?: string;
30
+ /** Enable web emulator support (initializes kotlinc-js compiler). */
31
+ webEnabled?: boolean;
25
32
  projectPath?: string;
26
33
  projectName?: string;
27
34
  }
@@ -33,12 +40,18 @@ export class JetStartServer extends EventEmitter {
33
40
  private wsServer: any;
34
41
  private logsServer: LogsServer;
35
42
  private wsHandler: WebSocketHandler | null = null;
36
- private config: Required<ServerConfig> & { displayHost: string };
43
+ private config: Required<Omit<ServerConfig, 'emulatorHost' | 'webEnabled'>> & { displayHost: string; emulatorHost?: string; webEnabled: boolean };
37
44
  private sessionManager: SessionManager;
38
45
  private buildService: BuildService;
46
+ private hotReloadService: HotReloadService | null = null;
47
+ private jsCompiler: JsCompilerService | null = null;
39
48
  private currentSession: ServerSession | null = null;
40
49
  private buildMutex: boolean = false; // Prevent concurrent builds
41
50
  private latestApkPath: string | null = null; // Store latest built APK path
51
+ private useTrueHotReload: boolean = false; // Use DEX-based hot reload (DISABLED - use APK builds instead)
52
+ private adbHelper: AdbHelper; // Auto-install APKs via ADB
53
+ private autoInstall: boolean = true; // Auto-install APK after build
54
+ private isFileChangeBuild: boolean = false; // Track if build is from file change
42
55
 
43
56
  constructor(config: ServerConfig = {}) {
44
57
  super();
@@ -52,6 +65,8 @@ export class JetStartServer extends EventEmitter {
52
65
  displayHost: displayHost,
53
66
  projectPath: config.projectPath || process.cwd(),
54
67
  projectName: config.projectName || path.basename(config.projectPath || process.cwd()),
68
+ emulatorHost: config.emulatorHost,
69
+ webEnabled: config.webEnabled ?? false,
55
70
  };
56
71
 
57
72
  this.sessionManager = new SessionManager();
@@ -61,11 +76,7 @@ export class JetStartServer extends EventEmitter {
61
76
  cachePath: path.join(os.tmpdir(), 'jetstart-cache'),
62
77
  watchEnabled: true,
63
78
  });
64
-
65
- // Hook into logger events
66
- // This creates a circular import if we import logger here, but we imported 'log' etc.
67
- // We need to import loggerEvents.
68
- // The previous tool replacement added loggerEvents export.
79
+ this.adbHelper = new AdbHelper();
69
80
  }
70
81
 
71
82
  async start(): Promise<ServerSession> {
@@ -93,13 +104,30 @@ export class JetStartServer extends EventEmitter {
93
104
  });
94
105
 
95
106
  // Inject server URL into build.gradle for hot reload
96
- const serverUrl = `ws://${this.config.displayHost}:${this.config.wsPort}`;
107
+ // For Android emulators, use 10.0.2.2 (host gateway) in BuildConfig; physical phones use the network IP
108
+ const buildConfigHost = this.config.emulatorHost || this.config.displayHost;
109
+ const serverUrl = `ws://${buildConfigHost}:${this.config.wsPort}`;
97
110
  await injectBuildConfigFields(this.config.projectPath, [
98
111
  { type: 'String', name: 'JETSTART_SERVER_URL', value: serverUrl },
99
112
  { type: 'String', name: 'JETSTART_SESSION_ID', value: this.currentSession.id }
100
113
  ]);
101
114
  log(`Injected server URL: ${serverUrl}`);
102
115
 
116
+ // Initialize Hot Reload Service
117
+ this.hotReloadService = new HotReloadService(this.config.projectPath);
118
+ if (this.config.webEnabled) {
119
+ this.jsCompiler = new JsCompilerService();
120
+ }
121
+ const envCheck = await this.hotReloadService.checkEnvironment();
122
+ if (envCheck.ready) {
123
+ log('🔥 True hot reload enabled (DEX-based)');
124
+ this.useTrueHotReload = true;
125
+ } else {
126
+ log('True hot reload unavailable - file changes will trigger full Gradle builds');
127
+ log(`Issues: ${envCheck.issues.join(', ')}`);
128
+ this.useTrueHotReload = false;
129
+ }
130
+
103
131
  // Start HTTP server
104
132
  this.httpServer = await createHttpServer({
105
133
  port: this.config.httpPort,
@@ -112,10 +140,21 @@ export class JetStartServer extends EventEmitter {
112
140
  const wsResult = await createWebSocketServer({
113
141
  port: this.config.wsPort,
114
142
  logsServer: this.logsServer,
143
+ adbHelper: this.adbHelper, // Enable wireless ADB auto-connect
144
+ expectedSessionId: this.currentSession?.id, // Reject old-session devices
145
+ expectedToken: this.currentSession?.token, // Validate token too
146
+ projectName: this.config.projectName,
115
147
  onClientConnected: async (sessionId: string) => {
116
- // Trigger initial build when client connects
117
- log(`Triggering initial build for connected client (session: ${sessionId})`);
118
- await this.handleRebuild();
148
+ log(`Client connected (session: ${sessionId}). Triggering initial build...`);
149
+ // If APK already built, just notify this new client - no rebuild needed
150
+ if (this.latestApkPath && fs.existsSync(this.latestApkPath)) {
151
+ log(`APK already built, re-sending build complete to new client`);
152
+ const downloadUrl = `http://${this.config.displayHost}:${this.config.httpPort}/download/app.apk`;
153
+ this.wsHandler?.sendBuildComplete(sessionId, downloadUrl);
154
+ } else {
155
+ this.isFileChangeBuild = false; // Don't auto-install for initial build
156
+ await this.handleRebuild();
157
+ }
119
158
  },
120
159
  });
121
160
  this.wsServer = wsResult.server;
@@ -126,25 +165,28 @@ export class JetStartServer extends EventEmitter {
126
165
 
127
166
  // Start watching for file changes
128
167
  this.buildService.startWatching(this.config.projectPath, async (files) => {
129
- log(`Files changed: ${files.map(f => path.basename(f)).join(', ')}`);
130
-
131
- // Check if only UI files changed (MainActivity.kt, screens/, components/)
132
- const uiFiles = files.filter(f =>
133
- f.includes('MainActivity.kt') ||
134
- f.includes('/screens/') ||
135
- f.includes('\\screens\\') ||
136
- f.includes('/components/') ||
137
- f.includes('\\components\\')
138
- );
139
-
140
- // If ALL changed files are UI files, use DSL hot reload (FAST)
141
- if (uiFiles.length > 0 && uiFiles.length === files.length) {
142
- log('🚀 UI-only changes detected, using DSL hot reload');
143
- this.handleUIUpdate(uiFiles[0]);
168
+ log(`Files changed: ${files.map(f => f.split(/[/\\]/).pop()).join(', ')}`);
169
+
170
+ // Check if all changed files support hot reload (.kt files that aren't in build directory)
171
+ const hotReloadFiles = files.filter(f => {
172
+ const normalized = f.replace(/\\/g, '/');
173
+ // Support hot reload for all .kt files EXCEPT those in /build/ directory
174
+ return normalized.endsWith('.kt') &&
175
+ !normalized.includes('/build/') &&
176
+ !normalized.includes('build.gradle');
177
+ });
178
+
179
+ // Use TRUE hot reload for Kotlin files (sends DEX via WebSocket - no USB needed!)
180
+ if (hotReloadFiles.length > 0 && hotReloadFiles.length === files.length && this.useTrueHotReload) {
181
+ log('🔥 Kotlin files changed, using TRUE hot reload (DEX via WebSocket)');
182
+ for (const file of hotReloadFiles) {
183
+ await this.handleUIUpdate(file);
184
+ }
144
185
  } else {
145
- // Otherwise, full Gradle build
146
- log('📦 Non-UI changes detected, triggering full Gradle build');
186
+ // Build files or mixed changes - use full Gradle build
187
+ log('📦 Build files changed, triggering Gradle build + ADB install');
147
188
  this.buildService.clearCache();
189
+ this.isFileChangeBuild = true; // Mark as file change build for auto-install
148
190
  await this.handleRebuild();
149
191
  }
150
192
  });
@@ -202,7 +244,7 @@ export class JetStartServer extends EventEmitter {
202
244
  this.emit('build:start');
203
245
  });
204
246
 
205
- this.buildService.on('build:complete', (result) => {
247
+ this.buildService.on('build:complete', async (result) => {
206
248
  success(`Build completed in ${result.buildTime}ms`);
207
249
  if (this.wsHandler && this.currentSession) {
208
250
  // Store the APK path
@@ -212,6 +254,45 @@ export class JetStartServer extends EventEmitter {
212
254
  const downloadUrl = `http://${this.config.displayHost}:${this.config.httpPort}/download/app.apk`;
213
255
  this.wsHandler.sendBuildComplete(this.currentSession.id, downloadUrl);
214
256
  log(`APK download URL: ${downloadUrl}`);
257
+
258
+ // Auto-install APK via ADB only for file change builds (not initial builds)
259
+ if (this.autoInstall && this.latestApkPath && this.isFileChangeBuild) {
260
+ const readyDevices = this.adbHelper.getDevices();
261
+
262
+ if (readyDevices.length > 0) {
263
+ log(`📱 Auto-installing APK on ${readyDevices.length} device(s)...`);
264
+ const installResult = await this.adbHelper.installApk(this.latestApkPath);
265
+ if (installResult.success) {
266
+ success('📱 APK auto-installed! App will restart with new code.');
267
+ // Launch the app
268
+ // Read actual package name from jetstart.config.json
269
+ let appPackageName = 'com.example.app';
270
+ try {
271
+ const configPath = path.join(this.config.projectPath, 'jetstart.config.json');
272
+ if (fs.existsSync(configPath)) {
273
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
274
+ appPackageName = cfg.packageName || appPackageName;
275
+ }
276
+ } catch (_) { /* empty */ }
277
+ await this.adbHelper.launchApp(appPackageName, '.MainActivity');
278
+ } else {
279
+ error(`Auto-install failed: ${installResult.error}`);
280
+ }
281
+ } else {
282
+ // Check if devices are connecting
283
+ const allDevices = this.adbHelper.getAllDeviceStates();
284
+ if (allDevices.length > 0) {
285
+ const states = allDevices.map(d => `${d.id} (${d.state})`).join(', ');
286
+ log(`⏳ Devices found but not ready: ${states}`);
287
+ log('ℹ️ Device may need user authorization on the phone.');
288
+ log('ℹ️ Auto-install will retry on the next file change.');
289
+ } else {
290
+ log('ℹ️ No devices connected for auto-install. Scan QR or download APK manually.');
291
+ }
292
+ }
293
+ // Reset flag after handling
294
+ this.isFileChangeBuild = false;
295
+ }
215
296
  }
216
297
  // Re-emit for external listeners (e.g., dev command) with result
217
298
  this.emit('build:complete', result);
@@ -272,39 +353,66 @@ export class JetStartServer extends EventEmitter {
272
353
  }
273
354
 
274
355
  /**
275
- * Handle UI file updates using DSL hot reload (FAST)
356
+ * Handle UI file updates - uses TRUE hot reload (DEX-based) when available
276
357
  */
277
- private handleUIUpdate(filePath: string): void {
278
- if (!this.currentSession || !this.wsHandler) {
279
- return;
280
- }
281
358
 
359
+ /**
360
+ * Compile a .kt file to a browser ES module and broadcast to web clients.
361
+ * Physical devices use DEX (already sent above). This targets browsers only.
362
+ */
363
+ private async sendJsPreview(filePath: string): Promise<void> {
364
+ if (!this.jsCompiler?.isAvailable() || !this.currentSession || !this.wsHandler) return;
282
365
  try {
283
- log(`Parsing UI file: ${path.basename(filePath)}`);
284
- const parseResult = DSLParser.parseFile(filePath);
285
-
286
- if (parseResult.success && parseResult.dsl) {
287
- const dslContent = JSON.stringify(parseResult.dsl);
288
- log(`DSL generated: ${dslContent.length} bytes`);
289
-
290
- // Send DSL update via WebSocket (instant hot reload!)
291
- this.wsHandler.sendUIUpdate(
366
+ const result = await this.jsCompiler.compile(filePath);
367
+ if (result.success && result.jsBase64) {
368
+ this.wsHandler.sendJsUpdate(
292
369
  this.currentSession.id,
293
- dslContent,
294
- [path.basename(filePath)]
370
+ result.jsBase64,
371
+ path.basename(filePath),
372
+ result.byteSize ?? 0,
373
+ result.screenFunctionName ?? 'Screen',
295
374
  );
296
-
297
- success(`UI hot reload sent in <100ms ⚡`);
298
375
  } else {
299
- error(`Failed to parse UI file: ${parseResult.errors?.join(', ')}`);
300
- // Fallback to full build
301
- log('Falling back to full Gradle build...');
302
- this.handleRebuild();
376
+ log(`[JsCompiler] Web preview skipped: ${result.error?.slice(0, 100)}`);
303
377
  }
304
378
  } catch (err: any) {
305
- error(`UI update failed: ${err.message}`);
306
- // Fallback to full build
307
- this.handleRebuild();
379
+ log(`[JsCompiler] Web preview error: ${err.message}`);
380
+ }
381
+ }
382
+
383
+ private async handleUIUpdate(filePath: string): Promise<void> {
384
+ if (!this.currentSession || !this.wsHandler) return;
385
+
386
+ // TRUE hot reload: compile to DEX and push to device via WebSocket
387
+ if (this.useTrueHotReload && this.hotReloadService) {
388
+ try {
389
+ log(`Hot reloading: ${path.basename(filePath)}...`);
390
+ const result = await this.hotReloadService.hotReload(filePath);
391
+
392
+ if (result.success && result.dexBase64) {
393
+ this.wsHandler.sendDexReload(
394
+ this.currentSession.id,
395
+ result.dexBase64,
396
+ result.classNames
397
+ );
398
+ success(`Hot reload complete! (${result.compileTime + result.dexTime}ms)`);
399
+ // Run JS compilation in parallel — does not block DEX delivery
400
+ this.sendJsPreview(filePath).catch(() => {});
401
+ return;
402
+ }
403
+
404
+ // DEX compilation failed — fall through to full Gradle rebuild
405
+ error(`Hot reload compile failed: ${result.errors[0] ?? 'unknown'}`);
406
+ log('Triggering full Gradle rebuild...');
407
+ } catch (err: any) {
408
+ error(`Hot reload error: ${err.message}`);
409
+ log('Triggering full Gradle rebuild...');
410
+ }
308
411
  }
412
+
413
+ // Fall back to full Gradle build (catches build-file changes, new dependencies, etc.)
414
+ this.buildService.clearCache();
415
+ this.isFileChangeBuild = true;
416
+ await this.handleRebuild();
309
417
  }
310
418
  }
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * WebSocket Message Handler
3
- * Processes incoming WebSocket messages
3
+ * Processes incoming WebSocket messages with session + token validation.
4
+ *
5
+ * Security model:
6
+ * - Every connecting client must supply the correct sessionId AND token.
7
+ * - Both are embedded in the QR code shown by `jetstart dev`.
8
+ * - A device that was built against a previous session (different token)
9
+ * is rejected immediately — it cannot hijack the current dev session.
4
10
  */
5
11
 
6
12
  import {
@@ -9,26 +15,38 @@ import {
9
15
  CoreConnectedMessage,
10
16
  CoreBuildStartMessage,
11
17
  CoreBuildCompleteMessage,
12
- CoreUIUpdateMessage,
18
+ CoreDexReloadMessage,
19
+ CoreJsUpdateMessage,
13
20
  } from '@jetstart/shared';
14
21
  import { ConnectionManager } from './manager';
15
22
  import { log, error as logError } from '../utils/logger';
16
-
17
23
  import { LogsServer } from '@jetstart/logs';
18
24
 
19
25
  export class WebSocketHandler {
20
26
  private onClientConnected?: (sessionId: string) => void;
21
27
  private logsServer?: LogsServer;
28
+ /** The session ID this server owns — clients must match this exactly. */
29
+ private expectedSessionId: string;
30
+ /** The token this server owns — clients must match this exactly. */
31
+ private expectedToken: string;
32
+ /** Human-readable project name for the `core:connected` response. */
33
+ private projectName: string;
22
34
 
23
35
  constructor(
24
36
  private connectionManager: ConnectionManager,
25
- options?: {
37
+ options?: {
26
38
  onClientConnected?: (sessionId: string) => void;
27
39
  logsServer?: LogsServer;
40
+ expectedSessionId?: string;
41
+ expectedToken?: string;
42
+ projectName?: string;
28
43
  }
29
44
  ) {
30
- this.onClientConnected = options?.onClientConnected;
31
- this.logsServer = options?.logsServer;
45
+ this.onClientConnected = options?.onClientConnected;
46
+ this.logsServer = options?.logsServer;
47
+ this.expectedSessionId = options?.expectedSessionId ?? '';
48
+ this.expectedToken = options?.expectedToken ?? '';
49
+ this.projectName = options?.projectName ?? 'JetStart Project';
32
50
  }
33
51
 
34
52
  handleMessage(clientId: string, data: Buffer): void {
@@ -36,11 +54,9 @@ export class WebSocketHandler {
36
54
  const message: WSMessage = JSON.parse(data.toString());
37
55
  log(`Received message from ${clientId}: ${message.type}`);
38
56
 
39
- // Route message based on type
40
57
  if (this.isClientMessage(message)) {
41
58
  this.handleClientMessage(clientId, message);
42
59
  }
43
-
44
60
  } catch (err: any) {
45
61
  logError(`Failed to parse message from ${clientId}: ${err.message}`);
46
62
  }
@@ -59,23 +75,19 @@ export class WebSocketHandler {
59
75
  log(`Client ${clientId} status: ${message.status}`);
60
76
  break;
61
77
  case 'client:log':
62
- // Add to Logs Server (for CLI/Web Dashboard)
63
78
  if (this.logsServer) {
64
79
  this.logsServer.addLog(message.log);
65
80
  }
66
-
67
- // Broadcast log to all clients in the session (e.g. Logs Viewer in App)
68
81
  if (message.sessionId) {
69
82
  this.connectionManager.broadcastToSession(message.sessionId, {
70
83
  type: 'core:log',
71
84
  timestamp: Date.now(),
72
85
  sessionId: message.sessionId,
73
- log: message.log
86
+ log: message.log,
74
87
  });
75
88
  }
76
89
  break;
77
90
  case 'client:heartbeat':
78
- // Update last activity
79
91
  break;
80
92
  case 'client:disconnect':
81
93
  this.handleDisconnect(clientId, message);
@@ -84,25 +96,54 @@ export class WebSocketHandler {
84
96
  }
85
97
 
86
98
  private handleConnect(clientId: string, message: ClientMessage & { type: 'client:connect' }): void {
87
- log(`Client connecting with session: ${message.sessionId}`);
99
+ const incomingSession = message.sessionId;
100
+ const incomingToken = (message as any).token as string | undefined;
101
+
102
+ // ── Session + token validation ──────────────────────────────────────────
103
+ // Only validate when the server has an expected session configured.
104
+ if (this.expectedSessionId) {
105
+ if (incomingSession !== this.expectedSessionId) {
106
+ logError(
107
+ `Rejected client ${clientId}: wrong session "${incomingSession}" (expected "${this.expectedSessionId}")`
108
+ );
109
+ logError('This device was built against a different jetstart dev session. Rescan the QR code.');
110
+ // Close the WebSocket immediately — do not accept this client.
111
+ const ws = this.connectionManager.getConnection(clientId);
112
+ if (ws) {
113
+ ws.close(4001, 'Session mismatch — rescan QR code');
114
+ }
115
+ this.connectionManager.removeConnection(clientId);
116
+ return;
117
+ }
118
+
119
+ if (this.expectedToken && incomingToken && incomingToken !== this.expectedToken) {
120
+ logError(
121
+ `Rejected client ${clientId}: wrong token (session ${incomingSession})`
122
+ );
123
+ const ws = this.connectionManager.getConnection(clientId);
124
+ if (ws) {
125
+ ws.close(4002, 'Token mismatch — rescan QR code');
126
+ }
127
+ this.connectionManager.removeConnection(clientId);
128
+ return;
129
+ }
130
+ }
88
131
 
89
- // Associate this client with the session for isolation
90
- this.connectionManager.setClientSession(clientId, message.sessionId);
132
+ // ── Accepted ─────────────────────────────────────────────────────────────
133
+ log(`Client accepted (session: ${incomingSession})`);
134
+ this.connectionManager.setClientSession(clientId, incomingSession);
91
135
 
92
- // Send connected confirmation
93
136
  const response: CoreConnectedMessage = {
94
137
  type: 'core:connected',
95
138
  timestamp: Date.now(),
96
- sessionId: message.sessionId,
97
- projectName: 'DemoProject', // In real implementation, get from session
139
+ sessionId: incomingSession,
140
+ projectName: this.projectName,
98
141
  };
99
-
100
142
  this.connectionManager.sendToClient(clientId, response);
101
143
 
102
- // Trigger initial build for the client
103
144
  if (this.onClientConnected) {
104
- log(`Triggering initial build for session: ${message.sessionId}`);
105
- this.onClientConnected(message.sessionId);
145
+ log(`Triggering initial build for session: ${incomingSession}`);
146
+ this.onClientConnected(incomingSession);
106
147
  }
107
148
  }
108
149
 
@@ -110,14 +151,12 @@ export class WebSocketHandler {
110
151
  log(`Client disconnecting: ${message.reason || 'No reason provided'}`);
111
152
  }
112
153
 
113
- // Send build notifications
114
154
  sendBuildStart(sessionId: string): void {
115
155
  const message: CoreBuildStartMessage = {
116
156
  type: 'core:build-start',
117
157
  timestamp: Date.now(),
118
158
  sessionId,
119
159
  };
120
-
121
160
  this.connectionManager.broadcastToSession(sessionId, message);
122
161
  }
123
162
 
@@ -138,46 +177,45 @@ export class WebSocketHandler {
138
177
  },
139
178
  downloadUrl: apkUrl,
140
179
  };
141
-
142
180
  this.connectionManager.broadcastToSession(sessionId, message);
143
181
  }
144
182
 
145
- /**
146
- * Send UI update (DSL-based hot reload) - SECURE, session-isolated
147
- */
148
- sendUIUpdate(sessionId: string, dslContent: string, screens?: string[]): void {
149
- const message: CoreUIUpdateMessage = {
150
- type: 'core:ui-update',
183
+ sendDexReload(sessionId: string, dexBase64: string, classNames: string[]): void {
184
+ const message: CoreDexReloadMessage = {
185
+ type: 'core:dex-reload',
151
186
  timestamp: Date.now(),
152
187
  sessionId,
153
- dslContent,
154
- screens,
155
- hash: this.generateHash(dslContent),
188
+ dexBase64,
189
+ classNames,
156
190
  };
157
-
158
- log(`Sending UI update to session ${sessionId}: ${dslContent.length} bytes`);
159
- log(`DSL Content: ${dslContent}`);
160
- this.connectionManager.broadcastToSession(sessionId, message);
191
+ log(`Sending DEX reload: ${dexBase64.length} base64 chars, ${classNames.length} classes`);
192
+ this.connectionManager.broadcastToAll(message as any);
161
193
  }
162
194
 
163
- private generateHash(content: string): string {
164
- // Simple hash for caching (in production, use crypto.createHash)
165
- let hash = 0;
166
- for (let i = 0; i < content.length; i++) {
167
- const char = content.charCodeAt(i);
168
- hash = ((hash << 5) - hash) + char;
169
- hash = hash & hash; // Convert to 32bit integer
170
- }
171
- return hash.toString(16);
195
+ /**
196
+ * Send compiled Kotlin→JS ES module to web emulator clients.
197
+ * The browser imports it dynamically and renders the Compose UI as HTML.
198
+ */
199
+ sendJsUpdate(sessionId: string, jsBase64: string, sourceFile: string, byteSize: number, screenFunctionName: string): void {
200
+ const message: CoreJsUpdateMessage = {
201
+ type: 'core:js-update',
202
+ timestamp: Date.now(),
203
+ sessionId,
204
+ jsBase64,
205
+ sourceFile,
206
+ byteSize,
207
+ };
208
+ log(`Sending JS update: ${sourceFile} (${byteSize} bytes) to web clients`);
209
+ // Broadcast to all — web clients use it, Android clients ignore unknown types
210
+ this.connectionManager.broadcastToAll(message as any);
172
211
  }
173
212
 
174
- // Broadcast any log entry to the session
175
213
  sendLogBroadcast(sessionId: string, logEntry: any): void {
176
- this.connectionManager.broadcastToSession(sessionId, {
177
- type: 'core:log',
178
- timestamp: Date.now(),
179
- sessionId: sessionId,
180
- log: logEntry
181
- });
214
+ this.connectionManager.broadcastToSession(sessionId, {
215
+ type: 'core:log',
216
+ timestamp: Date.now(),
217
+ sessionId,
218
+ log: logEntry,
219
+ });
182
220
  }
183
- }
221
+ }
@@ -8,7 +8,7 @@ import { Server } from 'http';
8
8
  import { WebSocketHandler } from './handler';
9
9
  import { ConnectionManager } from './manager';
10
10
  import { log } from '../utils/logger';
11
-
11
+ import { AdbHelper } from '../build/gradle';
12
12
  import { LogsServer } from '@jetstart/logs';
13
13
 
14
14
  export interface WebSocketConfig {
@@ -16,6 +16,13 @@ export interface WebSocketConfig {
16
16
  server?: Server;
17
17
  logsServer?: LogsServer;
18
18
  onClientConnected?: (sessionId: string) => void;
19
+ adbHelper?: AdbHelper;
20
+ /** Session ID this server owns — clients presenting a different ID are rejected. */
21
+ expectedSessionId?: string;
22
+ /** Token this server owns — clients presenting a different token are rejected. */
23
+ expectedToken?: string;
24
+ /** Project name shown in the core:connected response. */
25
+ projectName?: string;
19
26
  }
20
27
 
21
28
  export interface WebSocketServerResult {
@@ -24,32 +31,38 @@ export interface WebSocketServerResult {
24
31
  }
25
32
 
26
33
  export async function createWebSocketServer(config: WebSocketConfig): Promise<WebSocketServerResult> {
27
- const wss = new WebSocketServer({
28
- port: config.port,
29
- });
34
+ const wss = new WebSocketServer({ port: config.port });
30
35
 
31
36
  const connectionManager = new ConnectionManager();
32
37
  const handler = new WebSocketHandler(connectionManager, {
33
- logsServer: config.logsServer,
34
- onClientConnected: config.onClientConnected,
38
+ logsServer: config.logsServer,
39
+ onClientConnected: config.onClientConnected,
40
+ expectedSessionId: config.expectedSessionId,
41
+ expectedToken: config.expectedToken,
42
+ projectName: config.projectName,
35
43
  });
36
44
 
37
- wss.on('connection', (ws: WebSocket, _request) => {
45
+ wss.on('connection', (ws: WebSocket, request) => {
38
46
  const clientId = connectionManager.addConnection(ws);
39
- log(`WebSocket client connected: ${clientId}`);
40
47
 
41
- // Handle messages
42
- ws.on('message', (data: Buffer) => {
43
- handler.handleMessage(clientId, data);
44
- });
48
+ const clientIp = request.socket.remoteAddress || 'unknown';
49
+ log(`WebSocket client connected: ${clientId} (IP: ${clientIp})`);
50
+
51
+ // Auto-connect wireless ADB for real devices
52
+ if (clientIp && clientIp !== 'localhost' && clientIp !== '127.0.0.1' && clientIp !== '::1') {
53
+ const cleanIp = clientIp.replace(/^::ffff:/, '');
54
+ if (config.adbHelper) {
55
+ process.nextTick(() => config.adbHelper!.connectWireless(cleanIp));
56
+ }
57
+ }
58
+
59
+ ws.on('message', (data: Buffer) => handler.handleMessage(clientId, data));
45
60
 
46
- // Handle disconnection
47
61
  ws.on('close', () => {
48
62
  log(`WebSocket client disconnected: ${clientId}`);
49
63
  connectionManager.removeConnection(clientId);
50
64
  });
51
65
 
52
- // Handle errors
53
66
  ws.on('error', (err: Error) => {
54
67
  console.error(`WebSocket error for ${clientId}:`, err.message);
55
68
  });
@@ -95,9 +95,9 @@ export class ConnectionManager {
95
95
  }
96
96
 
97
97
  /**
98
- * Broadcast to ALL clients regardless of session (use with caution)
98
+ * Broadcast to ALL clients regardless of session (for dev mode hot reload)
99
99
  */
100
- private broadcastToAll(message: CoreMessage): void {
100
+ broadcastToAll(message: CoreMessage): void {
101
101
  const data = JSON.stringify(message);
102
102
  const connectionCount = Array.from(this.connections.values()).filter(c => c.ws.readyState === WebSocket.OPEN).length;
103
103
  console.log(`[ConnectionManager] Broadcasting ${message.type} to ${connectionCount} connected clients`);