Skip to content

Commit 9059aed

Browse files
ralyodioclaude
andauthored
feat(m3.4): @tronbrowser/sdk and tron run (#31)
* feat(m3.4): @tronbrowser/sdk and `tron run` Public developer SDK + a script runner (PRD M3.4), building on the M3.1–M3.3 Node automation runtime. import { tron } from '@tronbrowser/sdk'; const browser = await tron.launch({ headless: true }); const page = await browser.newPage(); await page.goto(url); const snap = await page.snapshot(); await browser.close(); tron run ./agent.ts # TS runs directly (Node >=24 strips types) tron run ./agent.js --headless --trace ./trace - packages/sdk: Browser (session lifecycle: launch via the tron-session engine, hand out Pages, tear down the temp profile on close) and Page (goto/reload/ back/forward, snapshot, click/fill/type by ref, extract, screenshot, pdf, eval, url/title). analyze/step/runTask are declared but throw until M3.5. Side-effects are injectable; launch reuses TRON_SESSION_BIN so binary resolution isn't duplicated. - tron-run.mjs + tron-run-hooks.mjs: the runner registers an ESM resolver hook mapping @tronbrowser/sdk + /browser-core to the shipped runtime, then imports the user script (no node_modules, no build). --headless/--profile/--trace arrive via env and are honored by tron.launch(). - trace.ts: minimal --trace bundle (metadata.json + actions.jsonl) with values redacted (full trace/replay is M3.7). - install.sh routes `tron run`; build-release ships browser-core + sdk dist and the runner files in the launcher payload. Tests (+8): SDK lifecycle/cleanup/launch-failure with fakes, and an end-to-end run over the real CDP WebSocket transport incl. multi-page Target.createTarget. Verified `tron run` for JS and TS (no build) and --trace end-to-end against a WS CDP stub. Full workspace suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci: build before typecheck/test so cross-package deps resolve @tronbrowser/sdk imports @tronbrowser/browser-core and resolves it via its built dist, so typecheck/test need the dist present. pnpm builds in topological order (browser-core before sdk), so building first fixes the resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c15aa56 commit 9059aed

15 files changed

Lines changed: 810 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,19 @@ jobs:
2525
- name: Lint
2626
run: pnpm lint
2727

28+
# Build first: packages that import another workspace package (e.g.
29+
# @tronbrowser/sdk -> @tronbrowser/browser-core) resolve it via its built
30+
# dist, so typecheck/test need the dist present. pnpm builds in topological
31+
# order, so dependencies compile before their dependents.
32+
- name: Build
33+
run: pnpm build
34+
2835
- name: Typecheck
2936
run: pnpm typecheck
3037

3138
- name: Test
3239
run: pnpm test
3340

34-
- name: Build
35-
run: pnpm build
36-
3741
# package + release jobs are stubbed until the Chromium build lands in CI.
3842
package:
3943
runs-on: ubuntu-latest
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// ESM resolver hook for `tron run`: maps the bare `@tronbrowser/*` specifiers a
2+
// user script imports to the runtime shipped in the launcher payload, so scripts
3+
// need no node_modules of their own. Registered by tron-run.mjs.
4+
let map = {};
5+
6+
export async function initialize(data) {
7+
map = data ?? {};
8+
}
9+
10+
export async function resolve(specifier, context, next) {
11+
if (Object.prototype.hasOwnProperty.call(map, specifier)) {
12+
return { url: map[specifier], shortCircuit: true };
13+
}
14+
return next(specifier, context);
15+
}

apps/desktop/launcher/tron-run.mjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// `tron run <script>` — execute a user JS/TS automation script that imports
2+
// `@tronbrowser/sdk` (PRD M3.4). Node >= 24 strips TS types natively, so .ts
3+
// runs with no build step. The @tronbrowser/* imports resolve to the runtime
4+
// shipped alongside this file via the registered resolver hook.
5+
import { existsSync } from 'node:fs';
6+
import { register } from 'node:module';
7+
import { dirname, resolve as resolvePath, join } from 'node:path';
8+
import { fileURLToPath, pathToFileURL } from 'node:url';
9+
10+
const here = dirname(fileURLToPath(import.meta.url));
11+
const sdkEntry = join(here, 'sdk', 'index.js');
12+
const coreEntry = join(here, 'automate', 'index.js');
13+
14+
const argv = process.argv.slice(2);
15+
let script;
16+
const passthrough = [];
17+
for (let i = 0; i < argv.length; i += 1) {
18+
const a = argv[i];
19+
if (a === '--') { passthrough.push(...argv.slice(i + 1)); break; }
20+
if (!script && !a.startsWith('-')) { script = a; continue; }
21+
if (a === '--headless') { process.env.TRON_RUN_HEADLESS = '1'; continue; }
22+
if (a === '--profile') { process.env.TRON_RUN_PROFILE = argv[++i]; continue; }
23+
if (a === '--trace') { process.env.TRON_RUN_TRACE = resolvePath(argv[++i]); continue; }
24+
passthrough.push(a);
25+
}
26+
27+
if (!script) {
28+
process.stderr.write('usage: tron run <script.js|.ts> [--headless] [--profile <name>] [--trace <dir>]\n');
29+
process.exit(2);
30+
}
31+
32+
const scriptPath = resolvePath(script);
33+
if (!existsSync(scriptPath)) {
34+
process.stderr.write(`tron run: script not found: ${script}\n`);
35+
process.exit(2);
36+
}
37+
if (!existsSync(sdkEntry)) {
38+
process.stderr.write('This TronBrowser build lacks the SDK runtime. Run: tron upgrade\n');
39+
process.exit(1);
40+
}
41+
42+
register(pathToFileURL(join(here, 'tron-run-hooks.mjs')), import.meta.url, {
43+
data: {
44+
'@tronbrowser/sdk': pathToFileURL(sdkEntry).href,
45+
'@tronbrowser/browser-core': pathToFileURL(coreEntry).href,
46+
},
47+
});
48+
49+
// Present argv to the script as if it were invoked directly.
50+
process.argv = [process.argv[0], scriptPath, ...passthrough];
51+
52+
try {
53+
await import(pathToFileURL(scriptPath).href);
54+
} catch (err) {
55+
process.stderr.write(`tron run: ${err && err.stack ? err.stack : err}\n`);
56+
process.exit(1);
57+
}

apps/desktop/scripts/build-release.sh

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,22 +51,25 @@ fetch_marksyncr() {
5151
[ -n "$MKS_SRC" ] || echo " ! MarkSyncr fetch skipped (non-fatal)"
5252
}
5353

54-
# The Node automation runtime for `tron snapshot|click|fill` (PRD M3.2). The
55-
# @tronbrowser/browser-core source has no runtime deps, so its compiled dist tree
56-
# is self-contained; ship it with a {"type":"module"} marker and the shell
57-
# dispatcher runs it via node. Best-effort like the extension fetches — a build
58-
# host without node/pnpm simply omits it (the CLI then reports "run tron upgrade").
54+
# The Node automation runtime for `tron snapshot|click|fill|extract|...` (M3.2/3)
55+
# and the `@tronbrowser/sdk` used by `tron run` (M3.4). Both packages' source has
56+
# no runtime deps, so their compiled dist trees are self-contained; ship each with
57+
# a {"type":"module"} marker and the shell dispatcher / tron-run.mjs run them via
58+
# node. Best-effort like the extension fetches — a build host without node/pnpm
59+
# simply omits them (the CLI then reports "run tron upgrade").
5960
stage_automation() { # dest dir
6061
local s="$1"
6162
command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1 || {
6263
echo " ! automation runtime skipped (needs node + pnpm)"; return; }
63-
if ( cd "$REPO_ROOT" && pnpm --filter @tronbrowser/browser-core build >/dev/null 2>&1 ); then
64-
rm -rf "$s/automate"
64+
if ( cd "$REPO_ROOT" && pnpm --filter @tronbrowser/browser-core --filter @tronbrowser/sdk build >/dev/null 2>&1 ); then
65+
rm -rf "$s/automate" "$s/sdk"
6566
cp -R "$REPO_ROOT/packages/browser-core/dist" "$s/automate"
6667
printf '{\n "type": "module"\n}\n' > "$s/automate/package.json"
67-
echo " + bundled automation runtime (tron snapshot/click/fill)"
68+
cp -R "$REPO_ROOT/packages/sdk/dist" "$s/sdk"
69+
printf '{\n "type": "module"\n}\n' > "$s/sdk/package.json"
70+
echo " + bundled automation runtime + SDK (tron snapshot/extract/run)"
6871
else
69-
echo " ! automation runtime skipped (browser-core build failed)"
72+
echo " ! automation runtime skipped (browser-core/sdk build failed)"
7073
fi
7174
}
7275

@@ -81,6 +84,9 @@ stage() { # dest dir
8184
# Managed-session engine for `tron browser …` / `tron open` (PRD M3.1). Sits
8285
# next to the shim; the `tron` dispatcher resolves it relative to $CURRENT.
8386
install -m 0755 "$DESKTOP/launcher/tron-session" "$s/tron-session"
87+
# `tron run` launcher + ESM resolver hook (PRD M3.4).
88+
install -m 0644 "$DESKTOP/launcher/tron-run.mjs" "$s/tron-run.mjs"
89+
install -m 0644 "$DESKTOP/launcher/tron-run-hooks.mjs" "$s/tron-run-hooks.mjs"
8490
stage_automation "$s"
8591
# -L dereferences the branding symlinks (icons/logo.svg -> repo-root logo.svg)
8692
# so the package contains real files, not dangling links.

apps/web/public/install.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Usage:
9090
tron extract <mode> Extract text|links|forms|tables|main (JSON)
9191
tron screenshot <p> Save a PNG of the current page (--full-page)
9292
tron headless <url> One-shot: --snapshot | --screenshot <p> | --extract <mode>
93+
tron run <script> Run a JS/TS script using @tronbrowser/sdk (--headless/--trace)
9394
tron upgrade Update to the latest release
9495
tron remove Uninstall TronBrowser (keeps your profile data)
9596
tron version Print the installed version
@@ -178,6 +179,15 @@ case "${1:-}" in
178179
headless)
179180
# One-shot: launch a headless ephemeral session, navigate, act, tear down.
180181
run_automation "$@" ;;
182+
run)
183+
# Execute a JS/TS automation script that imports @tronbrowser/sdk (PRD M3.4).
184+
shift
185+
[ "$#" -gt 0 ] || { echo "usage: tron run <script.js|.ts> [--headless] [--profile <name>] [--trace <dir>]" >&2; exit 2; }
186+
_ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")"
187+
RUNNER="$_ld/tron-run.mjs"
188+
command -v node >/dev/null 2>&1 || { echo "tron run needs Node.js (>=24) on PATH." >&2; exit 1; }
189+
[ -f "$RUNNER" ] || { echo "This TronBrowser build lacks the SDK runtime. Run: tron upgrade" >&2; exit 1; }
190+
exec env TRON_SESSION_BIN="$(session_bin)" node "$RUNNER" "$@" ;;
181191
restart)
182192
# Force-quit any running TronBrowser, then launch fresh. Chromium forwards a
183193
# new launch to an already-running instance (which keeps the OLD extension

