diff --git a/cli/package.json b/cli/package.json index af48a84a13..a3ce78fb0e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -26,6 +26,7 @@ "commander": "^9.4.1", "debounce-promise": "^3.1.2", "devicescript-compiler": "*", + "faye-websocket": "^0.11.4", "fs-extra": "^10.1.0" } } diff --git a/cli/src/build.ts b/cli/src/build.ts index db4f3b21df..fd7e6c55a1 100644 --- a/cli/src/build.ts +++ b/cli/src/build.ts @@ -11,8 +11,10 @@ import { compile, jacdacDefaultSpecifications, JacsDiagnostic, + DEVS_ASSEMBLY_FILE, } from "devicescript-compiler" import { CmdOptions } from "./command" +import { startDevTools } from "./devtools" function jacsFactory() { let d = require("devicescript-vm") @@ -30,7 +32,7 @@ async function getHost(options: BuildOptions) { const inst = options.noVerify ? undefined : await jacsFactory() inst?.jacsInit() - const outdir = options.outDir || "./built" + const outdir = options.outDir ensureDirSync(outdir) const jacsHost = { @@ -84,11 +86,18 @@ export interface BuildOptions extends CmdOptions { export async function build(file: string, options: BuildOptions) { file = file || "main.ts" + options = options || {} + options.outDir = options.outDir || "./built" + options.mainFileName = file + await buildOnce(file, options) if (options.watch) await buildWatch(file, options) } async function buildWatch(file: string, options: BuildOptions) { + const bytecodeFile = join(options.outDir, DEVS_ASSEMBLY_FILE) + + // start watch source file console.log(`watching ${file}...`) const work = debounce( async () => { @@ -99,6 +108,9 @@ async function buildWatch(file: string, options: BuildOptions) { { leading: true } ) watch(file, work) + + // start watching bytecode file + await startDevTools(bytecodeFile) } async function buildOnce(file: string, options: BuildOptions) { diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 81e2eb9285..ee9b64961d 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -1,4 +1,3 @@ - import { build } from "./build" import pkg from "../package.json" import { program } from "commander" @@ -6,17 +5,24 @@ import { program } from "commander" export async function mainCli() { program .name("DeviceScript") - .description("build and run DeviceScript program https://aka.ms/devicescript") + .description( + "build and run DeviceScript program https://aka.ms/devicescript" + ) .version(pkg.version) .option("-v, --verbose", "more logging") program .command("build", { isDefault: true }) .description("build a DeviceScript file") - .option("-w, --watch", "watch file changes and rebuild automatically") .option("-l, --library", "build library") .option("--no-verify", "don't verify resulting bytecode") .option("-o", "--out-dir", "output directory, default is 'built'") + .option("-w, --watch", "watch file changes and rebuild automatically") + .option("--internet", "allow connections from non-localhost") + .option( + "--localhost", + "use localhost:8000 instead of the internet dashboard" + ) .arguments("[file.ts]") .action(build) diff --git a/cli/src/devtools.ts b/cli/src/devtools.ts new file mode 100644 index 0000000000..3543c825e4 --- /dev/null +++ b/cli/src/devtools.ts @@ -0,0 +1,145 @@ +const SENDER_FIELD = "__jacdac_sender" +/* eslint-disable @typescript-eslint/no-var-requires */ +const WebSocket = require("faye-websocket") +import http from "http" +import https from "https" +import url from "url" +import net from "net" +import fs from "fs" + +const log = console.log +const debug = console.debug +const error = console.error + +function fetchProxy(localhost: boolean): Promise { + const protocol = localhost ? http : https + const url = localhost + ? "http://localhost:8000/devtools/proxy.html" + : "https://microsoft.github.io/jacdac-docs/devtools/proxy" + //debug(`fetch jacdac devtools proxy at ${url}`) + return new Promise((resolve, reject) => { + protocol + .get(url, res => { + if (res.statusCode != 200) + reject( + new Error(`proxy download failed (${res.statusCode})`) + ) + res.setEncoding("utf8") + let body = "" + res.on("data", data => (body += data)) + res.on("end", () => { + body = body.replace( + /https:\/\/microsoft.github.io\/jacdac-docs\/dashboard/g, + localhost + ? "http://localhost:8000/devicescript/" + : "https://microsoft.github.io/jacdac-docs/editors/devicescript/" + ) + resolve(body) + }) + res.on("error", reject) + }) + .on("error", reject) + }) +} + +export async function startDevTools( + bytecodeFile: string, + options?: { + internet?: boolean + localhost?: boolean + } +) { + const { internet, localhost } = options || {} + const port = 8081 + const tcpPort = 8082 + const listenHost = internet ? undefined : "127.0.0.1" + + log(`start dev tools for ${bytecodeFile}`) + log(` dashboard: http://localhost:${port}`) + log(` websocket: ws://localhost:${port}`) + log(` tcpsocket: tcp://localhost:${tcpPort}`) + + // download proxy sources + const proxyHtml = await fetchProxy(localhost) + + // start http server + const clients: WebSocket[] = [] + + // upload DeviceScript file is needed + const sendDeviceScript = () => { + const bytecode = fs.readFileSync(bytecodeFile) + debug(`refresh bytecode...`) + const msg = JSON.stringify({ + type: "source", + channel: "devicescript", + bytecode: bytecode.toString("hex"), + }) + clients.forEach(c => c.send(msg)) + } + + const server = http.createServer(function (req, res) { + const parsedUrl = url.parse(req.url) + const pathname = parsedUrl.pathname + if (pathname === "/") { + res.setHeader("Cache-control", "no-cache") + res.setHeader("Content-type", "text/html") + res.end(proxyHtml) + } else { + res.statusCode = 404 + } + }) + function removeClient(client: WebSocket) { + const i = clients.indexOf(client) + clients.splice(i, 1) + log(`client: disconnected (${clients.length} clients)`) + } + server.on("upgrade", (request, socket, body) => { + // is this a socket? + if (WebSocket.isWebSocket(request)) { + const client = new WebSocket(request, socket, body) + const sender = "ws" + Math.random() + let firstDeviceScript = false + // store sender id to deduped packet + client[SENDER_FIELD] = sender + clients.push(client) + log(`webclient: connected (${sender}, ${clients.length} clients)`) + client.on("message", (event: any) => { + const { data } = event + if (!firstDeviceScript && sendDeviceScript) { + firstDeviceScript = true + sendDeviceScript() + } + }) + client.on("close", () => removeClient(client)) + client.on("error", (ev: Error) => error(ev)) + } + }) + + const tcpServer = net.createServer((client: any) => { + const sender = "tcp" + Math.random() + client[SENDER_FIELD] = sender + client.send = (pkt0: Buffer) => { + const pkt = new Uint8Array(pkt0) + const b = new Uint8Array(1 + pkt.length) + b[0] = pkt.length + b.set(pkt, 1) + try { + client.write(b) + } catch { + try { + client.end() + } catch {} // eslint-disable-line no-empty + } + } + clients.push(client) + log(`tcpclient: connected (${sender} ${clients.length} clients)`) + client.on("end", () => removeClient(client)) + client.on("error", (ev: Error) => error(ev)) + }) + + server.listen(port, listenHost) + tcpServer.listen(tcpPort, listenHost) + + debug(`watch ${bytecodeFile}`) + fs.watch(bytecodeFile, sendDeviceScript) +} diff --git a/compiler/src/compiler.ts b/compiler/src/compiler.ts index 6566235b90..d2e5a7b5cd 100644 --- a/compiler/src/compiler.ts +++ b/compiler/src/compiler.ts @@ -72,6 +72,8 @@ export const JD_SERIAL_MAX_PAYLOAD_SIZE = 236 export const CMD_GET_REG = 0x1000 export const CMD_SET_REG = 0x2000 +export const DEVS_ASSEMBLY_FILE = "prog.jasm" + class Cell { _index: number @@ -2792,7 +2794,7 @@ class Program implements TopOpWriter { // early assembly dump, in case serialization fails if (this.numErrors == 0) - this.host.write("prog.jasm", this.getAssembly()) + this.host.write(DEVS_ASSEMBLY_FILE, this.getAssembly()) const b = this.serialize() const dbg: DebugInfo = { @@ -2812,7 +2814,7 @@ class Program implements TopOpWriter { // write assembly again if (this.numErrors == 0) - this.host.write("prog.jasm", this.getAssembly()) + this.host.write(DEVS_ASSEMBLY_FILE, this.getAssembly()) if (this.numErrors == 0) { try { diff --git a/website/docs/Dev/cli.md b/website/docs/Dev/cli.md index 769962ef2e..3c565832bc 100644 --- a/website/docs/Dev/cli.md +++ b/website/docs/Dev/cli.md @@ -18,16 +18,10 @@ The command tool is named `devicescript` or `devsc` for short. ## Build -The `build` command compiles the DeviceScript files in the current folder, using the resolution rules in `tsconfig.json`. +The `build` command compiles a DeviceScript file (default is `main.ts`), using the resolution rules in `tsconfig.json`. It is the default command. ```bash -devsc build -``` - -This is the default command, so you can omit build. - -```bash -devsc +devsc build main.ts ``` ### watch @@ -38,3 +32,15 @@ add `--watch`. ```bash devsc build --watch ``` + +When the build is run in watch mode, it also opens a developer tool web server that allows +to execute the compiled program in a virtual device or physical devices. Follow the console +application instructions to open the web page. + +#### --internet + +To access the developer tools outside localhost, add `--internet` + +```bash +devsc build --watch --internet +``` diff --git a/yarn.lock b/yarn.lock index 0c4774dc72..d9a12a4da9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -228,6 +228,13 @@ ext@^1.1.2: dependencies: type "^2.7.2" +faye-websocket@^0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + fs-extra@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -242,6 +249,11 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +http-parser-js@>=0.5.1: + version "0.5.8" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" + integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q== + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -271,6 +283,11 @@ node-gyp-build@^4.3.0: resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.5.0.tgz#7a64eefa0b21112f89f58379da128ac177f20e40" integrity sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg== +safe-buffer@>=5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + tstl@^2.0.7: version "2.5.13" resolved "https://registry.yarnpkg.com/tstl/-/tstl-2.5.13.tgz#a5a5d27b79a12767e46a08525b3e045c5cdb1180" @@ -310,6 +327,20 @@ utf-8-validate@^5.0.2: dependencies: node-gyp-build "^4.3.0" +websocket-driver@>=0.5.1: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + websocket-polyfill@^0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz#7321ada0f5f17516290ba1cb587ac111b74ce6a5"