Embedding 3270Web¶
3270Web can be put inside another application: an iframe in a portal page, or the screen drawn by a single-page app that talks to the API and never shows the terminal at all. Both are off by default and both are turned on by the same setting.
This page is the whole of what an integrator needs. If a frame stays blank or a call is refused, the Troubleshooting section names the symptom rather than the header.
Why it is off by default¶
A browser stops one origin's page from framing, reading or posting to another on purpose, and 3270Web takes all three of those defences. Embedding means relaxing them for named origins:
| What blocks the frame | What embedding changes |
|---|---|
X-Frame-Options: SAMEORIGIN and frame-ancestors 'self' |
frame-ancestors names the allowed origins instead |
The session cookie is SameSite=Lax, so it is not sent inside a cross-site frame |
It becomes SameSite=None; Secure |
A cross-origin call to /api/v1 has no CORS permission |
The named origins get one |
Relaxing them for everybody would be a downgrade for every deployment that never embeds anything. So the origins are named, one by one, and nothing is inferred from the request.
Turning it on¶
Set EMBED_ORIGINS to the origins that may embed the terminal. Comma or
whitespace separated; each entry is a scheme and a host, with a port if it is
not the default:
EMBED_ORIGINS="https://portal.example.com,https://intranet.example.com:8443"
There is no wildcard. *, https://*.example.com and a bare
portal.example.com are all refused, and a refused entry is dropped rather
than treated as permission for anything — a typo cannot widen the list.
Read back what the server made of it:
curl https://3270web.example.com/embed/config
{
"enabled": true,
"origins": ["https://portal.example.com"],
"secure_request": true,
"frame_ancestors": "frame-ancestors 'self' https://portal.example.com;"
}
An entry the server could not use is reported under ignored, with the
reason.
HTTPS is required¶
A framed terminal is a cross-site context, so the session cookie has to be
SameSite=None, and browsers only accept that on a Secure cookie. Over
plain HTTP there is no combination that works: the cookie is either not sent
or discarded, and the frame shows the connect page forever.
Serve 3270Web over TLS, or behind a proxy that terminates TLS. Behind a proxy
there is a second setting: the hop into this process is plain HTTP, so the
server cannot see the TLS by itself and reads X-Forwarded-Proto instead —
but only when TRUST_PROXY_HEADERS=true says a proxy is really in front of
it. That gate is not optional paranoia: the header is set by whoever sends the
request, so believing it unconditionally would let any client assert that its
own connection was secure.
EMBED_ORIGINS="https://portal.example.com"
TRUST_PROXY_HEADERS=true # only if a proxy terminates TLS for you
/embed/config reports secure_request: false and a warning when this is
the thing that is missing.
In an iframe¶
<iframe
src="https://3270web.example.com/screen?embed=1"
title="Mainframe terminal"
style="width: 100%; height: 640px; border: 0"
></iframe>
?embed=1 renders the terminal without the menu bar, the session tab bar or
the animated background — the page around the frame has its own.
The screen, its operator information area and all the keyboard handling are
untouched: a terminal that cannot be typed into is not a smaller terminal.
The choice is remembered in a cookie for the rest of the session, because
/screen is reached by a redirect from the connect form and a query parameter
does not survive a redirect. ?embed=0 clears it.
?embed=1 is presentation only. It permits nothing — what may frame the
server is decided by EMBED_ORIGINS and enforced by the browser — so it does
not matter that anyone can add it to a URL.
Showing the menu bar¶
Add the embedded-toolbar class to the frame's <body> to keep the menu bar.
The simplest way is a second query parameter your page appends and a small
stylesheet of your own; the default is the screen and nothing else, because
the common case is a portal that has its own controls.
Driving the frame from the page around it¶
The frame and the page are different origins, so the page cannot reach into
the document. postMessage is the channel, and 3270Web answers a small set of
named commands on it.
Every inbound message must come from an origin named in EMBED_ORIGINS. The
same list decides who may frame the terminal and who may drive it.
const frame = document.querySelector("iframe");
const TERMINAL = "https://3270web.example.com";
let nextId = 1;
const pending = new Map();
window.addEventListener("message", (event) => {
if (event.origin !== TERMINAL) return;
const msg = event.data;
if (!msg || msg.source !== "3270web") return;
if (msg.type === "ready") {
console.log("terminal ready, commands:", msg.commands);
}
if (msg.type === "screen") {
console.log("the host repainted the screen");
}
if (msg.type === "result") {
const settle = pending.get(msg.id);
if (settle) {
pending.delete(msg.id);
msg.ok ? settle.resolve(msg.data) : settle.reject(new Error(msg.error));
}
}
});
function send(type, extra = {}) {
const id = nextId++;
frame.contentWindow.postMessage({ type, id, ...extra }, TERMINAL);
return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
}
// Read the screen, type into a field, and press Enter.
const screen = await send("getScreen");
await send("writeField", { row: 5, col: 20, text: "SMITH" });
await send("submit", { aid: "Enter" });
Commands¶
| Command | Arguments | What it does |
|---|---|---|
getScreen |
— | The screen as JSON: text, fields, cursor, keyboard state |
sendKey |
key |
One AID or navigation key: Enter, PF3, Tab, Clear |
writeField |
row, col, text |
Write into the field containing that cell |
submit |
aid |
Submit what was written, with Enter by default |
moveCursor |
row, col |
Move the cursor without sending anything to the host |
waitForUnlock |
— | Wait for the host to finish and the keyboard to unlock |
ping |
— | A liveness check that touches no host state |
Rows and columns are 0-indexed, matching what getScreen reports.
Events¶
| Event | When |
|---|---|
ready |
The frame has loaded and is listening. Carries the session id and the command list. |
screen |
The host repainted the screen without being asked — the screen a caller polling after its own commands would never see. |
result |
The answer to one command, carrying the id that command was sent with. |
Nothing here is a new capability: each command calls the same endpoint the menus call, so a page around the frame can do what the person looking at the frame could already do, and nothing beyond it.
Without an iframe: calling the API directly¶
A single-page app that draws the screen itself does not need a frame. It needs
the REST API, which the same EMBED_ORIGINS list opens to
cross-origin callers.
const res = await fetch("https://3270web.example.com/api/v1/sessions", {
headers: { Authorization: `Bearer ${token}` },
});
Two things to know about this path:
- It is bearer-authenticated, and never uses the session cookie. Credentials
are not allowed on these cross-origin requests, so a browser with a 3270Web
session already open cannot have it borrowed by a page that merely happens to
be on the allowlist. Every call carries
Authorization: Bearer <API_TOKEN>of its own. API_TOKENmust be set, or the entire/api/v1surface answers 503. See REST API.
Putting a token that drives a mainframe into a browser is a decision, not a
detail. The usual answer is that the SPA's own backend holds the token and
proxies, in which case CORS is not involved at all and EMBED_ORIGINS is only
needed for the iframe.
Troubleshooting¶
The frame is blank and the console says the page refused to connect.
The framing origin is not in EMBED_ORIGINS. Check /embed/config — it lists
the origins the server accepted and, under ignored, the entries it could not
use and why. An origin has to match on scheme, host and port.
The frame shows the connect page and never keeps a session.
The session cookie is not reaching the frame — a cross-site cookie needs
SameSite=None, which browsers only accept on a Secure cookie. Check
secure_request in /embed/config. If it is false while you are serving
over HTTPS, the missing piece is TRUST_PROXY_HEADERS=true: the server cannot
see TLS that a proxy terminated. If you are genuinely on plain HTTP, no
configuration makes this work.
postMessage commands are ignored.
The frame only listens to origins in EMBED_ORIGINS. Post to the terminal's
exact origin — not * — and check that the page's own origin is on the list.
A cross-origin API call fails at the preflight.
Preflights are answered for allowlisted origins only, and are refused with 403
for everything else. The Origin the browser sends must match a list entry
exactly.
A cross-origin API call returns 401 after a successful preflight.
That is the intended split: CORS decides whether the browser may make the call
and the token decides whether the server will answer it. Send
Authorization: Bearer <API_TOKEN>.
Related¶
- REST API — the full endpoint reference
- Guided Business Tasks — running a whole flow from a form rather than driving the screen keystroke by keystroke, which is often what an embedding page actually wants
- Configuration — the other environment variables