@deskthing/cli
Version:
An emulator for the DeskThing Server
499 lines (424 loc) • 15.7 kB
text/typescript
import { createServer } from "http";
import { existsSync, readFileSync, statSync } from "fs";
import { extname, join, normalize } from "path";
import { fileURLToPath } from "url";
import { URL } from "url";
import { DeskThingConfig } from "../../config/deskthing.config";
import { Logger } from "../services/logger";
import { CallbackService } from "./callbackService";
// Types for client device detection (simplified versions)
enum ClientConnectionMethod {
LAN = "LAN",
Unknown = "Unknown"
}
enum ClientPlatformIDs {
Desktop = "Desktop",
Tablet = "Tablet",
Iphone = "Iphone",
Unknown = "Unknown"
}
export class DevClient {
private deskthingPath: string;
constructor() {
// Mock user data paths - these would normally come from configuration
this.deskthingPath = join(process.cwd(), 'deskthing');
// if deskthingPath does not exist, warn the user to update with `npx @deskthing/cli update --noOverwrite`
if (!existsSync(this.deskthingPath)) {
Logger.warn(`DeskThing path does not exist: ${this.deskthingPath}`);
Logger.warn("Please run `npx @deskthing/cli update --noOverwrite` to set up the environment.");
}
}
async start(): Promise<void> {
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const staticPath = join(__dirname, "./template");
Logger.debug(`Static files will be served from: ${staticPath}`);
const mimeTypes = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".json": "application/json",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
".otf": "font/otf",
};
const server = createServer((req, res) => {
// Handle only GET requests for most routes (except proxy which may handle POST)
if (!["GET", "POST"].includes(req.method || "")) {
res.statusCode = 405;
res.end("Method Not Allowed");
return;
}
// Get URL path and parse query parameters
const url = new URL(req.url || "/", `http://${req.headers.host}`);
let urlPath = url.pathname;
const queryParams = url.searchParams;
Logger.debug(`${req.method} ${urlPath}`);
// Route handling
if (this.handleSpecialRoutes(req, res, urlPath, queryParams)) {
return;
}
if (this.handleClientRoutes(req, res, urlPath, staticPath, mimeTypes)) {
return;
}
if (this.handleResourceRoutes(req, res, urlPath)) {
return;
}
if (this.handleProxyRoutes(req, res, urlPath, queryParams)) {
return;
}
// Default route handling - serve from template directory
if (urlPath === "/") {
urlPath = "/index.html";
}
// Security: Prevent directory traversal attacks
const safePath = normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, "");
const filePath = join(staticPath, safePath);
Logger.debug(`Serving ${urlPath} from template`);
this.serveStaticFile(res, filePath, mimeTypes, () => {
// Try serving index.html as fallback for SPA routing
const indexPath = join(staticPath, "index.html");
this.serveStaticFile(res, indexPath, mimeTypes, () => {
res.statusCode = 404;
res.end("Not Found");
});
});
});
const clientPort = DeskThingConfig.development.client.clientPort;
server.listen(clientPort, () => {
Logger.info(
`\x1b[36m🚀 Development Server is running at http://localhost:${clientPort}\x1b[0m`
);
Logger.info(
`\x1b[33m🔄 Callback Server is running at http://localhost:${clientPort}/callback \x1b[0m`
);
});
}
private handleSpecialRoutes(req: any, res: any, urlPath: string, queryParams: URLSearchParams): boolean {
// Handle callback request
if (urlPath.startsWith("/callback")) {
CallbackService.handleCallback(req, res);
return true;
}
// Handle config request
if (urlPath === "/config") {
try {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(DeskThingConfig.development.client));
return true;
} catch (err) {
Logger.error("Error serving config:", err);
res.statusCode = 500;
res.end("Internal Server Error");
return true;
}
}
return false;
}
private handleClientRoutes(req: any, res: any, urlPath: string, staticPath: string, mimeTypes: Record<string, string>): boolean {
// Handle /manifest.json redirect
if (urlPath === "/manifest.json") {
res.writeHead(302, { "Location": "/client/manifest.json" });
res.end();
return true;
}
// Handle /client/manifest.json
if (urlPath === "/client/manifest.json") {
const clientIp = req.headers.host?.split(':')[0] || "localhost";
const clientPort = DeskThingConfig.development.client.clientPort;
// Create a mock manifest response
const mockManifest = {
id: "mock-client",
name: "Mock DeskThing Client",
short_name: "MockClient",
description: "Development mock client for DeskThing",
version: "1.0.0",
author: "DeskThing Dev",
repository: "https://github.com/deskthing/deskthing",
context: {
ip: clientIp,
port: clientPort,
method: "LAN",
id: "Desktop",
name: "Desktop"
},
connectionId: this.generateConnectionId(),
reactive: true,
compatibility: {
server: "1.0.0",
app: "1.0.0"
}
};
Logger.info("Client connected via manifest request");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(mockManifest));
return true;
}
// Handle /client/* routes - serve from webapp directory or template as fallback
if (urlPath.startsWith("/client")) {
const subPath = urlPath.substring(7) || "/index.html"; // Remove /client
const templateFilePath = join(staticPath, subPath);
// Try template directory
this.serveStaticFile(res, templateFilePath, mimeTypes, () => {
// Fallback to index.html for SPA routing
const templateIndexPath = join(staticPath, "index.html");
this.serveStaticFile(res, templateIndexPath, mimeTypes, () => {
res.statusCode = 404;
res.end("App not found");
});
});
return true;
}
return false;
}
private handleResourceRoutes(req: any, res: any, urlPath: string): boolean {
// Handle /icons route
if (urlPath.startsWith("/icons")) {
const iconPath = urlPath.substring(6); // Remove /icons
const fullIconPath = join(this.deskthingPath, 'icons', iconPath);
this.serveStaticFile(res, fullIconPath, {}, () => {
res.statusCode = 404;
res.end("Icon not found");
}, {
maxAge: "1d",
immutable: true,
etag: true,
lastModified: true
});
return true;
}
// Handle /resource/icons route
if (urlPath.startsWith("/resource/icons")) {
const iconPath = urlPath.substring(15); // Remove /resource/icons
const fullIconPath = join(this.deskthingPath, 'icons', iconPath);
this.serveStaticFile(res, fullIconPath, {}, () => {
res.statusCode = 404;
res.end("Icon not found");
}, {
maxAge: "1d",
immutable: true,
etag: true,
lastModified: true
});
return true;
}
// Handle /resource/image/:appName/:imageName
const imageMatch = urlPath.match(/^\/resource\/image\/([^\/]+)\/([^\/]+)$/);
if (imageMatch) {
const [, , imageName] = imageMatch;
if (!imageName) {
res.statusCode = 400;
res.end("Image name is required");
return true;
}
const imagePath = join(this.deskthingPath, "images", imageName);
this.serveStaticFile(res, imagePath, {}, () => {
res.statusCode = 404;
res.end("Image not found");
});
return true;
}
return false;
}
private handleProxyRoutes(req: any, res: any, urlPath: string, queryParams: URLSearchParams): boolean {
// Handle /proxy/fetch/:url(*)
const proxyFetchMatch = urlPath.match(/^\/proxy\/fetch\/(.+)$/);
if (proxyFetchMatch) {
const targetUrl = decodeURIComponent(proxyFetchMatch[1]);
this.proxyRequest(targetUrl, res);
return true;
}
// Handle /proxy/v1
if (urlPath === "/proxy/v1") {
const url = queryParams.get("url");
if (!url) {
res.statusCode = 400;
res.end("Missing url query parameter");
return true;
}
Logger.debug(`Proxying resource from: ${url}`);
this.proxyRequest(url, res);
return true;
}
return false;
}
private serveStaticFile(
res: any,
filePath: string,
mimeTypes: Record<string, string>,
onNotFound: () => void,
cacheOptions?: { maxAge?: string; immutable?: boolean; etag?: boolean; lastModified?: boolean }
): void {
if (existsSync(filePath) && statSync(filePath).isFile()) {
try {
// Determine content type based on file extension
const ext = extname(filePath).toLowerCase();
const contentType = mimeTypes[ext] || this.getDefaultMimeType(ext);
const fileContent = readFileSync(filePath);
const headers: Record<string, string> = { "Content-Type": contentType };
// Add cache headers if specified
if (cacheOptions) {
if (cacheOptions.maxAge) {
headers["Cache-Control"] = `max-age=${this.parseCacheMaxAge(cacheOptions.maxAge)}${cacheOptions.immutable ? ', immutable' : ''}`;
}
if (cacheOptions.etag) {
headers["ETag"] = `"${this.generateETag(fileContent)}"`;
}
if (cacheOptions.lastModified) {
const stats = statSync(filePath);
headers["Last-Modified"] = stats.mtime.toUTCString();
}
}
res.writeHead(200, headers);
res.end(fileContent);
} catch (err) {
Logger.error(`Error serving ${filePath}:`, err);
res.statusCode = 500;
res.end("Internal Server Error");
}
} else {
onNotFound();
}
}
private async proxyRequest(targetUrl: string, res: any): Promise<void> {
try {
Logger.debug(`Proxying request to: ${targetUrl}`);
const response = await fetch(targetUrl);
if (!response.ok) {
res.statusCode = response.status;
res.end(`Upstream resource responded with ${response.status}`);
return;
}
// Copy headers from the original response
const contentType = response.headers.get('content-type');
if (contentType) {
res.setHeader('Content-Type', contentType);
}
// Copy other relevant headers
['content-length', 'content-encoding', 'cache-control'].forEach(header => {
const value = response.headers.get(header);
if (value) {
res.setHeader(header, value);
}
});
if (!response.body) {
res.statusCode = 204;
res.end();
return;
}
// Stream the response directly to the client
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
res.end();
} catch (error) {
Logger.error('Error proxying resource:', error);
res.statusCode = 500;
res.end(`Error fetching resource: ${error instanceof Error ? error.message : String(error)}`);
}
}
private getDefaultMimeType(ext: string): string {
const defaultMimeTypes: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".json": "application/json",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
".otf": "font/otf",
};
return defaultMimeTypes[ext] || "application/octet-stream";
}
private parseCacheMaxAge(maxAge: string): number {
if (maxAge.endsWith('d')) {
return parseInt(maxAge.slice(0, -1)) * 24 * 60 * 60;
}
if (maxAge.endsWith('h')) {
return parseInt(maxAge.slice(0, -1)) * 60 * 60;
}
if (maxAge.endsWith('m')) {
return parseInt(maxAge.slice(0, -1)) * 60;
}
return parseInt(maxAge) || 0;
}
private generateETag(content: Buffer): string {
const crypto = require('crypto');
return crypto.createHash('md5').update(content).digest('hex');
}
private generateConnectionId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
private getDeviceType(userAgent: string | undefined, ip: string, port: number) {
if (!userAgent) {
return {
method: ClientConnectionMethod.LAN,
ip,
port,
id: ClientPlatformIDs.Unknown,
name: 'unknown'
};
}
userAgent = userAgent.toLowerCase();
const deviceMap = {
// Desktops
linux: { id: ClientPlatformIDs.Desktop, name: 'linux' },
win: { id: ClientPlatformIDs.Desktop, name: 'windows' },
mac: { id: ClientPlatformIDs.Desktop, name: 'mac' },
chromebook: { id: ClientPlatformIDs.Desktop, name: 'chromebook' },
// Tablets
ipad: { id: ClientPlatformIDs.Tablet, name: 'tablet' },
webos: { id: ClientPlatformIDs.Tablet, name: 'webos' },
kindle: { id: ClientPlatformIDs.Tablet, name: 'kindle' },
// Mobile
iphone: { id: ClientPlatformIDs.Iphone, name: 'iphone' },
'firefox os': { id: ClientPlatformIDs.Iphone, name: 'firefox-os' },
blackberry: { id: ClientPlatformIDs.Iphone, name: 'blackberry' },
'windows phone': { id: ClientPlatformIDs.Iphone, name: 'windows-phone' }
};
// Special case for Android
if (userAgent.includes('android')) {
return {
method: ClientConnectionMethod.LAN,
ip,
port,
id: userAgent.includes('mobile') ? ClientPlatformIDs.Iphone : ClientPlatformIDs.Tablet,
name: userAgent.includes('mobile') ? 'android' : 'tablet'
};
}
// Find matching device from map
const matchedDevice = Object.entries(deviceMap).find(([key]) => userAgent.includes(key));
if (matchedDevice) {
return { method: ClientConnectionMethod.LAN, ip, port, ...matchedDevice[1] };
}
// Default to unknown
return {
method: ClientConnectionMethod.LAN,
ip,
port,
id: ClientPlatformIDs.Unknown,
name: 'unknown'
};
}
}