Skip to main content

Terminal Region

The terminal region shows a command's output the way a terminal would: colours, bold, cursor movement, progress bars that redraw in place, and full-screen boxes. The server sends the command's raw bytes, unchanged, and xterm.js renders them in the browser. Use it when the output only makes sense on a terminal.

The region lives in its own module, juneau-terminal-region (package org.apache.juneau.terminal.region). It is opt-in: add the module and nothing else changes. It depends on juneau-console-views and on the xterm.js WebJar (org.webjars.npm:xterm__xterm, pinned at 6.0.0).

A console page card​

<@card type="terminal" id="build" title="Build">
{
contractVersion: '1',
terminal: {
bytesUrl: '/runs/juneau-terminal/r42/bytes',
eventsUrl: '/runs/juneau-terminal-view/r42/events',
refreshMs: 1000,
title: 'mvn test',
tailBytes: 2097152
}
}
</@card>
KeyRules
bytesUrlRequired. A same-origin path, with no scheme, no leading //, no .. segment and no {.
eventsUrlOptional. A terminal-view events endpoint over the same run. It gives the steps their offsets and the badge its exit code.
refreshMsOptional. The poll interval while the command runs. Values below 1000 are raised to 1000; a value of 0 or less is an error.
titleOptional. Shown in the toolbar.
tailBytesOptional, 1 to 1 GiB, default 2 MiB. On first load only the last tailBytes are fetched; Load all fetches the rest.

The card takes its options as the body only; src= and template= are rejected. The page needs no toolkit=: the card pulls the terminal toolkit pack, which brings views and xterm with it. From Java, TerminalDef.forMixin("build", "/runs", "r42") builds the same options.

The endpoints​

Implement TerminalMixin on a REST resource. Its one abstract method resolves an id to a source, and is where your access rules live:

@Rest
public class RunsResource extends BasicRestServlet implements TerminalMixin, TerminalViewMixin {
@Override
public Optional<TerminalSource> terminalSource(String id, RestRequest req) {
return runs.find(id).map(Run::terminal);
}
}

TerminalEndpoints gives the same logic for custom paths.

PathAnswers
GET …/juneau-terminal/{id}/bytes?from=<offset>&max=<n>Raw bytes from from (default 0), at most 256 KiB per call.
GET …/juneau-terminal/{id}/rawEvery byte still held, as an attachment named <id>.log.
GET …/juneau-terminal.js, …/juneau-terminal.cssThe runtime and its stylesheet.

The bytes endpoint sets these headers:

HeaderMeaning
Term-NextThe offset to ask for next.
Term-EndThe current end of the log.
Term-Cols, Term-RowsThe terminal size the command ran at.
Term-Donetrue once the command has finished and every byte has been read.
Term-Truncatedtrue when the bytes before from are no longer held.
Term-Errorgone when the log has been deleted.

Ids match ^[A-Za-z0-9_-]{1,128}$; anything else is 400. An unknown or forbidden id is 404. A from past the end is 416, with Term-End. A from or max that is not a plain non-negative integer is 400, as is a from above 2^53-1. Both endpoints send X-Content-Type-Options: nosniff.

When the source has gone, bytes still answers 200 with no bytes, Term-Done: true and Term-Error: gone, and Term-Next and Term-End equal to from. raw answers 200 with an empty body and the same Term-Error: gone.

Sources​

SourceUse
FileTerminalSource.create(path)A growing log file. The size comes from a <log>.size sidecar ({"cols":C,"rows":R}), default 120×40.
MemoryTerminalSource.create(capacity, cols, rows)A ring buffer your code writes into. Readers that fall behind see Term-Truncated.

Implement TerminalSource for anything else.

Running a command: TerminalProcess​

TerminalProcess.start(tool, cmd, cols, rows, dir) runs cmd under juneau_terminal.py --pty at a fixed size. tool names the output parser and must be maven, pytest, playwright or generic; anything else throws. The command sees a real terminal, so tools that colour or redraw behave as they do in a shell. The process writes the log, the size sidecar and a terminal-view events file into dir. terminal() and events() give the two sources to serve, and both report done once the process has exited. cmd is a list of arguments, passed as they are: it is never joined into a string or run through a shell. Each run needs its own dir; a directory that already holds a run is refused. The script is found through the juneau.terminal.script system property or the JUNEAU_TERMINAL_SCRIPT environment variable; python3 must be on the PATH. POSIX only.

