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>
| Key | Rules |
|---|---|
bytesUrl | Required. A same-origin path, with no scheme, no leading //, no .. segment and no {. |
eventsUrl | Optional. A terminal-view events endpoint over the same run. It gives the steps their offsets and the badge its exit code. |
refreshMs | Optional. The poll interval while the command runs. Values below 1000 are raised to 1000; a value of 0 or less is an error. |
title | Optional. Shown in the toolbar. |
tailBytes | Optional, 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.
| Path | Answers |
|---|---|
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}/raw | Every byte still held, as an attachment named <id>.log. |
GET …/juneau-terminal.js, …/juneau-terminal.css | The runtime and its stylesheet. |
The bytes endpoint sets these headers:
| Header | Meaning |
|---|---|
Term-Next | The offset to ask for next. |
Term-End | The current end of the log. |
Term-Cols, Term-Rows | The terminal size the command ran at. |
Term-Done | true once the command has finished and every byte has been read. |
Term-Truncated | true when the bytes before from are no longer held. |
Term-Error | gone 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
| Source | Use |
|---|---|
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
bytesUrlends in/bytesand resolves to anhttp:orhttps: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:andhttps:. 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.
| Key | Type | Meaning |
|---|---|---|
convertEol | boolean | Treat \n as \r\n. |
scrollback | integer, 0 to 1,000,000 | Lines of scrollback (default 100,000). |
fontFamily | non-empty string | The 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);
TerminalProcessalways implementsTerminalSink. Started withinputtrue it takes bytes throughwrite; withinputfalse (the default of the shorterstartoverloads)acceptsInput()is always false.TerminalAccessdecides, per caller and terminal id, who may watch and who may type.inputSinkreturns empty by default, so aTerminalAccessthat only implementscanWatchallows 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.inputas an upstream topic handled byTerminalInput, andterm.input-statusas 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:
| State | Meaning |
|---|---|
live | A probe succeeded: this caller may type and the job is taking input. |
denied | The caller may not watch the terminal or has no input sink for it. |
closed | The sink no longer accepts input (the job ended, or a write failed). |
busy | The sink's pending-input buffer was full and the bytes were dropped. |
bad | The 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:

With input enabled:
- A toggle in the toolbar, Enable input. It appears only with
input: true, aninputIdand a WebSocket bridge carryingterm.inputupstream andterm.input-statusdownstream; 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 aliveanswer. - 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 afterclosed, Input throttled: some keys not sent afterbusy, and Disconnected: input not sent while the bridge is down. - For
deniedandclosedthe toggle is disabled and relabelled Input not permitted or Job ended. Adeniedorclosedthat 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
deniedorclosedanswer 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
inputon the process, acanWatchthat returns true, a presentinputSink, and a sink that still accepts input. Each failure is answered asdeniedorclosed; the answer carries no reason beyond that state, and never says why access was refused. -
Rate limit. The bus's
maxUpstreamPerSecondpolicy (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 thatwritereturns false and the caller getsbusy. 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.TerminalAccessruns 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
TerminalAccessinstead. -
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.inputlogger:principal=alice terminal=t1 bytes=10 keys=^C,ESC,CR,TAB,BS,^D,^Zkeysnames the control bytes present, comma-separated, in order of first appearance, at most 8.^C,^D,^Z,ESC,CR,TABandBSare named (both 0x08 and DEL, 0x7F, areBS); any other C0 byte uses caret notation (^A,^Jfor LF,^@, and so on). The typed text is never logged. An anonymous caller isprincipal=-. Any other principal has every character at or below 0x20, every ISO control, U+2028, U+2029,=,\and%written as%XXfor 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:
^Cinterrupts the foreground process (an unhandled interrupt ends the job with exit 130), and^Dends a read, or ends input at the start of a line.^Zdoes not suspend a plain job, because nothing could resume it; a shell with job control of its own, such asbash -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>. Thedownstreamline must be present and unretained;bindthrowsIllegalStateExceptionotherwise. A retained nudge would replay a stale offset to every new session. bindcan be called once, andclosestops the scheduler. A nudge beforebindor aftercloseis dropped.- Nudges are coalesced: at most one per terminal per 250 ms, always carrying the latest
end. Adonewhose delivery fails is picked up by polling. - The publisher must not block or throw.
TerminalNudges.nudgeonly 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.nudgespublishes after everywriteand a finaldonefromfinish. 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.nudgesstarts a daemon thread that checks the size of the runner's log every 100 ms and publishes when it has grown. It publishesdoneonce the job has finished, andclosestops it promptly.FileTerminalSource.nudgesstarts 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 publishesdone, because a plain file source does not know when the producer finishes; the card's polling seesdonethrough theterminalsupplier.FileTerminalSourceisAutoCloseable:closestops the thread promptly.- All three
nudgesmethods 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 entry | Bridge downstream | Result |
|---|---|---|
term.nudge:t1 | term.nudge:t1 | Passes and delivers. This is the supported wiring. |
term.nudge:t1 | term.nudge:* | Rejected with E-51: the exact declaration does not cover the wildcard downstream. |
term.nudge:* | term.nudge:t1 | Rejected 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
endis 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 carryingdonefetches 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
canWatchruns for every delivery. For each nudge,TerminalNudgesasksTerminalAccess.canWatchabout 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, socanWatchmust be cheap. An exception from it counts as a denial.- Base the rule on the principal plus live state. The
TerminalCallerthatcanWatchsees 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'smaxSessionAge. 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)inTerminalMixin, a separate check. Keep the two consistent, but do not expect to copy one into the other:terminalSourcesees the liveRestRequest, whilecanWatchsees aTerminalCallerfrozen 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.
canWatchruns 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 exactterm.nudge:<guess>topics (the bus's topic cap), but it still receives only whatcanWatchallows. - Do not strip
term.nudgetopics 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 incanWatchinstead.
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
- Terminal View Region
- juneau-terminal: the PTY runner behind
TerminalProcess - Admin-Console Chrome
- Input: the
juneau-terminal-busmodule andTerminalInput - Bus nudges:
TerminalNudgesandTerminalNudgePublisher