docs/sdk-and-run.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# SDK and `tron run` (M3.4)
2+
3+
Write browser automation as scripts with the public SDK, and run them with
4+
`tron run` — no build step, no `node_modules` of your own.
5+
6+
```ts
7+
import { tron } from '@tronbrowser/sdk';
8+
9+
const browser = await tron.launch({ headless: true });
10+
const page = await browser.newPage();
11+
12+
await page.goto('https://example.com/contact');
13+
const snap = await page.snapshot();
14+
const emailRef = snap.elements.find((e) => e.name === 'Email')?.ref;
15+
if (emailRef) await page.fill(emailRef, 'jane@example.com');
16+
17+
console.log(await page.extract('links'));
18+
await page.screenshot({ fullPage: true }).then(/**/);
19+
await browser.close();
20+
```
21+
22+
```sh
23+
tron run ./agent.ts # TypeScript runs directly (Node ≥24 strips types)
24+
tron run ./agent.js
25+
tron run ./agent.ts --headless # force headless regardless of launch({})
26+
tron run ./agent.ts --profile work # use a named profile
27+
tron run ./agent.ts --trace ./trace # write an action trace bundle
28+
```
29+
30+
## API (`@tronbrowser/sdk`)
31+
32+
- **`tron.launch(options?)``Browser`**`{ headless?, profile? }`. Starts a
33+
managed session in its own isolated temp profile.
34+
- **`Browser`**: `newPage()`, `pages()`, `close()` (stops the session, removes
35+
the temp profile).
36+
- **`Page`**: `goto`, `reload`, `back`, `forward`, `snapshot`/`snapshotText`,
37+
`click(@ref)`, `fill(@ref, value)`, `type`, `extract(target, fields?)`,
38+
`screenshot`, `pdf`, `eval`, `url`, `title`, `close`.
39+
- `analyze` / `step` / `runTask` are declared but land in **M3.5** (AI analyze).
40+
41+
## How `tron run` works
42+
43+
- The shell `tron` dispatcher runs `tron-run.mjs` via `node`, passing
44+
`TRON_SESSION_BIN` so the SDK can launch/close a managed session.
45+
- `tron-run.mjs` registers an ESM resolver hook that maps `@tronbrowser/sdk`
46+
and `@tronbrowser/browser-core` to the runtime shipped in the launcher payload,
47+
then imports your script. TypeScript needs no build — Node ≥24 strips types.
48+
- `--headless` / `--profile` / `--trace` arrive via env and are honored by
49+
`tron.launch()` when the script doesn't specify otherwise.
50+
51+
## Trace bundles (`--trace <dir>`)
52+
53+
A minimal trace (full trace/replay is M3.7): `metadata.json` plus
54+
`actions.jsonl` — the sequence of page actions. **Values are redacted** (a
55+
`fill` records its ref, never the text), matching the PRD's default privacy.
56+
57+
## Scope / limitations
58+
59+
- Requires Node ≥24 (bundled with the desktop app or on PATH).
60+
- `click`/`fill` take `@refs` from a `snapshot`; richer targets (CSS/text/role)
61+
and character-level `type`/`press` come later.
62+
- SDK contracts are unit-tested and exercised end-to-end over the real CDP
63+
transport in `packages/sdk/src/*.test.ts`.