dir is also the command's working directory. The runner's own stdout and stderr go to runner.out in it, which is appended to and has no size cap; it is for diagnosis only and is never served.

TerminalProcess.start(tool, cmd, cols, rows, dir, prompts) also opens a prompt channel: the runner gets --prompts stdio, each prompt line the script sends goes to TerminalPrompts.onLine, onEnd is called once when the channel ends, and answer(jsonLine) sends a reply. Neither a prompt nor an answer is logged. Prompts and input are mutually exclusive. close() ends the channel, and the runner then cancels the job. The line format is the prompt protocol.

Steps and the terminal view​

Terminal-view step, suite and test events can carry rawOffset, the byte offset in the log where the line that produced the event starts. With an eventsUrl, the terminal records the line each step starts on. A terminal view next to it links each step to that line with rawHref: '#build-O{offset}', where build is the terminal card's id. The terminal scrolls when the page hash changes. rawHref takes exactly one {offset}. A deep link that points before the start of a trimmed tail loads everything once (as Load all does) and then scrolls.

In the browser​

  • The terminal keeps the command's columns and rows, and scales its font (8 to 20 px) to fit the panel.
  • 100,000 lines of scrollback by default (see xterm options), a search box (Enter for next, Shift+Enter for previous) over the screen being shown, Copy, and Follow.
  • A Raw link downloads the whole log. It is shown only when bytesUrl ends in /bytes and resolves to an http: or https: URL; the link is the same path with /raw.
  • Polling backs off to 5 s after three empty polls, pauses while the tab is hidden, and retries errors at 1, 2, 5, then 10 s.
  • Once the bytes are done, the events are still polled until one reports the run finished. After 5 empty polls in a row the client gives up on them and shows the final badge without an exit code.
  • Links open only for http: and https:. Clipboard writes from the output (OSC 52) are ignored. The terminal takes no input unless both the card and the page wire it; see Input.

xterm options​

JuneauTerminal.mount(host, {bytesUrl, terminal: {convertEol: true}}) passes a few xterm options through to the terminal. Use convertEol for output with bare \n line ends (anything not written through a PTY), which xterm otherwise draws as a staircase.

KeyTypeMeaning
convertEolbooleanTreat \n as \r\n.
scrollbackinteger, 0 to 1,000,000Lines of scrollback (default 100,000).
fontFamilynon-empty stringThe terminal font family.

On the client, other keys and values of the wrong type are ignored. The size, input, link and font-size options stay with Juneau. A card carries the options nested as {bytesUrl, terminal: {convertEol: true}}, the same shape mount takes. Server-declared cards accept the same terminal key: in a <@card> FreeMarker body (terminal: {bytesUrl: '/runs/juneau-terminal/r42/bytes', terminal: {convertEol: true}}), through TerminalDef.fromMap, or from Java with the terminal(Map) builder method (TerminalDef.create(id)....terminal(Map.of("convertEol", true))). On the server, unlike the client, an unknown inner key or a value of the wrong type or range is an error rather than being ignored.

Input​

A terminal card can let the user type into a running job. Input is off by default and is opt-in at every layer: the process, the access check, the bus handler and the page contract each have to say yes. It lives in a second module, juneau-terminal-bus (package org.apache.juneau.terminal.bus), which adds TerminalInput and depends on the terminal module and the server bus.

Wiring​

var process = TerminalProcess.start("generic", List.of("./release.sh"), 120, 40, dir, true);

TerminalAccess access = new TerminalAccess() {
public boolean canWatch(TerminalCaller caller, String terminalId) { return mayWatch(caller, terminalId); }
public Optional<TerminalSink> inputSink(TerminalCaller caller, String terminalId) {
return mayType(caller, terminalId) ? Optional.of(process) : Optional.empty();
}
};

