mcp-for-i 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,8 @@ From an npm install or a git checkout build, these commands are the supported co
50
50
  ```bash
51
51
  mcp-for-i control
52
52
  mcp-for-i control status
53
+ mcp-for-i control stop
54
+ mcp-for-i control restart
53
55
  mcp-for-i control enable
54
56
  mcp-for-i control disable
55
57
  ```
@@ -59,11 +61,15 @@ Equivalent direct control-plane binary commands:
59
61
  ```bash
60
62
  mcp-for-i-control open
61
63
  mcp-for-i-control status
64
+ mcp-for-i-control stop
65
+ mcp-for-i-control restart
62
66
  mcp-for-i-control enable
63
67
  mcp-for-i-control disable
64
68
  mcp-for-i-control serve
65
69
  ```
66
70
 
71
+ `stop` shuts down the running control plane (it works for one started by `control`, `serve` or the background service). If the background service is enabled, the control plane starts again at the next login; `disable` turns that off. `restart` stops it and starts it again in the background.
72
+
67
73
  Behavior by environment:
68
74
  - Windows: `enable` registers a Startup entry.
69
75
  - macOS: `enable` registers a LaunchAgent.
@@ -212,6 +218,10 @@ The existing member tools remain compatible: an unchanged `ibmi.qsys.members.lis
212
218
 
213
219
  Source-date reads use an ephemeral `QTEMP` alias in the same database job. If that path is unavailable, the server falls back to a complete UTF-8 `CPYTOSTMF` read; it never reconstructs a member from search matches.
214
220
 
221
+ ## 5250 Green Screen
222
+
223
+ `ibmi.tn5250.*` gives the assistant a real 5250 session (a built-in TN5250 client, nothing to install on the IBM i). While a session is open the MCP server also serves a live view on `http://127.0.0.1:3981` (the link, with its token, is in every screen result and on the control plane's Runtime tab): you watch the screen the assistant is driving and can type and click in it yourself. Clients that support MCP Apps draw the same screen inside the chat. See [docs/tools.md](docs/tools.md#tn5250-notes).
224
+
215
225
  ## IBM i Documentation
216
226
 
217
227
  `ibmi.docs.search` and `ibmi.docs.open` provide a release-aware, cached catalog of allowlisted official IBM documentation. The same catalog and topic content are exposed as MCP resources under `ibmi-docs://`. Documentation results include their official canonical URL, content version/checksum, and observation time. When local coverage is insufficient, search results explicitly recommend a web fallback instead of presenting cached material as exhaustive.
@@ -1,7 +1,7 @@
1
1
  import { startControlPlaneServer } from "./server.js";
2
2
  import { openUrl } from "./platform.js";
3
3
  import { getControlPlaneUrl } from "./serverShared.js";
