diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd9482..1f9717e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]) diff --git a/README.md b/README.md index 569e99a..1516971 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/usage.md b/docs/usage.md index c050075..23220f0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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, diff --git a/examples/xterm.ts b/examples/xterm.ts new file mode 100644 index 0000000..4f0ba98 --- /dev/null +++ b/examples/xterm.ts @@ -0,0 +1,20 @@ +/// +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) { + 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(); + }, + }; +} diff --git a/package.json b/package.json index ff5e5ca..6f76bdf 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb057f3..1727810 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,6 @@ settings: importers: .: - dependencies: - ghostty-web: - specifier: 0.4.0 - version: 0.4.0 devDependencies: '@playwright/test': specifier: ^1.64.1 @@ -27,6 +23,12 @@ importers: '@vitest/coverage-v8': specifier: ^5.0.5 version: 5.0.5(vitest@5.0.5) + '@xterm/xterm': + specifier: 6.0.0 + version: 6.0.0 + ghostty-web: + specifier: 0.4.0 + version: 0.4.0 node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -642,6 +644,9 @@ packages: '@vitest/spy@5.0.5': resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@yuku-core/binding-android-arm64@0.19.0': resolution: {integrity: sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==} cpu: [arm64] @@ -1495,6 +1500,8 @@ snapshots: '@vitest/spy@5.0.5': {} + '@xterm/xterm@6.0.0': {} + '@yuku-core/binding-android-arm64@0.19.0': optional: true diff --git a/scripts/browser-smoke-server.mjs b/scripts/browser-smoke-server.mjs index b708118..47df7bd 100644 --- a/scripts/browser-smoke-server.mjs +++ b/scripts/browser-smoke-server.mjs @@ -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"; @@ -66,6 +67,28 @@ function html(assets) { `; } +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}`); @@ -78,6 +101,22 @@ const server = createServer(async (request, response) => { ); return; } + if (pathname === "/xterm") { + send( + response, + 'xterm example', + "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"); diff --git a/scripts/check-pack.mjs b/scripts/check-pack.mjs index 236ed36..4c07b1c 100644 --- a/scripts/check-pack.mjs +++ b/scripts/check-pack.mjs @@ -1,3 +1,4 @@ +import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync, @@ -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(); @@ -52,9 +56,11 @@ try { encoding: "utf8", }), ); - /** @type {Array<{ filename: string; files: Array<{ path: string }> }> | Record }>} */ + /** @type {Array<{ filename: string; size: number; unpackedSize: number; files: Array<{ path: string }> }> | Record }>} */ 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", @@ -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 = { 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} */ + 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, [ diff --git a/src/browser.ts b/src/browser.ts index 8291da2..93af765 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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 { @@ -132,48 +134,6 @@ export async function createGhosttyTerminal( } } -export async function attachTerminalStream( - target: Pick, - source: AsyncIterable, - signal?: AbortSignal, -): Promise { - 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; diff --git a/src/stream.ts b/src/stream.ts index 875ec9c..dfe66c3 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -1,7 +1,54 @@ +import { abortedResult, abortPromise } from "./abort.js"; import { AsyncByteQueue } from "./byte-queue.js"; import { concatenateBytes } from "./bytes.js"; import { LibterminalError } from "./index.js"; +export type TerminalStreamTarget = { + write(bytes: Uint8Array): void; +}; + +export async function attachTerminalStream( + target: TerminalStreamTarget, + source: AsyncIterable, + signal?: AbortSignal, +): Promise { + 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(); + } +} + export type SlowSubscriberPolicy = "disconnect" | "drop-oldest"; export type FanoutEvent = diff --git a/test/browser.test.ts b/test/browser.test.ts index d8f34c1..9dccd17 100644 --- a/test/browser.test.ts +++ b/test/browser.test.ts @@ -112,6 +112,10 @@ describe("createGhosttyTerminal", () => { }); describe("attachTerminalStream", () => { + it("keeps the browser export identical to the renderer-neutral stream export", async () => { + const stream = await import("../src/stream.js"); + expect(attachTerminalStream).toBe(stream.attachTerminalStream); + }); it.each([ ["read", "sync"], ["read", "async"], diff --git a/test/browser/ghostty.spec.ts b/test/browser/ghostty.spec.ts index f312ac2..eccc7eb 100644 --- a/test/browser/ghostty.spec.ts +++ b/test/browser/ghostty.spec.ts @@ -220,7 +220,7 @@ for (const assets of ["node", "worker"]) { const pkg = JSON.parse(await readFile("package.json", "utf8")); const baseline = { schemaVersion: 1, - wrapper: pkg.dependencies["ghostty-web"], + wrapper: pkg.peerDependencies["ghostty-web"], assets, workload, browser: browser.version(), diff --git a/test/browser/xterm.spec.ts b/test/browser/xterm.spec.ts new file mode 100644 index 0000000..02a856b --- /dev/null +++ b/test/browser/xterm.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; + +test("xterm consumes ordered ANSI and UTF-8 output without Ghostty", async ({ page }) => { + const requests: string[] = []; + page.on("request", (request) => requests.push(request.url())); + await page.goto("/xterm"); + const result = await page.evaluate(async () => { + const exampleUrl = "/xterm-example.js"; + const { mountXterm } = (await import(exampleUrl)) as typeof import("../../examples/xterm.js"); + const bytes = new TextEncoder().encode("\x1b[32mcafé 日本語\x1b[0m"); + const abortable = { + async *[Symbol.asyncIterator]() { + yield bytes.subarray(0, 7); + yield bytes.subarray(7); + }, + }; + const mounted = mountXterm(document.body, abortable); + await mounted.done; + await new Promise((resolve) => mounted.terminal.write("", resolve)); + const text = mounted.terminal.buffer.active.getLine(0)?.translateToString(true); + mounted.dispose(); + let returned = false; + const pending = mountXterm(document.body, { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: async () => { + returned = true; + return { done: true, value: undefined }; + }, + }), + }); + pending.dispose(); + await pending.done; + return { text, returned, terminals: document.querySelectorAll(".xterm").length }; + }); + expect(result).toEqual({ text: "café 日本語", returned: true, terminals: 0 }); + expect(requests.some((url) => /ghostty|\.wasm/.test(url))).toBe(false); +});