@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.
- package/README.md +189 -74
- package/dist/build/dex-generator.d.ts +27 -0
- package/dist/build/dex-generator.js +202 -0
- package/dist/build/dsl-parser.d.ts +3 -30
- package/dist/build/dsl-parser.js +67 -240
- package/dist/build/dsl-types.d.ts +8 -0
- package/dist/build/gradle.d.ts +51 -0
- package/dist/build/gradle.js +233 -1
- package/dist/build/hot-reload-service.d.ts +36 -0
- package/dist/build/hot-reload-service.js +179 -0
- package/dist/build/js-compiler-service.d.ts +61 -0
- package/dist/build/js-compiler-service.js +421 -0
- package/dist/build/kotlin-compiler.d.ts +54 -0
- package/dist/build/kotlin-compiler.js +450 -0
- package/dist/build/kotlin-parser.d.ts +91 -0
- package/dist/build/kotlin-parser.js +1030 -0
- package/dist/build/override-generator.d.ts +54 -0
- package/dist/build/override-generator.js +430 -0
- package/dist/server/index.d.ts +16 -1
- package/dist/server/index.js +147 -42
- package/dist/websocket/handler.d.ts +20 -4
- package/dist/websocket/handler.js +73 -38
- package/dist/websocket/index.d.ts +8 -0
- package/dist/websocket/index.js +15 -11
- package/dist/websocket/manager.d.ts +2 -2
- package/dist/websocket/manager.js +1 -1
- package/package.json +3 -3
- package/src/build/dex-generator.ts +197 -0
- package/src/build/dsl-parser.ts +73 -272
- package/src/build/dsl-types.ts +9 -0
- package/src/build/gradle.ts +259 -1
- package/src/build/hot-reload-service.ts +178 -0
- package/src/build/js-compiler-service.ts +411 -0
- package/src/build/kotlin-compiler.ts +460 -0
- package/src/build/kotlin-parser.ts +1043 -0
- package/src/build/override-generator.ts +478 -0
- package/src/server/index.ts +162 -54
- package/src/websocket/handler.ts +94 -56
- package/src/websocket/index.ts +27 -14
- package/src/websocket/manager.ts +2 -2
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 =>
|
|
130
|
-
|
|
131
|
-
// Check if
|
|
132
|
-
const
|
|
133
|
-
f.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
if (
|
|
142
|
-
log('
|
|
143
|
-
|
|
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
|
-
//
|
|
146
|
-
log('📦
|
|
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
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
}
|
package/src/websocket/handler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
31
|
-
this.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
|
-
|
|
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
|
-
//
|
|
90
|
-
|
|
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:
|
|
97
|
-
projectName:
|
|
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: ${
|
|
105
|
-
this.onClientConnected(
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
hash: this.generateHash(dslContent),
|
|
188
|
+
dexBase64,
|
|
189
|
+
classNames,
|
|
156
190
|
};
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
214
|
+
this.connectionManager.broadcastToSession(sessionId, {
|
|
215
|
+
type: 'core:log',
|
|
216
|
+
timestamp: Date.now(),
|
|
217
|
+
sessionId,
|
|
218
|
+
log: logEntry,
|
|
219
|
+
});
|
|
182
220
|
}
|
|
183
|
-
}
|
|
221
|
+
}
|
package/src/websocket/index.ts
CHANGED
|
@@ -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:
|
|
34
|
-
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,
|
|
45
|
+
wss.on('connection', (ws: WebSocket, request) => {
|
|
38
46
|
const clientId = connectionManager.addConnection(ws);
|
|
39
|
-
log(`WebSocket client connected: ${clientId}`);
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
});
|
package/src/websocket/manager.ts
CHANGED
|
@@ -95,9 +95,9 @@ export class ConnectionManager {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
|
-
* Broadcast to ALL clients regardless of session (
|
|
98
|
+
* Broadcast to ALL clients regardless of session (for dev mode hot reload)
|
|
99
99
|
*/
|
|
100
|
-
|
|
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`);
|