var input = TerminalInput.create(access);
var bus = ServerBus.create(BusPolicy.create()
.upstream(TerminalInput.TOPIC, input)
.downstream(TerminalInput.STATUS_TOPIC, false)
.build());
input.bind(bus);
  • TerminalProcess always implements TerminalSink. Started with input true it takes bytes through write; with input false (the default of the shorter start overloads) acceptsInput() is always false.
  • TerminalAccess decides, per caller and terminal id, who may watch and who may type. inputSink returns empty by default, so a TerminalAccess that only implements canWatch allows no typing. A typist must also be allowed to watch.
  • The handler answers through the bus, but the bus is built from the policy that holds the handler, so the handler is bound to the bus afterwards, once.
  • The two policy lines register term.input as an upstream topic handled by TerminalInput, and term.input-status as a downstream topic that is not retained.

The page contract​

The card sets terminal.input and terminal.inputId. inputId is the terminal id sent in every frame; it must match the id grammar of the endpoints above, and input: true without it is an error. TerminalDef.forMixin fills inputId from the terminal id you pass it. The card then publishes term.input, subscribes to term.input-status in the input-status role (the only role a terminal card accepts), and the page declares the status topic and a WebSocket bridge:

{
"contractVersion": "1",
"title": "T",
"nav": [],
"activeNav": [],
"topics": [
{"topic": "term.input-status", "retain": false, "publisher": "server"}
],
"bridges": [
{"id": "term", "transport": "websocket", "session": "/c/juneau-bus/session",
"downstream": ["term.input-status"], "upstream": ["term.input"]}
],
"cards": [
{"id": "term", "type": "terminal",
"terminal": {"bytesUrl": "/t/juneau-terminal/t1/bytes", "input": true, "inputId": "t1"},
"publishes": [{"topic": "term.input", "retain": false}],
"subscribes": [{"topic": "term.input-status", "as": "input-status"}]}
]
}

Upstream topics are only allowed on a WebSocket bridge, so an SSE bridge cannot carry input.

A FreeMarker page wires input the same way. input and inputId go in the card body's terminal object, publishes and subscribes are attributes on <@card>, and the topic and bridge are declared with <@topic> and <@bridge>:

<@topic name="term.input-status" retain=false publisher="server"/>
<@bridge id="term" transport="websocket" session="servlet:/juneau-bus/session"
downstream="term.input-status" upstream="term.input"/>
<@card type="terminal" id="term" title="Release"
publishes="term.input"
subscribes=[{"topic": "term.input-status", "as": "input-status"}]>
{
contractVersion: '1',
terminal: {
bytesUrl: '/t/juneau-terminal/t1/bytes',
input: true,
inputId: 't1'
}
}
</@card>

A frame on term.input is {"id": <terminal id>, "data": <string>}, and an empty data is a probe. Every probe, and every refused data frame, is answered on term.input-status to the sending session only, as {"id", "state"}. The state is one of:

StateMeaning
liveA probe succeeded: this caller may type and the job is taking input.
deniedThe caller may not watch the terminal or has no input sink for it.
closedThe sink no longer accepts input (the job ended, or a write failed).
busyThe sink's pending-input buffer was full and the bytes were dropped.
badThe frame was malformed: a bad or missing id, a missing or non-string data, or any key besides id and data.

An accepted data frame gets no answer. The answer repeats the id only when it is a valid terminal id; for an invalid one the answer has no id key at all (it is omitted, not null).

What the user sees​

Without input, the region is a transcript with a search box, Copy, a Follow toggle, a status badge and a Raw link, as in the petstore jobs page:

A terminal region showing a finished job&#39;s transcript, with search, Copy, Follow, a Done badge and a Raw link