4
- import { disableBackgroundService, enableBackgroundService, ensureControlPlaneRunning, getServiceStatus } from "./serviceManager.js";
4
+ import { disableBackgroundService, enableBackgroundService, ensureControlPlaneRunning, getServiceStatus, stopControlPlane } from "./serviceManager.js";
5
5
  export async function runControlCommand(args, options) {
6
6
  const stderr = options.stderr || process.stderr;
7
7
  const command = normalizeCommand(args, options.defaultCommand || "serve");
@@ -17,6 +17,13 @@ export async function runControlCommand(args, options) {
17
17
  case "open":
18
18
  await openControlUi(stderr, scriptPath, nodePath);
19
19
  return;
20
+ case "stop":
21
+ await stop(stderr, scriptPath, nodePath);
22
+ return;
23
+ case "restart":
24
+ await stop(stderr, scriptPath, nodePath);
25
+ await openControlUi(stderr, scriptPath, nodePath, false);
26
+ return;
20
27
  case "enable":
21
28
  await enableService(stderr, scriptPath, nodePath);
22
29
  return;
@@ -53,6 +60,28 @@ async function serve(stderr) {
53
60
  process.once("SIGINT", shutdown);
54
61
  process.once("SIGTERM", shutdown);
55
62
  await server.closed;
63
+ if (await server.shutdownRequested) {
64
+ stderr.write("mcp-for-i control plane stopped\n");
65
+ // Background timers (update checks, client probes) must not keep a stopped server's process alive.
66
+ process.exit(0);
67
+ }
68
+ }
69
+ async function stop(stderr, scriptPath, nodePath) {
70
+ const url = getControlPlaneUrl();
71
+ const result = await stopControlPlane(scriptPath, nodePath);
72
+ if (!result.wasRunning) {
73
+ stderr.write(`Control plane is not running at ${url}\n`);
74
+ }
75
+ else if (result.stopped) {
76
+ stderr.write(`Stopped control plane at ${url}\n`);
77
+ }
78
+ else {
79
+ stderr.write(`Control plane at ${url} did not stop: ${result.detail || "it is still answering"}\n`);
80
+ process.exitCode = 1;
81
+ }
82
+ if (result.serviceEnabled) {
83
+ stderr.write(`The background service is still enabled (${result.managedBy}), so it starts again at the next login. Run "mcp-for-i control disable" to turn that off.\n`);
84
+ }
56
85
  }
57
86
  async function printStatus(stderr, scriptPath, nodePath) {
58
87
  const url = getControlPlaneUrl();
@@ -66,12 +95,17 @@ async function printStatus(stderr, scriptPath, nodePath) {
66
95
  if (status.detail)
67
96
  stderr.write(`Detail: ${status.detail}\n`);
68
97
  }
69
- async function openControlUi(stderr, scriptPath, nodePath) {
98
+ async function openControlUi(stderr, scriptPath, nodePath, openBrowser = true) {
70
99
  const url = getControlPlaneUrl();
71
100
  const result = await ensureControlPlaneRunning(scriptPath, nodePath);
72
101
  if (result.started) {
73
102
  stderr.write(`Started control plane at ${url}\n`);
74
103
  }
104
+ if (!openBrowser) {
105
+ if (!result.started)
106
+ stderr.write(`Control plane is running at ${url}\n`);
107
+ return;
108
+ }
75
109
  try {
76
110
  await openUrl(url);
77
111
  stderr.write(`Opening ${url}\n`);
@@ -99,6 +133,8 @@ function printHelp(stderr) {
99
133
  stderr.write(`mcp-for-i-control commands:\n`);
100
134
  stderr.write(` serve Start control plane server in the foreground\n`);
101
135
  stderr.write(` open Start control plane in the background if needed, then open UI\n`);
136
+ stderr.write(` stop Stop the running control plane\n`);
137
+ stderr.write(` restart Stop the control plane, then start it again in the background\n`);
102
138
  stderr.write(` status Show running and background-service status\n`);
103
139
  stderr.write(` enable Enable background service for this platform\n`);
104
140
  stderr.write(` disable Disable background service for this platform\n`);
@@ -27,11 +27,18 @@ export async function startControlPlaneServer(opts) {
27
27
  const runtime = new RuntimeService(rootDir);
28
28
  const clients = new ClientIntegrationService(rootDir);
29
29
  const guard = { token, port, extraHosts: extraAllowedHosts(host) };
30
+ let markShutdown = () => undefined;
31
+ const shutdownRequested = new Promise(resolve => { markShutdown = resolve; });
32
+ const requestShutdown = () => {
33
+ markShutdown(true);
34
+ server.close();
35
+ server.closeAllConnections();
36
+ };
30
37
  const server = http.createServer(async (req, res) => {
31
38
  try {
32
39
  if (!checkRequest(req, res, guard))
33
40
  return;
34
- await routeRequest(req, res, { store, connections, runtime, clients, token });
41
+ await routeRequest(req, res, { store, connections, runtime, clients, token, requestShutdown });
35
42
  }
36
43
  catch (err) {
37
44
  if (res.headersSent) {
@@ -49,12 +56,16 @@ export async function startControlPlaneServer(opts) {
49
56
  server.listen(port, host, () => resolve());
50
57
  });
51
58
  guard.port = server.address().port;
52
- const closed = new Promise(resolve => server.once("close", () => resolve()));
59
+ const closed = new Promise(resolve => server.once("close", () => {
60
+ markShutdown(false);
61
+ resolve();
62
+ }));
53
63
  return {
54
64
  host,
55
65
  port: guard.port,
56
66
  token,
57
67
  closed,
68
+ shutdownRequested,
58
69
  close: () => new Promise((resolve, reject) => {
59
70
  server.close(err => (err ? reject(err) : resolve()));
60
71
  server.closeIdleConnections();
@@ -170,10 +181,20 @@ async function routeRequest(req, res, services) {
170
181
  sendJson(res, 200, { settings });
171
182
  return;
172
183
  }
184
+ if (method === "POST" && pathname === "/api/shutdown") {
185
+ // Answer first, then stop listening and drop the remaining connections.
186
+ res.once("finish", () => setImmediate(services.requestShutdown));
187
+ sendJson(res, 200, { stopping: true });
188
+ return;
189
+ }
173
190
  if (method === "GET" && pathname === "/api/sessions") {
174
191
  sendJson(res, 200, { snapshot: await readSessionSnapshot() });
175
192
  return;
176
193
  }
194
+ if (method === "GET" && pathname === "/api/tn5250/live") {
195
+ sendJson(res, 200, { views: await readLiveViews() });
196
+ return;
197
+ }
177
198
  if (method === "GET" && pathname === "/api/runtime/status") {
178
199
  sendJson(res, 200, { jobs: services.runtime.getStatus() });
179
200
  return;
@@ -375,6 +396,41 @@ function parseNumberSetting(input, min, max, key) {
375
396
  }
376
397
  return normalized;
377
398
  }
399
+ /**
400
+ * Live 5250 views: each MCP process that serves one writes a file with its
401
+ * address and sessions. Entries whose server no longer answers are dropped.
402
+ */
403
+ async function readLiveViews() {
404
+ const dir = path.join(getConfigDir(), "tn5250-live");
405
+ let names = [];
406
+ try {
407
+ names = (await fsp.readdir(dir)).filter(name => name.endsWith(".json"));
408
+ }
409
+ catch {
410
+ return [];
411
+ }
412
+ const views = [];
413
+ for (const name of names) {
414
+ const file = path.join(dir, name);
415
+ try {
416
+ const entry = JSON.parse(await fsp.readFile(file, "utf8"));
417
+ if (!entry || typeof entry.url !== "string" || typeof entry.port !== "number")
418
+ throw new Error("bad entry");
419
+ const controller = new AbortController();
420
+ const timer = setTimeout(() => controller.abort(), 1000);
421
+ const alive = await fetch(`http://127.0.0.1:${entry.port}/api/sessions?t=${new URL(entry.url).searchParams.get("t")}`, { signal: controller.signal })
422
+ .then(r => r.ok).catch(() => false);
423
+ clearTimeout(timer);
424
+ if (!alive)
425
+ throw new Error("gone");
426
+ views.push({ pid: entry.pid, port: entry.port, url: entry.url, sessions: entry.sessions || [], updatedAt: entry.updatedAt });
427
+ }
428
+ catch {
429
+ await fsp.rm(file, { force: true }).catch(() => undefined);
430
+ }
431
+ }
432
+ return views;
433
+ }
378
434
  async function readSessionSnapshot() {
379
435
  const snapshotPath = path.join(getConfigDir(), "session-state.json");
380
436
  try {
@@ -4,7 +4,7 @@ import path from "path";
4
4
  import { spawn } from "child_process";
5
5
  import { getConfigDir } from "../config/store.js";
6
6
  import { getPlatformDescriptor } from "./platform.js";
7
- import { getControlPlanePort, isControlPlaneRunning } from "./serverShared.js";
7
+ import { getControlPlaneAuthHeaders, getControlPlanePort, getControlPlaneUrl, isControlPlaneRunning } from "./serverShared.js";
8
8
  const SERVICE_ID = "mcp-for-i-controlplane";
9
9
  const MAC_LABEL = "com.mcpfori.controlplane";
10
10
  const RC_BEGIN = "# >>> mcp-for-i control >>>";
@@ -87,6 +87,40 @@ export async function disableBackgroundService(scriptPath, nodePath = process.ex
87
87
  }
88
88
  return getServiceStatus(scriptPath, nodePath);
89
89
  }
90
+ export async function stopControlPlane(scriptPath, nodePath = process.execPath, timeoutMs = 10000) {
91
+ const status = await getServiceStatus(scriptPath, nodePath).catch(() => undefined);
92
+ const base = { serviceEnabled: Boolean(status?.enabled), managedBy: status?.managedBy };
93
+ if (!(await isControlPlaneRunning()))
94
+ return { ...base, wasRunning: false, stopped: true };
95
+ if (process.platform === "darwin" && (await pathExists(getMacLaunchAgentPath()))) {
96
+ await runQuiet("launchctl", ["unload", getMacLaunchAgentPath()]);
97
+ }
98
+ else if (process.platform === "linux" && (await canUseSystemdUser()) && (await isSystemdActive())) {
99
+ await runQuiet("systemctl", ["--user", "stop", SERVICE_ID]);
100
+ }
101
+ let detail;
102
+ if (await isControlPlaneRunning()) {
103
+ try {
104
+ const res = await fetch(`${getControlPlaneUrl()}/api/shutdown`, {
105
+ method: "POST",
106
+ headers: { ...getControlPlaneAuthHeaders(), "Content-Type": "application/json" },
107
+ body: "{}"
108
+ });
109
+ if (!res.ok)
110
+ detail = `shutdown request failed with HTTP ${res.status}`;
111
+ }
112
+ catch {
113
+ // the server may drop the connection while it stops
114
+ }
115
+ }
116
+ const deadline = Date.now() + timeoutMs;
117
+ while (Date.now() < deadline) {
118
+ if (!(await isControlPlaneRunning()))
119
+ return { ...base, wasRunning: true, stopped: true };
120
+ await wait(250);
121
+ }
122
+ return { ...base, wasRunning: true, stopped: false, detail };
123
+ }
90
124
  export async function ensureControlPlaneRunning(scriptPath, nodePath = process.execPath, timeoutMs = 15000) {
91
125
  if (await isControlPlaneRunning()) {
92
126
  return { started: false };
@@ -547,6 +547,20 @@ export function renderControlPlaneHtml(token = "") {
547
547
  </table>
548
548
  </div>
549
549
  </div>
550
+ <div class="card">
551
+ <div class="card-head">
552
+ <div>
553
+ <h2>Live 5250 screens</h2>
554
+ <div class="sub">Green screens your coding assistant is driving. Open one to watch, type and click alongside it.</div>
555
+ </div>
556
+ </div>
557
+ <div class="table-wrap">
558
+ <table>
559
+ <thead><tr><th>Connection</th><th>Host</th><th>State</th><th>Last input</th><th></th></tr></thead>
560
+ <tbody id="liveBody"></tbody>
561
+ </table>
562
+ </div>
563
+ </div>
550
564
  <div class="help" id="versionCheckedAt"></div>
551
565
  </div>
552
566
  </section>
@@ -1327,6 +1341,32 @@ export function renderControlPlaneHtml(token = "") {
1327
1341
  body.appendChild(tr);
1328
1342
  }
1329
1343
  }
1344
+ function renderLiveViews(views) {
1345
+ const body = $("liveBody");
1346
+ body.innerHTML = "";
1347
+ const rows = [];
1348
+ for (const view of Array.isArray(views) ? views : []) {
1349
+ for (const s of view.sessions || []) rows.push({ view, s });
1350
+ }
1351
+ if (rows.length === 0) {
1352
+ const tr = document.createElement("tr");
1353
+ tr.innerHTML = "<td colspan='5' style='padding:0'><div class='empty compact'><div class='empty-icon'>" + icon("pulse") + "</div><h3>No 5250 session</h3><p>A screen appears here when your coding assistant opens one with ibmi.tn5250.connect.</p></div></td>";
1354
+ body.appendChild(tr);
1355
+ return;
1356
+ }
1357
+ for (const { view, s } of rows) {
1358
+ const url = new URL(view.url);
1359
+ url.searchParams.set("s", s.connectionName);
1360
+ const tr = document.createElement("tr");
1361
+ tr.innerHTML = "<td><span class='cell-primary'>" + escapeHtml(s.connectionName) + "</span></td>" +
1362
+ "<td class='mono' style='font-size:12.5px'>" + escapeHtml(s.host || "") + "</td>" +
1363
+ "<td><span class='chip " + (s.connected ? "ok" : "danger") + "'>" + (s.connected ? (s.signedOn ? "Signed on" : "Connected") : "Closed") + "</span></td>" +
1364
+ "<td>" + escapeHtml(s.lastActor === "agent" ? "Agent" : s.lastActor === "person" ? "You" : "-") + "</td>" +
1365
+ "<td style='text-align:right'><a class='btn' href='" + escapeHtml(url.toString()) + "' target='_blank' rel='noopener'>Open</a></td>";
1366
+ body.appendChild(tr);
1367
+ }
1368
+ }
1369
+ async function loadLiveViews() { renderLiveViews((await api("/api/tn5250/live")).views || []); }
1330
1370
  const SETTINGS_FIELDS = [
1331
1371
  ["sessionIdleMinutes", 1, 1440, 30],
1332
1372
  ["sessionPingSeconds", 5, 300, 15],
@@ -1673,6 +1713,7 @@ export function renderControlPlaneHtml(token = "") {
1673
1713
  if (heartbeatId) clearInterval(heartbeatId);
1674
1714
  heartbeatId = setInterval(async () => {
1675
1715
  const ok = await pingHealth();
1716
+ if (ok && state.online && !state.busy) loadLiveViews().catch(() => {});
1676
1717
  if (ok && !state.online) {
1677
1718
  setServiceState("online", "Online", "Local control service");
1678
1719
  await refreshAll();
@@ -1689,7 +1730,7 @@ export function renderControlPlaneHtml(token = "") {
1689
1730
  return;
1690
1731
  }
1691
1732
  setServiceState("online", "Online", "Local control service");
1692
- await Promise.allSettled([loadConnections(), loadRuntimeStatus(), loadServiceStatus(), loadVersions(), loadSessions(), loadSettings(), loadClients()]);
1733
+ await Promise.allSettled([loadConnections(), loadRuntimeStatus(), loadServiceStatus(), loadVersions(), loadSessions(), loadLiveViews(), loadSettings(), loadClients()]);
1693
1734
  });
1694
1735
  }
1695
1736
 
@@ -3,6 +3,7 @@ import net from "net";
3
3
  import tls from "tls";
4
4
  import { AID, FLAG_ATN, FLAG_SRQ, OP_NO_OP, buildGdsRecord, parseGdsRecord } from "./datastream.js";
5
5
  import { Screen } from "./screen.js";
6
+ import { charsetFor, keyboardTypeFor } from "./ebcdic.js";
6
7
  import { DO, DONT, ENV_SEND, OPT_BINARY, OPT_EOR, OPT_NEW_ENVIRON, OPT_TERMINAL_TYPE, TT_SEND, TelnetParser, WILL, WONT, envIs, frameRecord, parseEnvSend, telnetCommand, terminalTypeIs } from "./telnet.js";
7
8
  const AID_ALIASES = {
8
9
  ENTER: AID.ENTER,
@@ -18,6 +19,25 @@ const AID_ALIASES = {
18
19
  CLEAR: AID.CLEAR,
19
20
  RECORDBACKSPACE: AID.RECORD_BACKSPACE
20
21
  };
22
+ /**
23
+ * Other names for the two non-AID host keys. In IBM emulators (ACS, PC5250)
24
+ * Esc is Attention and Shift+Esc is System Request.
25
+ */
26
+ const HOST_KEY_ALIASES = {
27
+ ATTN: "ATTN",
28
+ ATTENTION: "ATTN",
29
+ ESC: "ATTN",
30
+ ESCAPE: "ATTN",
31
+ SYSREQ: "SYSREQ",
32
+ SYSRQ: "SYSREQ",
33
+ SYSTEMREQUEST: "SYSREQ",
34
+ SHIFTESC: "SYSREQ",
35
+ SHIFTESCAPE: "SYSREQ"
36
+ };
37
+ /** "ATTN" or "SYSREQ" for Attention / System Request key names (including Esc and Shift+Esc). */
38
+ export function hostKeyFor(name) {
39
+ return HOST_KEY_ALIASES[name.toUpperCase().replace(/[\s_+-]/g, "")];
40
+ }
21
41
  /** AID byte for a key name, or undefined when it is not an AID key. */
22
42
  export function aidForKey(name) {
23
43
  const key = name.toUpperCase().replace(/[\s_-]/g, "");
@@ -44,6 +64,8 @@ export class Tn5250Client extends EventEmitter {
44
64
  willSent = new Set();
45
65
  doSent = new Set();
46
66
  lastUpdateAt = 0;
67
+ /** Host records received so far; lets callers tell whether the host answered. */
68
+ recordsReceived = 0;
47
69
  constructor(options) {
48
70
  super();
49
71
  this.options = options;
@@ -216,9 +238,10 @@ export class Tn5250Client extends EventEmitter {
216
238
  const values = [];
217
239
  if (this.options.deviceName)
218
240
  values.push({ kind: "uservar", name: "DEVNAME", value: ascii(this.options.deviceName) });
219
- values.push({ kind: "uservar", name: "KBDTYPE", value: ascii("USB") });
220
- values.push({ kind: "uservar", name: "CODEPAGE", value: ascii("37") });
221
- values.push({ kind: "uservar", name: "CHARSET", value: ascii("697") });
241
+ const ccsid = this.screen.ccsid;
242
+ values.push({ kind: "uservar", name: "KBDTYPE", value: ascii(keyboardTypeFor(ccsid)) });
243
+ values.push({ kind: "uservar", name: "CODEPAGE", value: ascii(String(ccsid)) });
244
+ values.push({ kind: "uservar", name: "CHARSET", value: ascii(String(charsetFor(ccsid))) });
222
245
  const signOn = this.options.autoSignOn;
223
246
  if (signOn) {
224
247
  // RFC 4777: an empty IBMRSEED means IBMSUBSPW carries the password unencrypted.
@@ -239,11 +262,12 @@ export class Tn5250Client extends EventEmitter {
239
262
  this.screen.warnings.push(err?.message || String(err));
240
263
  }
241
264
  this.lastUpdateAt = Date.now();
265
+ this.recordsReceived += 1;
242
266
  this.emit("update");
243
267
  }
244
268
  /** Send an AID key (Enter, F1-F24, PageUp, ...) or Attn / SysReq. */
245
269
  sendKey(name) {
246
- const key = name.toUpperCase().replace(/[\s_-]/g, "");
270
+ const key = hostKeyFor(name) || name.toUpperCase().replace(/[\s_-]/g, "");
247
271
  if (key === "ATTN") {
248
272
  this.write(frameRecord(buildGdsRecord(OP_NO_OP, new Uint8Array(0), FLAG_ATN)));
249
273
  this.screen.keyboardLocked = true;
@@ -1,11 +1,29 @@
1
1
  /**
2
2
  * EBCDIC conversion for the 5250 data stream.
3
3
  *
4
- * Only CCSID 37 (US/Canada) is built in. The table maps every byte from
5
- * 0x40 to 0xFE to its Unicode character; bytes below 0x40 are controls,
6
- * attributes and orders in the 5250 data stream and render as blanks.
4
+ * Single-byte (SBCS) code pages: CCSID 37 is the base table and the others
5
+ * are stored as differences from it. Each table maps every byte from 0x40 to
6
+ * 0xFE to its Unicode character; bytes below 0x40 are controls, attributes
7
+ * and orders in the 5250 data stream and render as blanks. Double-byte
8
+ * (DBCS) code pages are not supported.
7
9
  */
8
- export const SUPPORTED_CCSIDS = [37];
10
+ import { CCSID_DELTAS } from "./ebcdicTables.js";
11
+ export const SUPPORTED_CCSIDS = [37, ...Object.keys(CCSID_DELTAS).map(Number)].sort((a, b) => a - b);
12
+ /** Graphic character set (GCSGID) announced with the code page: 695 for the euro code pages, else 697. */
13
+ export function charsetFor(ccsid) {
14
+ return ccsid >= 1140 && ccsid <= 1149 ? 695 : 697;
15
+ }
16
+ /** Keyboard type (KBDTYPE) that matches the code page's country. */
17
+ export function keyboardTypeFor(ccsid) {
18
+ const base = ccsid >= 1140 && ccsid <= 1149 ? [37, 273, 277, 278, 280, 284, 285, 297, 500, 871][ccsid - 1140] : ccsid;
19
+ const map = {
20
+ 37: "USB", 273: "AGB", 277: "DMB", 278: "SWB", 280: "ITB", 284: "SPB", 285: "UKB", 297: "FAB", 500: "BLB", 871: "ICB", 1047: "USB"
21
+ };
22
+ return map[base] || "USB";
23
+ }
24
+ export function isSupportedCcsid(ccsid) {
25
+ return SUPPORTED_CCSIDS.includes(ccsid);
26
+ }
9
27
  // Unicode code points for EBCDIC 0x40..0xFE, 16 per row.
10
28
  const CCSID37_FROM_40 = [
11
29
  /* 0x40 */ 0x0020, 0x00A0, 0x00E2, 0x00E4, 0x00E0, 0x00E1, 0x00E3, 0x00E5, 0x00E7, 0x00F1, 0x00A2, 0x002E, 0x003C, 0x0028, 0x002B, 0x007C,
@@ -23,12 +41,13 @@ const CCSID37_FROM_40 = [
23
41
  ];
24
42
  const tables = new Map();
25
43
  function buildTable(ccsid) {
26
- if (ccsid !== 37)
44
+ if (!isSupportedCcsid(ccsid))
27
45
  throw new Error(`CCSID ${ccsid} is not supported for TN5250 (supported: ${SUPPORTED_CCSIDS.join(", ")})`);
46
+ const delta = CCSID_DELTAS[ccsid] || {};
28
47
  const toChar = new Array(256).fill(" ");
29
48
  const fromChar = new Map();
30
49
  for (let i = 0; i < CCSID37_FROM_40.length; i += 1) {
31
- const ch = String.fromCodePoint(CCSID37_FROM_40[i]);
50
+ const ch = String.fromCodePoint(delta[0x40 + i] ?? CCSID37_FROM_40[i]);
32
51
  toChar[0x40 + i] = ch;
33
52
  if (!fromChar.has(ch))
34
53
  fromChar.set(ch, 0x40 + i);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * EBCDIC single-byte code pages for TN5250, as differences from CCSID 37.
3
+ * Generated from the IBM code page tables shipped with the Java runtime
4
+ * (bytes 0x40-0xFE). Do not edit by hand.
5
+ */
6
+ export const CCSID_DELTAS = {
7
+ 273: { 0x43: 0x007b, 0x4a: 0x00c4, 0x4f: 0x0021, 0x59: 0x007e, 0x5a: 0x00dc, 0x5f: 0x005e, 0x63: 0x005b, 0x6a: 0x00f6, 0x7c: 0x00a7, 0xa1: 0x00df, 0xb0: 0x00a2, 0xb5: 0x0040, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e4, 0xcc: 0x00a6, 0xd0: 0x00fc, 0xdc: 0x007d, 0xe0: 0x00d6, 0xec: 0x005c, 0xfc: 0x005d },
8
+ 277: { 0x47: 0x007d, 0x4a: 0x0023, 0x4f: 0x0021, 0x5a: 0x00a4, 0x5b: 0x00c5, 0x5f: 0x005e, 0x67: 0x0024, 0x6a: 0x00f8, 0x70: 0x00a6, 0x7b: 0x00c6, 0x7c: 0x00d8, 0x80: 0x0040, 0x9c: 0x007b, 0x9e: 0x005b, 0x9f: 0x005d, 0xa1: 0x00fc, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e6, 0xd0: 0x00e5, 0xdc: 0x007e },
9
+ 278: { 0x43: 0x007b, 0x47: 0x007d, 0x4a: 0x00a7, 0x4f: 0x0021, 0x51: 0x0060, 0x5a: 0x00a4, 0x5b: 0x00c5, 0x5f: 0x005e, 0x63: 0x0023, 0x67: 0x0024, 0x6a: 0x00f6, 0x71: 0x005c, 0x79: 0x00e9, 0x7b: 0x00c4, 0x7c: 0x00d6, 0x9f: 0x005d, 0xa1: 0x00fc, 0xb0: 0x00a2, 0xb5: 0x005b, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e4, 0xcc: 0x00a6, 0xd0: 0x00e5, 0xdc: 0x007e, 0xe0: 0x00c9, 0xec: 0x0040 },
10
+ 280: { 0x44: 0x007b, 0x48: 0x005c, 0x4a: 0x00b0, 0x4f: 0x0021, 0x51: 0x005d, 0x54: 0x007d, 0x58: 0x007e, 0x5a: 0x00e9, 0x5f: 0x005e, 0x6a: 0x00f2, 0x79: 0x00f9, 0x7b: 0x00a3, 0x7c: 0x00a7, 0x90: 0x005b, 0xa1: 0x00ec, 0xb0: 0x00a2, 0xb1: 0x0023, 0xb5: 0x0040, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e0, 0xcd: 0x00a6, 0xd0: 0x00e8, 0xdd: 0x0060, 0xe0: 0x00e7 },
11
+ 284: { 0x49: 0x00a6, 0x4a: 0x005b, 0x5a: 0x005d, 0x69: 0x0023, 0x6a: 0x00f1, 0x7b: 0x00d1, 0xa1: 0x00a8, 0xb0: 0x00a2, 0xba: 0x005e, 0xbb: 0x0021, 0xbd: 0x007e },
12
+ 285: { 0x4a: 0x0024, 0x5b: 0x00a3, 0xa1: 0x00af, 0xb0: 0x00a2, 0xb1: 0x005b, 0xba: 0x005e, 0xbc: 0x007e },
13
+ 297: { 0x44: 0x0040, 0x48: 0x005c, 0x4a: 0x00b0, 0x4f: 0x0021, 0x51: 0x007b, 0x54: 0x007d, 0x5a: 0x00a7, 0x5f: 0x005e, 0x6a: 0x00f9, 0x79: 0x00b5, 0x7b: 0x00a3, 0x7c: 0x00e0, 0x90: 0x005b, 0xa0: 0x0060, 0xa1: 0x00a8, 0xb0: 0x00a2, 0xb1: 0x0023, 0xb5: 0x005d, 0xba: 0x00ac, 0xbb: 0x007c, 0xbd: 0x007e, 0xc0: 0x00e9, 0xd0: 0x00e8, 0xdd: 0x00a6, 0xe0: 0x00e7 },
14
+ 500: { 0x4a: 0x005b, 0x4f: 0x0021, 0x5a: 0x005d, 0x5f: 0x005e, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c },
15
+ 871: { 0x4a: 0x00de, 0x4f: 0x0021, 0x5a: 0x00c6, 0x5f: 0x00d6, 0x79: 0x00f0, 0x7c: 0x00d0, 0x8c: 0x0060, 0x8e: 0x007b, 0x9c: 0x007d, 0x9e: 0x005d, 0xa1: 0x00f6, 0xac: 0x0040, 0xae: 0x005b, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c, 0xbe: 0x005c, 0xc0: 0x00fe, 0xcc: 0x007e, 0xd0: 0x00e6, 0xe0: 0x00b4, 0xec: 0x005e },
16
+ 1047: { 0x5f: 0x005e, 0xad: 0x005b, 0xb0: 0x00ac, 0xba: 0x00dd, 0xbb: 0x00a8, 0xbd: 0x005d },
17
+ 1140: { 0x9f: 0x20ac },
18
+ 1141: { 0x43: 0x007b, 0x4a: 0x00c4, 0x4f: 0x0021, 0x59: 0x007e, 0x5a: 0x00dc, 0x5f: 0x005e, 0x63: 0x005b, 0x6a: 0x00f6, 0x7c: 0x00a7, 0x9f: 0x20ac, 0xa1: 0x00df, 0xb0: 0x00a2, 0xb5: 0x0040, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e4, 0xcc: 0x00a6, 0xd0: 0x00fc, 0xdc: 0x007d, 0xe0: 0x00d6, 0xec: 0x005c, 0xfc: 0x005d },
19
+ 1142: { 0x47: 0x007d, 0x4a: 0x0023, 0x4f: 0x0021, 0x5a: 0x20ac, 0x5b: 0x00c5, 0x5f: 0x005e, 0x67: 0x0024, 0x6a: 0x00f8, 0x70: 0x00a6, 0x7b: 0x00c6, 0x7c: 0x00d8, 0x80: 0x0040, 0x9c: 0x007b, 0x9e: 0x005b, 0x9f: 0x005d, 0xa1: 0x00fc, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e6, 0xd0: 0x00e5, 0xdc: 0x007e },
20
+ 1143: { 0x43: 0x007b, 0x47: 0x007d, 0x4a: 0x00a7, 0x4f: 0x0021, 0x51: 0x0060, 0x5a: 0x20ac, 0x5b: 0x00c5, 0x5f: 0x005e, 0x63: 0x0023, 0x67: 0x0024, 0x6a: 0x00f6, 0x71: 0x005c, 0x79: 0x00e9, 0x7b: 0x00c4, 0x7c: 0x00d6, 0x9f: 0x005d, 0xa1: 0x00fc, 0xb0: 0x00a2, 0xb5: 0x005b, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e4, 0xcc: 0x00a6, 0xd0: 0x00e5, 0xdc: 0x007e, 0xe0: 0x00c9, 0xec: 0x0040 },
21
+ 1144: { 0x44: 0x007b, 0x48: 0x005c, 0x4a: 0x00b0, 0x4f: 0x0021, 0x51: 0x005d, 0x54: 0x007d, 0x58: 0x007e, 0x5a: 0x00e9, 0x5f: 0x005e, 0x6a: 0x00f2, 0x79: 0x00f9, 0x7b: 0x00a3, 0x7c: 0x00a7, 0x90: 0x005b, 0x9f: 0x20ac, 0xa1: 0x00ec, 0xb0: 0x00a2, 0xb1: 0x0023, 0xb5: 0x0040, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e0, 0xcd: 0x00a6, 0xd0: 0x00e8, 0xdd: 0x0060, 0xe0: 0x00e7 },
22
+ 1145: { 0x49: 0x00a6, 0x4a: 0x005b, 0x5a: 0x005d, 0x69: 0x0023, 0x6a: 0x00f1, 0x7b: 0x00d1, 0x9f: 0x20ac, 0xa1: 0x00a8, 0xb0: 0x00a2, 0xba: 0x005e, 0xbb: 0x0021, 0xbd: 0x007e },
23
+ 1146: { 0x4a: 0x0024, 0x5b: 0x00a3, 0x9f: 0x20ac, 0xa1: 0x00af, 0xb0: 0x00a2, 0xb1: 0x005b, 0xba: 0x005e, 0xbc: 0x007e },
24
+ 1147: { 0x44: 0x0040, 0x48: 0x005c, 0x4a: 0x00b0, 0x4f: 0x0021, 0x51: 0x007b, 0x54: 0x007d, 0x5a: 0x00a7, 0x5f: 0x005e, 0x6a: 0x00f9, 0x79: 0x00b5, 0x7b: 0x00a3, 0x7c: 0x00e0, 0x90: 0x005b, 0x9f: 0x20ac, 0xa0: 0x0060, 0xa1: 0x00a8, 0xb0: 0x00a2, 0xb1: 0x0023, 0xb5: 0x005d, 0xba: 0x00ac, 0xbb: 0x007c, 0xbd: 0x007e, 0xc0: 0x00e9, 0xd0: 0x00e8, 0xdd: 0x00a6, 0xe0: 0x00e7 },
25
+ 1148: { 0x4a: 0x005b, 0x4f: 0x0021, 0x5a: 0x005d, 0x5f: 0x005e, 0x9f: 0x20ac, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c },
26
+ 1149: { 0x4a: 0x00de, 0x4f: 0x0021, 0x5a: 0x00c6, 0x5f: 0x00d6, 0x79: 0x00f0, 0x7c: 0x00d0, 0x8c: 0x0060, 0x8e: 0x007b, 0x9c: 0x007d, 0x9e: 0x005d, 0x9f: 0x20ac, 0xa1: 0x00f6, 0xac: 0x0040, 0xae: 0x005b, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c, 0xbe: 0x005c, 0xc0: 0x00fe, 0xcc: 0x007e, 0xd0: 0x00e6, 0xe0: 0x00b4, 0xec: 0x005e }
27
+ };