Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ All notable changes to `@openclaw/libterminal` will be documented in this file.

### Changed

- Make Ghostty a pinned optional peer: renderer users install `ghostty-web@0.4.0`; stream/protocol-only installs no longer pull it in. Preserve embedded Worker assets.
- Export the existing renderer-neutral `attachTerminalStream` from `/stream`, preserving `/browser`, with an xterm example and package/type/bundle footprint checks.

- Run full CodeQL scans daily instead of on every main push, preserving PR and manual analysis and protecting active scheduled scans from cancellation.
- Refresh Node.js types, tsdown, and WebSocket test tooling while preserving the dependency cooldown and supported Node.js versions. (#139, thanks @dependabot[bot])

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ pnpm add @openclaw/libterminal
```

The Node.js adapters require Node.js `^22.18.0 || >=24.11.0`.
For Ghostty rendering or Node-hosted Ghostty assets, install the pinned optional peer:

```sh
pnpm add ghostty-web@0.4.0
```

Stream and protocol consumers do not need a renderer. The self-contained
`/worker-assets` export retains its embedded assets and also needs no peer.

Install the optional PTY driver when using `spawnLocalPty()`:

```sh
Expand Down
41 changes: 41 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,47 @@ independent copies, so changing one cannot corrupt other readers or stored repla
The exported testing helpers also copy recorded input and output bytes, including
sources that reuse a `Buffer` between chunks.

## Keep an existing renderer or transport

`attachTerminalStream` accepts an ordered byte iterator and a synchronous
`write(Uint8Array)` target. Import it from `/stream` to avoid Ghostty types and
renderer dependencies. The `/browser` export remains available.

```ts
import { attachTerminalStream } from "@openclaw/libterminal/stream";

await attachTerminalStream(existingTerminal, output, abort.signal);
```

[`examples/xterm.ts`](https://github.com/openclaw/libterminal/blob/main/examples/xterm.ts) connects an actual xterm instance and
owns its cancellation/disposal. Add `@xterm/xterm` and its CSS in your app. Keep
input authorization and your existing WebSocket/SSH transport in the app.
Attachment writes in order but does not wait for asynchronous rendering to finish;
use the renderer's completion callback when you need that guarantee.

`spawnLocalPty({ driver })` already accepts an injected `PtyDriver`. Applications
using a prebuilt or platform-specific PTY package can adapt its `spawn` result to
that interface without adding another driver to libterminal.

## Compatibility and footprint checks

| Surface | Automated check |
| ---------------------- | ------------------------------------------------------------- |
| Node 22, 24, 26 | Package, unit tests and installed consumer in CI |
| Browser / Worker | Existing Ghostty asset parity and Chromium tests |
| xterm | ANSI/UTF-8 rendering, cancellation and disposal in Chromium |
| Renderer-free consumer | Empty install, strict TypeScript declarations and Vite bundle |

`pnpm pack:check` verifies that installing libterminal alone does not install
Ghostty or node-pty. It reports compressed archive bytes, unpacked package bytes
and compressed root, protocol, stream and Worker consumer bundles separately.
Each portable entry must remain below 12 KB gzip and contain no
Ghostty/embedded-asset modules.

The root archive still contains `/worker-assets` for existing consumers. Moving
that synchronous public export into a separate package requires a coordinated
breaking release; optional peers do not remove those bytes from the archive.

## Browser

Ghostty terminals default to read-only. The application owns authorization,
Expand Down
20 changes: 20 additions & 0 deletions examples/xterm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/// <reference types="vite/client" />
import "@xterm/xterm/css/xterm.css";
import { attachTerminalStream } from "@openclaw/libterminal/stream";
import { Terminal } from "@xterm/xterm";

// Keep your existing transport: only its ordered output iterator is required.
export function mountXterm(parent: HTMLElement, output: AsyncIterable<Uint8Array>) {
const terminal = new Terminal({ cols: 80, rows: 24 });
terminal.open(parent);
const abort = new AbortController();
const done = attachTerminalStream(terminal, output, abort.signal);
return {
terminal,
done,
dispose() {
abort.abort();
terminal.dispose();
},
};
}
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,14 @@
"test:coverage": "pnpm run test --coverage",
"typecheck": "pnpm run generate:worker-assets && tsc --noEmit && tsc -p tsconfig.portable.json --noEmit"
},
"dependencies": {
"ghostty-web": "0.4.0"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.5",
"@types/ws": "^8.18.2",
"@typescript/native": "npm:typescript@^7.0.2",
"@vitest/coverage-v8": "^5.0.5",
"@xterm/xterm": "6.0.0",
"ghostty-web": "0.4.0",
"node-pty": "^1.1.0",
"oxfmt": "^0.75.0",
"oxlint": "^1.89.0",
Expand All @@ -105,11 +104,15 @@
"ws": "^8.22.0"
},
"peerDependencies": {
"ghostty-web": "0.4.0",
"node-pty": ">=1.0.0"
},
"peerDependenciesMeta": {
"node-pty": {
"optional": true
},
"ghostty-web": {
"optional": true
}
},
"engines": {
Expand Down
15 changes: 11 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions scripts/browser-smoke-server.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { build } from "vite";
import { readGhosttyAsset } from "../dist/node.js";
import { readGhosttyWorkerAsset } from "../dist/worker-assets.js";

Expand Down Expand Up @@ -66,6 +67,28 @@ function html(assets) {
</html>`;
}

const xtermBuild = await build({
configFile: false,
logLevel: "error",
build: {
write: false,
lib: { entry: "examples/xterm.ts", formats: ["es"], fileName: "xterm-example" },
minify: true,
},
});
const xtermOutputs = (Array.isArray(xtermBuild) ? xtermBuild : [xtermBuild]).flatMap((result) => {
if (!("output" in result)) throw new Error("unexpected example build");
return result.output;
});
const xtermScript = xtermOutputs.find((file) => file.type === "chunk");
if (!xtermScript || xtermScript.type !== "chunk") throw new Error("missing xterm example");

const xtermStyles = xtermOutputs
.flatMap((file) =>
file.type === "asset" && file.fileName.endsWith(".css") ? [String(file.source)] : [],
)
.join("\n");

const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", `http://${host}:${port}`);
Expand All @@ -78,6 +101,22 @@ const server = createServer(async (request, response) => {
);
return;
}
if (pathname === "/xterm") {
send(
response,
'<!doctype html><meta charset="utf-8"><title>xterm example</title><link rel="stylesheet" href="/xterm-example.css">',
"text/html; charset=utf-8",
);
return;
}
if (pathname === "/xterm-example.css") {
send(response, xtermStyles, "text/css; charset=utf-8");
return;
}
if (pathname === "/xterm-example.js") {
send(response, xtermScript.code, "text/javascript; charset=utf-8");
return;
}
const distFile = distScriptPath(pathname);
if (distFile) {
send(response, await readFile(distFile), "text/javascript; charset=utf-8");
Expand Down
80 changes: 78 additions & 2 deletions scripts/check-pack.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
existsSync,
Expand All @@ -9,6 +10,9 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
import { build } from "vite";

const workdir = mkdtempSync(join(tmpdir(), "libterminal-pack-"));
const npmCommand = resolveNpmCli();
Expand Down Expand Up @@ -52,9 +56,11 @@ try {
encoding: "utf8",
}),
);
/** @type {Array<{ filename: string; files: Array<{ path: string }> }> | Record<string, { filename: string; files: Array<{ path: string }> }>} */
/** @type {Array<{ filename: string; size: number; unpackedSize: number; files: Array<{ path: string }> }> | Record<string, { filename: string; size: number; unpackedSize: number; files: Array<{ path: string }> }>} */
const pack = JSON.parse(output);
const [{ filename, files }] = Array.isArray(pack) ? pack : Object.values(pack);
const [{ filename, files, size, unpackedSize }] = Array.isArray(pack)
? pack
: Object.values(pack);
const expected = [
"docs/usage.md",
"docs/ghostty-upgrade.md",
Expand Down Expand Up @@ -94,6 +100,76 @@ try {
cwd: workdir,
stdio: "pipe",
});
for (const name of ["ghostty-web", "node-pty"]) {
assert.equal(
existsSync(join(workdir, "node_modules", name)),
false,
`${name} must remain opt-in`,
);
}
const consumer = join(workdir, "consumer.ts");
writeFileSync(
consumer,
`import { attachTerminalStream } from "@openclaw/libterminal/stream";
const output: AsyncIterable<Uint8Array> = { async *[Symbol.asyncIterator]() { yield new Uint8Array([65]); } };
await attachTerminalStream({ write(bytes) { console.log(bytes); } }, output);
`,
);
execFileSync(
process.execPath,
[
fileURLToPath(import.meta.resolve("typescript/lib/tsc.js")),
"--noEmit",
"--strict",
"--skipLibCheck",
"false",
"--target",
"es2023",
"--module",
"nodenext",
"--moduleResolution",
"nodenext",
consumer,
],
{ cwd: workdir, stdio: "pipe" },
);
/** @type {Record<string, number>} */
const gzipBytes = {};
for (const entry of ["", "/protocol", "/stream", "/worker"]) {
writeFileSync(
consumer,
`import * as api from "@openclaw/libterminal${entry}";
globalThis.terminalApi = api;\n`,
);
const bundled = await build({
configFile: false,
root: workdir,
logLevel: "error",
build: { write: false, minify: true, lib: { entry: consumer, formats: ["es"] } },
});
const outputs = (Array.isArray(bundled) ? bundled : [bundled]).flatMap((result) => {
if (!("output" in result)) throw new Error("unexpected consumer build");
return result.output;
});
const chunks = outputs.filter((file) => file.type === "chunk");
for (const chunk of chunks) {
assert.ok(
!Object.keys(chunk.modules).some((id) => /ghostty|node-pty|worker-assets/.test(id)),
"renderer-free graph",
);
}
const bytes = gzipSync(chunks.map((chunk) => chunk.code).join("\n")).byteLength;
assert.ok(bytes < 12000, `${entry || "/"} grew to ${bytes} gzip bytes`);
gzipBytes[entry || "/"] = bytes;
}
console.log(
JSON.stringify({
packageBytes: size,
unpackedBytes: unpackedSize,
gzipBytes,
optionalDependenciesAbsent: ["ghostty-web", "node-pty"],
}),
);
execFileSync(
process.execPath,
[
Expand Down
46 changes: 3 additions & 43 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import type {
ITerminalOptions,
Terminal,
} from "ghostty-web";
import { abortedResult, abortPromise } from "./abort.js";
import { attachTerminalStream } from "./stream.js";

export { attachTerminalStream } from "./stream.js";
import { assertTerminalSize, LibterminalError, type TerminalSize } from "./index.js";

export {
Expand Down Expand Up @@ -132,48 +134,6 @@ export async function createGhosttyTerminal(
}
}

export async function attachTerminalStream(
target: Pick<Terminal, "write">,
source: AsyncIterable<Uint8Array>,
signal?: AbortSignal,
): Promise<void> {
const iterator = source[Symbol.asyncIterator]();
const aborted = abortPromise(signal);
let completed = false;
try {
for (;;) {
if (signal?.aborted) {
return;
}
const result = aborted
? await Promise.race([iterator.next(), aborted.promise])
: await iterator.next();
if (result === abortedResult || result.done) {
completed = result !== abortedResult;
return;
}
if (signal?.aborted) {
return;
}
target.write(result.value);
}
} finally {
if (!completed) {
try {
const returned = iterator.return?.();
if (aborted) {
await Promise.race([aborted.promise, returned]);
} else {
await returned;
}
} catch {
// Incomplete streams have already failed or been aborted; preserve that outcome.
}
}
aborted?.dispose();
}
}

class BrowserTerminalController implements GhosttyTerminalController {
readonly terminal: Terminal;
private readonly fitAddon: GhosttyFitAddon;
Expand Down
Loading
Loading