With input enabled:

  • A toggle in the toolbar, Enable input. It appears only with input: true, an inputId and a WebSocket bridge carrying term.input upstream and term.input-status downstream; otherwise the card looks as before. It is the only way to arm: pressing it sends a probe, and the terminal accepts keystrokes only after a live answer.
  • A status chip next to it: Input live while armed, Input unavailable when the probe got no answer within 2 s, Input not permitted after denied, Job ended after closed, Input throttled: some keys not sent after busy, and Disconnected: input not sent while the bridge is down.
  • For denied and closed the toggle is disabled and relabelled Input not permitted or Job ended. A denied or closed that arrives after the 2 s probe timeout still disables the toggle.
  • Pressing Esc twice within 500 ms disarms and swallows the second Esc, so it never reaches the job. The first Esc is sent as usual.
  • The terminal also disarms on a denied or closed answer while armed, when the bridge disconnects (chip Disconnected: input not sent), and when the run finishes (chip Job ended).

Keystrokes are batched: the browser sends at most one frame every 100 ms, and a frame carries at most 4096 UTF-8 bytes of what was typed (longer input is split across frames on code point boundaries).

The security model​

  • Default-deny. No input without input on the process, a canWatch that returns true, a present inputSink, and a sink that still accepts input. Each failure is answered as denied or closed; the answer carries no reason beyond that state, and never says why access was refused.

  • Rate limit. The bus's maxUpstreamPerSecond policy (20 frames per second per session by default) bounds the frame rate; the client's 100 ms batching stays well under it. Each frame is limited to the bus frame size (64 KiB by default). The process holds at most 64 KiB of pending input; beyond that write returns false and the caller gets busy. The write is queued and never blocks the WebSocket thread.

  • Two layers. The bus authorizer (BusPolicy.Builder.authorizer) runs once, when the session opens, as a coarse per-page switch. TerminalAccess runs on every frame and is the only per-user gate.

  • Do not strip the input topics for some users. The browser refuses the whole session when its grants do not match the contract, so a watch-only user would lose watching too. Deny them in TerminalAccess instead.

  • CSRF and origin. There is no per-frame token. Frames rely on the CSRF check on the session POST and the exact-origin check on the WebSocket upgrade.

  • A sink is a keyboard. Never return one for every viewer.

  • Audit. Each accepted non-empty write is logged at INFO on the juneau.terminal.input logger:

    principal=alice terminal=t1 bytes=10 keys=^C,ESC,CR,TAB,BS,^D,^Z

    keys names the control bytes present, comma-separated, in order of first appearance, at most 8. ^C, ^D, ^Z, ESC, CR, TAB and BS are named (both 0x08 and DEL, 0x7F, are BS); any other C0 byte uses caret notation (^A, ^J for LF, ^@, and so on). The typed text is never logged. An anonymous caller is principal=-. Any other principal has every character at or below 0x20, every ISO control, U+2028, U+2029, =, \ and % written as %XX for each of its UTF-8 bytes (upper-case hex), so a name can neither split the line nor forge a field.

  • Refusals are logged at FINE only, as principal=<p> terminal=<id|-> state=<s>, with - for an invalid id.

The validator rule​

A card with terminal.input: true must carry the whole wiring: the term.input publish, the term.input-status subscription in the input-status role, a topics entry declaring term.input-status with publisher: "server", and a WebSocket bridge carrying term.input upstream and term.input-status downstream. The page validator reports each missing piece as E-58, for example terminal card 'term' has input: true but its publishes lacks 'term.input'.

The browser runs the same check as E-JS-52. The problem is not tied to a card, so the console logs it to the browser console and shows it in the page banner. E-58 is a page-validator code and is unrelated to the bus runtime's E-JS-58 (a bad frame).

A FreeMarker or PageSpec page fails to render with the first such problem.

Limits​

  • There is no paste guard beyond the batching and rate limit above: pasted text goes to the job as keystrokes, in frames of up to 4096 bytes.
  • Input is bytes written into the job's pseudo-terminal, the runner's PTY, unfiltered. The terminal's line discipline acts on control characters: ^C interrupts the foreground process (an unhandled interrupt ends the job with exit 130), and ^D ends a read, or ends input at the start of a line. ^Z does not suspend a plain job, because nothing could resume it; a shell with job control of its own, such as bash -i, still suspends its foreground job. What the job does after that is the job's business. Text the job does not echo, such as a password, never reaches the log. The audit line records which control keys were sent, not what was typed.

