Node.js and TypeScript
Everything about the Node.js SDK: installing it, connecting, calling commands with options objects, files, waiting, errors, parallel phones with Promise.all, notifications and TypeScript.
The Node.js package is a thin client for droidline serve. The server and the phone do the waiting, retries and fallbacks; the package sends commands, resolves with the reply and rejects with a DroidlineError when something fails. It needs Node.js 18 or later, is published as ES modules and includes TypeScript types.
Install
npm install droidline
Your script must be an ES module: name it something.mjs, or set "type": "module" in your package.json. Check that it works with the server running:
node -e "import('droidline').then(async ({ connect }) => console.log(await (await connect()).info()))"
Connect
import { connect } from "droidline";
const d = await connect(); // the only phone that is online
const e = await connect("shelf-01"); // a phone by name or ID
connect() resolves with a Device: an object whose methods send commands to one phone. It rejects at once with code SERVER_NOT_RUNNING if droidline serve is not running.
To reach a server on another machine, pass options, or set DROIDLINE_HOST, DROIDLINE_PORT, DROIDLINE_TOKEN and DROIDLINE_DEVICE:
const d = await connect({ device: "shelf-01", host: "192.168.0.12", port: 8780, token: "dlc_..." });
For several phones, or for server commands such as devices and pair, create a Droidline client and ask it for devices:
import { Droidline } from "droidline";
const dl = new Droidline(); // same host, port and token options
console.log(await dl.devices());
const a = dl.device("shelf-01");
const b = dl.device("shelf-02");
The process exits on its own once nothing is pending, so scripts need no close(). Long-running programs can call dl.close() when they are done.
Call commands
Every command in the command reference is a method. Required parameters come first; optional ones can follow by position or go into an options object as the last argument:
await d.touch("text", "Log in");
await d.touch("text", "Log in", 0, 30); // nth=0, timeout=30 by position
await d.touch("text", "Log in", { timeout: 30 }); // clearer: an options object
await d.swipe("up"); // a direction...
await d.swipe(540, 1600, 540, 400, 300); // ...or coordinates and a duration
await d.chrome.go("droidline.dev"); // dotted commands are properties
Multi-word names exist in both styles, get_text and getText, scroll_to and scrollTo; this guide uses camelCase. Shorthands exist for the most common selector fields: d.touchById("login"), d.touchByText("OK"), d.touchByDesc("Search").
Every method returns a promise, so remember await. Forgetting it is the most common mistake: the script moves on before the phone has done anything.
What you get back
| The command returns | The promise resolves with | Example |
|---|---|---|
| nothing interesting | an object with details such as via and ms | await d.touch(...) gives { via: "node", ms: 208 } |
| one value | the value itself | await d.exists(...) gives true, await d.getText(...) a string |
| several fields | an object | await d.battery() gives { level: 84, charging: false, temperature: 31.5 } |
Files: dumps and screenshots
await d.dump("screen.json"); // writes the screen tree to a file on your PC
const tree = await d.dump(); // or resolves with it
await d.screenshot("shot.png"); // the extension picks PNG or JPEG
await d.screenshot("small.jpg", { scale: 0.5, quality: 70 });
const png = await d.screenshot({ format: "png" }); // without a path: a Buffer
Paths are on your PC, relative to the folder you run the script from.
Waiting and timeouts
Commands that act on an element wait for it, 10 seconds by default:
await d.touch("text", "Next", { timeout: 30 });
await d.wait("text", "Upload complete", 120); // only wait
await d.waitGone("id", "progress"); // wait until something disappears
You do not need setTimeout pauses before commands; they only make scripts slower.
Conditions
Conditions answer at once and never reject for a missing element:
if (await d.exists("text", "Allow")) await d.touch("text", "Allow");
if (!(await d.inApp("com.android.chrome"))) await d.launch("com.android.chrome");
const state = await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 }); // 0, 1 or -1
Errors
Every failure is a DroidlineError with code, retryable and data, the extra fields the server sent:
import { connect, DroidlineError } from "droidline";
const d = await connect();
try {
await d.touch("text", "Log in", { timeout: 5 });
} catch (e) {
if (!(e instanceof DroidlineError)) throw e;
switch (e.code) {
case "NOT_FOUND":
console.log("not on screen:", e.data.screen);
break;
case "DEVICE_OFFLINE":
console.log("the phone is offline");
break;
default:
console.log(e.code, e.retryable, e.message);
}
}
Besides the codes in Error codes, the SDK uses SERVER_NOT_RUNNING (nothing listens on the port) and CONNECTION_LOST (the connection dropped during a call; the command may or may not have run).
A simple retry for errors marked retryable:
async function retry(fn, attempts = 3) {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e) {
if (!(e instanceof DroidlineError) || !e.retryable || i >= attempts - 1) throw e;
await new Promise((r) => setTimeout(r, 2000));
}
}
}
await retry(() => d.touch("text", "Refresh"));
Several phones in parallel
Commands to one phone run in order; commands to different phones run at the same time. Promise.all drives a whole shelf at once:
import { Droidline } from "droidline";
const dl = new Droidline();
const online = (await dl.devices()).filter((p) => p.online);
const results = await Promise.all(online.map(async (p) => {
const d = dl.device(p.name);
await d.wake();
await d.launch("com.android.chrome");
await d.chrome.go("droidline.dev");
return [p.name, (await d.current()).package];
}));
console.log(results);
Use Promise.allSettled instead if one phone failing should not stop the others. Many phones has more patterns.
Notifications and events
Choose which apps the phone forwards first, with notifyFilter or on the app's Status tab. Then wait for one notification, or get a callback for each:
await d.notifyFilter(["com.google.android.gm"]);
const n = await d.waitNotification("textContains", "verification code", 60);
console.log(n.title, n.text);
const stop = await d.onNotification({ package: "com.google.android.gm" }, (n) => console.log(n.title, n.text));
// ... later
stop();
While a notification listener is active, the process keeps running; call stop() to let it exit. Notifications covers replies, clicks and webhooks.
Commands the SDK does not know yet
If the server is newer than your package, call a command by name:
await d.call("some_new_command", { value: 1 });
TypeScript
The package ships its own types. Parameter names, option objects and results are all typed:
import { connect, DroidlineError, type Device } from "droidline";
async function login(d: Device, email: string): Promise<boolean> {
await d.input("id", "email", email);
await d.touchById("login");
return (await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 })) === 1;
}
const d = await connect();
console.log(await login(d, "knife"));
Run TypeScript directly with npx tsx script.ts, or compile it with tsc as usual.
A complete script
The script from Your first script, with a clean exit code:
import { connect, DroidlineError } from "droidline";
async function main() {
const d = await connect();
await d.launch("dev.droidline.demo");
await d.wait("text", "Sign in");
if (await d.exists("text", "Close ad")) await d.touch("text", "Close ad");
await d.input("id", "email", "knife");
if (!(await d.checked("id", "auto_login"))) await d.touch("id", "auto_login");
await d.touch("text", "Log in");
if ((await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 })) !== 1) {
await d.screenshot("login-failed.png");
return 1;
}
console.log(await d.getText("id", "greeting"));
return 0;
}
try {
process.exitCode = await main();
} catch (e) {
if (!(e instanceof DroidlineError)) throw e;
console.error(`${e.code}: ${e.message}`);
process.exitCode = 2;
}