packages/sdk/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
"test": "vitest run --passWithNoTests",
1919
"lint": "eslint src"
2020
},
21+
"dependencies": {
22+
"@tronbrowser/browser-core": "workspace:*"
23+
},
2124
"devDependencies": {
2225
"typescript": "^5.6.3",
2326
"vitest": "^2.1.4"

packages/sdk/src/browser.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* A managed browser session (PRD M3.4 / §15.1). `tron.launch()` starts a
3+
* `tron-session`, and each Browser owns that session's lifecycle: it hands out
4+
* Page objects (one CDP connection per tab) and tears the session down on close.
5+
*/
6+
import type { CdpConnection, CdpTarget, SessionDescriptor } from '@tronbrowser/browser-core';
7+
import { defaultDeps, type SdkDeps } from './deps.js';
8+
import { Page } from './page.js';
9+
import { Tracer, tracerFromEnv } from './trace.js';
10+
11+
export interface LaunchOptions {
12+
headless?: boolean;
13+
profile?: string;
14+
}
15+
16+
export class Browser {
17+
readonly #deps: SdkDeps;
18+
readonly #dataDir: string;
19+
readonly #descriptor: SessionDescriptor;
20+
readonly #pages: Page[] = [];
21+
readonly #usedTargets = new Set<string>();
22+
readonly #tracer: Tracer | undefined;
23+
readonly #traceDir: string | undefined;
24+
#browserConn: CdpConnection | undefined;
25+
#closed = false;
26+
27+
private constructor(
28+
deps: SdkDeps,
29+
dataDir: string,
30+
descriptor: SessionDescriptor,
31+
trace?: { tracer: Tracer; dir: string },
32+
) {
33+
this.#deps = deps;
34+
this.#dataDir = dataDir;
35+
this.#descriptor = descriptor;
36+
this.#tracer = trace?.tracer;
37+
this.#traceDir = trace?.dir;
38+
}
39+
40+
/** Launch a managed session and return a Browser. */
41+
static async launch(options: LaunchOptions = {}, deps: SdkDeps = defaultDeps): Promise<Browser> {
42+
// CLI flags from `tron run` arrive via env and win when the script is neutral.
43+
const headless = options.headless ?? process.env.TRON_RUN_HEADLESS === '1';
44+
const profile = options.profile ?? process.env.TRON_RUN_PROFILE;
45+
const dataDir = await deps.makeDataDir();
46+
try {
47+
await deps.launchSession(dataDir, {
48+
headless,
49+
...(profile !== undefined ? { profile } : {}),
50+
});
51+
const descriptor = await deps.loadDescriptor(dataDir);
52+
return new Browser(deps, dataDir, descriptor, tracerFromEnv(process.env));
53+
} catch (err) {
54+
await deps.closeSession(dataDir).catch(() => {});
55+
await deps.removeDataDir(dataDir).catch(() => {});
56+
throw err;
57+
}
58+
}
59+
60+
#targets(): Promise<CdpTarget[]> {
61+
return this.#deps.fetchTargets(this.#descriptor.host, this.#descriptor.port);
62+
}
63+
64+
/** Open (or adopt the first free) page target and return a Page for it. */
65+
async newPage(): Promise<Page> {
66+
if (this.#closed) throw new Error('Browser is closed');
67+
68+
let target = (await this.#targets()).find(
69+
(t) => t.type === 'page' && !this.#usedTargets.has(t.id),
70+
);
71+
if (!target) target = await this.#createTarget();
72+
if (!target.webSocketDebuggerUrl) {
73+
throw new Error(`Page target ${target.id} has no webSocketDebuggerUrl`);
74+
}
75+
76+
const conn = await this.#deps.connect(target.webSocketDebuggerUrl);
77+
const page = await Page.attach(conn, target.id, this.#tracer);
78+
this.#usedTargets.add(target.id);
79+
this.#pages.push(page);
80+
return page;
81+
}
82+
83+
async #createTarget(): Promise<CdpTarget> {
84+
if (!this.#descriptor.webSocketDebuggerUrl) {
85+
throw new Error('Cannot open another page: session has no browser WebSocket endpoint');
86+
}
87+
this.#browserConn ??= await this.#deps.connect(this.#descriptor.webSocketDebuggerUrl);
88+
const { targetId } = await this.#browserConn.send<{ targetId: string }>('Target.createTarget', {
89+
url: 'about:blank',
90+
});
91+
const created = (await this.#targets()).find((t) => t.id === targetId);
92+
if (!created) throw new Error('Newly created page target did not appear');
93+
return created;
94+
}
95+
96+
/** Pages opened so far. */
97+
pages(): Page[] {
98+
return [...this.#pages];
99+
}
100+
101+
/** Close every page, stop the managed session, and remove its temp profile. */
102+
async close(): Promise<void> {
103+
if (this.#closed) return;
104+
this.#closed = true;
105+
for (const page of this.#pages) page.close();
106+
this.#browserConn?.close();
107+
if (this.#tracer && this.#traceDir) {
108+
await this.#tracer
109+
.flush(this.#traceDir, { headless: this.#descriptor.headless, profile: this.#descriptor.profileName })
110+
.catch(() => {});
111+
}
112+
await this.#deps.closeSession(this.#dataDir).catch(() => {});
113+
await this.#deps.removeDataDir(this.#dataDir).catch(() => {});
114+
}
115+
}

0 commit comments

Comments
 (0)