Bus nudges​

By default a card polls bytes?from=<offset> every second, or every 5 s once it has gone idle. When the page has a bus, the server can instead send a nudge: a small hint that terminal t1 now ends at offset N. The card fetches straight away, so output appears with low latency while the page polls far less.

A nudge carries only end (the offset one past the last byte) and, once no more bytes will be appended, done: true. It never carries terminal bytes. Polling stays the fallback and the safety net: a page with no bus, or with no nudge wiring, behaves exactly as described above.

Server side​

TerminalNudges lives in juneau-terminal-bus next to TerminalInput. The terminal module itself has no bus dependency; it only knows the TerminalNudgePublisher interface, void nudge(String terminalId, long end, boolean done), which TerminalNudges implements.

var nudges = TerminalNudges.create(access);

var bus = ServerBus.create(BusPolicy.create()
.downstream("term.nudge:*", false)
.build());
nudges.bind(bus);
  • The topic is term.nudge:<terminalId>. The downstream line must be present and unretained; bind throws IllegalStateException otherwise. A retained nudge would replay a stale offset to every new session.
  • bind can be called once, and close stops the scheduler. A nudge before bind or after close is dropped.
  • Nudges are coalesced: at most one per terminal per 250 ms, always carrying the latest end. A done whose delivery fails is picked up by polling.
  • The publisher must not block or throw. TerminalNudges.nudge only records state and schedules; the delivery runs on its own daemon thread.

Attach a source to the publisher with the id it is served under:

var memory = MemoryTerminalSource.create(1 << 20, 120, 40).nudges("t1", nudges);

var process = TerminalProcess.start("generic", List.of("./release.sh"), 120, 40, dir, true).nudges("t1", nudges);

var file = FileTerminalSource.create(dir.resolve("run.log")).build().nudges("t1", nudges);
  • MemoryTerminalSource.nudges publishes after every write and a final done from finish. The publisher is called outside the source's lock, and an exception from it is swallowed so a write never fails because of a nudge.
  • TerminalProcess.nudges starts a daemon thread that checks the size of the runner's log every 100 ms and publishes when it has grown. It publishes done once the job has finished, and close stops it promptly.
  • FileTerminalSource.nudges starts a daemon thread that checks the size of the log file every 100 ms and publishes when it has grown. A missing or shrinking file publishes nothing and the thread keeps going. It never publishes done, because a plain file source does not know when the producer finishes; the card's polling sees done through the terminal supplier. FileTerminalSource is AutoCloseable: close stops the thread promptly.
  • All three nudges methods can be called once, and reject an id that is not a valid terminal id.

Wiring the page​

Name the exact topic everywhere. For terminal t1 the page needs three things:

{
"contractVersion": "1",
"title": "T",
"nav": [],
"activeNav": [],
"topics": [
{"topic": "term.nudge:t1", "retain": false, "publisher": "server"}
],
"bridges": [
{"id": "nudges", "transport": "sse", "session": "/c/juneau-bus/session",
"downstream": ["term.nudge:t1"]}
],
"cards": [
{"id": "term", "type": "terminal",
"terminal": {"bytesUrl": "/t/juneau-terminal/t1/bytes"},
"subscribes": [{"topic": "term.nudge:t1", "as": "nudge"}]}
]
}

The card's subscribes entry uses the nudge role; a terminal card now accepts input-status and nudge. Nudges are downstream only, so an SSE or WebSocket bridge both work. In a FreeMarker page the pieces are the same <@topic>, <@bridge> and subscribes attribute shown under Input.

BusWiringValidator already checks partial wiring, so a missing piece is reported rather than silently ignored. How the exact and wildcard forms combine:

topics entryBridge downstreamResult
term.nudge:t1term.nudge:t1Passes and delivers. This is the supported wiring.
term.nudge:t1term.nudge:*Rejected with E-51: the exact declaration does not cover the wildcard downstream.
term.nudge:*term.nudge:t1Rejected with E-52: the exact bridge entry does not cover the wildcard declaration.
term.nudge:*term.nudge:*Passes the validator but delivers nothing.

The last row is the one silent trap. Delivery needs a session to hold the exact topic, and a session granted only the wildcard is skipped, so wildcard on both sides validates and then never nudges. Always wire the exact topic.

Nudging a directly mounted terminal​

A page that calls JuneauTerminal.mount(host, {id, bytesUrl}) itself, with no card, has no roles.nudge to route a bus message through. It delivers its own nudges instead: mount puts nudge(payload) on the handle JuneauTerminal.handle(id) returns, and on the cleanup function mount returns (for a mount without an id).

const stop = JuneauTerminal.mount(host, {id: 'lane_x', bytesUrl: '/t/juneau-terminal/lane_x/bytes'});
bus.subscribe('term.nudge:lane_x', function (payload) { JuneauTerminal.handle('lane_x').nudge(payload); });

The payload is the same {end, done?} as a card receives, with the same rules: a nudge at or behind the bytes already read is ignored, a malformed payload is ignored, and a nudge after the cleanup has run does nothing. Polling stays the safety net.

What the browser does​

  • A nudge whose end is ahead of what the card has already read triggers an immediate fetch. A nudge at or behind the current offset is ignored, as is a malformed one. A nudge carrying done fetches once if the bytes are not yet finished.
  • While nudges are arriving, the card polls at max(refreshMs, 5000) ms instead of its normal interval. The mode starts with the first nudge, not when the bridge connects, so a connected bridge that never sends a nudge does not slow the poll.
  • When the bridge disconnects, including a slow-consumer detach, the card goes back to its normal interval at once. This is idle-aware: a card that was already idle returns to 5 s, not 1 s.
  • A failing fetch is retried on the usual 1, 2, 5, 10 s schedule. A nudge never bypasses that backoff while fetches are failing, and never starts a second concurrent fetch.
  • Nudge mode does not time out. If nudges stop while the bridge stays up, the 5 s poll still runs, so output is at most 5 s late.

Security​

  • canWatch runs for every delivery. For each nudge, TerminalNudges asks TerminalAccess.canWatch about every connected session that was granted that exact topic, and publishes only to the sessions that pass. That is at most 4 deliveries per terminal per second times the number of sessions, so canWatch must be cheap. An exception from it counts as a denial.
  • Base the rule on the principal plus live state. The TerminalCaller that canWatch sees is built from the bus session, so its principal and attributes were fixed when the session opened. A long-lived session can carry stale attributes, bounded by the bus's maxSessionAge. Decide from the principal and from server state read at call time.
  • Use the same rule as the bytes endpoint. A nudge carries no bytes, but it tells a viewer that a terminal grew. The content gate is terminalSource(id, req) in TerminalMixin, a separate check. Keep the two consistent, but do not expect to copy one into the other: terminalSource sees the live RestRequest, while canWatch sees a TerminalCaller frozen at session open. Express the rule over the principal plus live server state so either can evaluate it. If they disagree, the worst case is that a user learns terminal X grew but cannot read it.
  • Revocation takes effect on the next delivery. canWatch runs before the frame is published, so a revocation that races a delivery can let that one nudge through. The next one is denied.
  • Terminal ids cannot be enumerated. Nudges are never retained, a session granted only term.nudge:* receives nothing, and whether a topic is granted at session open never depends on whether the terminal exists. A session can list up to 256 exact term.nudge:<guess> topics (the bus's topic cap), but it still receives only what canWatch allows.
  • Do not strip term.nudge topics in the authorizer. The browser refuses a whole session whose grants differ from its request, so a watch-restricted user would lose the page's bus. Deny them in canWatch instead.

Known limits​

  • Search highlights by UTF-16 index, so a line with wide (double-cell) characters can highlight the wrong span.
  • Step and checkpoint links use buffer line numbers. Once the scrollback (100,000 lines by default; see xterm options) is full and old lines drop, they go stale.

Browser smoke test​

TerminalSmoke_BrowserTest in the module drives the region in a real browser. It binds a socket, so it is opt-in: run it with -Pjs-tests -Dtest='TerminalSmoke_BrowserTest' -Djuneau.terminalSmoke=true.

See also​