#!/usr/bin/env node // dial.mjs: the kittensnrainbows terminal program. // // node dial.mjs calls bbs.prot3.com // node dial.mjs --host=H --port=P [--tls] calls somewhere else // // One file, no dependencies, Node 18 or newer. It reads from a keyboard or // from a pipe: each line answers the next thing it asks. Type ? once you // are in, and bye to hang up. If HTTPS_PROXY is set, it calls through that // proxy, the one socket tunnelled with CONNECT. // // Written out by src/make-dial.ts from src/dial.ts and src/testing/ws-client.ts. // Not edited by hand. The gaps are where the TypeScript types were. // ---- the modem: a WebSocket client written from RFC 6455 ---------------------- /** * A WebSocket client written independently of the server's framing. * * This file deliberately does NOT import src/wire/websocket.ts. The old * harness computed the expected Sec-WebSocket-Accept with the server's own * acceptKey() and then asserted the server agreed with itself — changing the * RFC GUID in the server broke nothing. It also decoded server frames with the * server's own decoder, so a matched encode/decode bug would have been * invisible. Everything here is rebuilt from RFC 6455 so the two halves can * actually disagree. * * The server never imports this. The terminal caller (src/dial.ts) does, * because it is a client, and a client is exactly what this is. */ import net from "node:net"; import tls from "node:tls"; import crypto from "node:crypto"; /** RFC 6455 §1.3. Written out here rather than imported, on purpose. */ const RFC6455_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; export function expectedAccept(clientKey ) { return crypto.createHash("sha1").update(clientKey + RFC6455_GUID).digest("base64"); }     /** * The tunnel through an outside line, ready for the call to run in. It fails * with an error named "OutsideLine" that says what the proxy said, so the * caller is told it was the proxy and not the board that said no. */ export function throughProxy(proxy , host , port ) { return new Promise((resolve, reject) => { const refuse = (words ) => { const e = new Error(words); e.name = "OutsideLine"; reject(e); }; if (proxy.protocol !== "http:" && proxy.protocol !== "https:") { refuse(`the outside line is ${proxy.protocol}//, and this program only speaks to an http:// proxy`); return; } const at = proxy.hostname.replace(/^\[|\]$/g, ""); const atPort = Number(proxy.port || (proxy.protocol === "https:" ? 443 : 80)); const raw = proxy.protocol === "https:" ? tls.connect({ host: at, port: atPort, servername: net.isIP(at) === 0 ? at : undefined }) : net.createConnection(atPort, at); const target = `${host.includes(":") ? `[${host}]` : host}:${port}`; const who = proxy.username === "" ? "" : `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}\r\n`; raw.once(proxy.protocol === "https:" ? "secureConnect" : "connect", () => { raw.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n${who}\r\n`); }); let heard = Buffer.alloc(0); const done = () => { raw.off("data", onData); raw.off("error", onError); raw.off("close", onClose); }; const onData = (chunk ) => { heard = Buffer.concat([heard, chunk]); const end = heard.indexOf("\r\n\r\n"); if (end === -1) { if (heard.length > 16_384) { done(); raw.destroy(); refuse("the outside line said more than a proxy says"); } return; } done(); const status = heard.subarray(0, heard.indexOf("\r\n")).toString("latin1"); if (!/^HTTP\/1\.[01] 2\d\d\b/.test(status)) { raw.destroy(); refuse(`the outside line said "${status.replace(/^HTTP\/1\.[01] /, "")}"`); return; } const early = heard.subarray(end + 4); if (early.length > 0) raw.unshift(early); resolve(raw); }; const onError = (e ) => { done(); raw.destroy(); refuse(`the outside line did not answer (${e.message})`); }; const onClose = () => { done(); refuse("the outside line hung up without a word"); }; raw.on("data", onData); raw.on("error", onError); raw.on("close", onClose); }); } export function connect( host , port , path = "/", options = {}, ) { return new Promise((resolve, reject) => { const key = crypto.randomBytes(16).toString("base64"); const open = () => { socket .write( [ `GET ${path} HTTP/1.1`, `Host: ${host}:${port}`, "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Key: ${key}`, "Sec-WebSocket-Version: 13", "", "", ].join("\r\n"), ); }; // A server that accepts the connection and then says nothing — a TLS door // handed a plaintext request, say — leaves this promise pending forever. // dial.ts grew a timeout for exactly this; the harness did not, and a proof // written with it ran until it was killed. const deadline = setTimeout( () => { socket?.destroy(); reject(new Error("no answer")); }, options.handshakeTimeoutMs ?? 10_000, ); if (typeof deadline.unref === "function") deadline.unref(); // Set once there is a line: at once, direct, or once the outside line // has put the call through. let socket ; const received = []; let headerLines = []; let upgraded = false; let buffer = Buffer.alloc(0); let closed = false; let binaryTotal = 0; const waiters = []; const closeWaiters = []; const listeners = []; const settle = () => { for (let i = waiters.length - 1; i >= 0; i--) { if (waiters[i] .predicate(received)) { waiters[i] .resolve(); waiters.splice(i, 1); } } }; /** Client frames MUST be masked (RFC 6455 §5.1). */ const frame = (opcode , payload ) => { const mask = crypto.randomBytes(4); const masked = Buffer.from(payload); for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4] ; let header ; if (payload.length < 126) { header = Buffer.alloc(2); header[1] = 0x80 | payload.length; } else if (payload.length < 65_536) { header = Buffer.alloc(4); header[1] = 0x80 | 126; header.writeUInt16BE(payload.length, 2); } else { header = Buffer.alloc(10); header[1] = 0x80 | 127; header.writeBigUInt64BE(BigInt(payload.length), 2); } header[0] = 0x80 | opcode; return Buffer.concat([header, mask, masked]); }; const client = { messages: () => received, texts: () => received.filter((r) => r.kind === "text").map((r) => r.text), binaryBytes: () => binaryTotal, handshakeStatus: () => headerLines[0] ?? "", handshakeAccept: () => (headerLines.find((l) => /^sec-websocket-accept:/i.test(l)) ?? "").split(": ")[1] ?? "", expectedAccept: () => expectedAccept(key), sendText: (text) => socket .write(frame(0x1, Buffer.from(text, "utf8"))), sendPing: (payload) => socket .write(frame(0x9, payload)), sendRaw: (bytes) => socket .write(bytes), until: (predicate, timeoutMs = 20_000) => new Promise ((res, rej) => { if (predicate(received)) return res(); const timer = setTimeout(() => rej(new Error("timed out waiting for condition")), timeoutMs); if (typeof timer.unref === "function") timer.unref(); waiters.push({ predicate, resolve: () => { clearTimeout(timer); res(); } }); }), whenClosed: (timeoutMs = 20_000) => new Promise ((res, rej) => { if (closed) return res(); const timer = setTimeout(() => rej(new Error("timed out waiting for close")), timeoutMs); if (typeof timer.unref === "function") timer.unref(); closeWaiters.push(() => { clearTimeout(timer); res(); }); }), hangUp: () => { socket .end(); socket .destroy(); }, putDown: () => new Promise ((res) => { const s = socket ; if (s.destroyed || closed) { res(); return; } const timer = setTimeout(() => { s.destroy(); res(); }, 1_000); s.once("close", () => { clearTimeout(timer); res(); }); const status = Buffer.alloc(2); status.writeUInt16BE(1000, 0); s.write(frame(0x8, status)); s.end(); }), subscribe: (listener) => { listeners.push(listener); }, }; const onData = (chunk ) => { buffer = Buffer.concat([buffer, chunk]); if (!upgraded) { const split = buffer.indexOf("\r\n\r\n"); if (split === -1) return; clearTimeout(deadline); headerLines = buffer.subarray(0, split).toString("latin1").split("\r\n"); buffer = Buffer.from(buffer.subarray(split + 4)); upgraded = true; resolve(client); } // Independent decode. Server frames are never masked. let offset = 0; for (;;) { if (offset + 2 > buffer.length) break; const opcode = buffer[offset] & 0x0f; const masked = (buffer[offset + 1] & 0x80) !== 0; let length = buffer[offset + 1] & 0x7f; let cursor = offset + 2; if (length === 126) { if (cursor + 2 > buffer.length) break; length = buffer.readUInt16BE(cursor); cursor += 2; } else if (length === 127) { if (cursor + 8 > buffer.length) break; length = Number(buffer.readBigUInt64BE(cursor)); cursor += 8; } if (masked) cursor += 4; if (cursor + length > buffer.length) break; const payload = Buffer.from(buffer.subarray(cursor, cursor + length)); offset = cursor + length; const kind = opcode === 0x1 ? "text" : opcode === 0x2 ? "binary" : opcode === 0x8 ? "close" : opcode === 0xa ? "pong" : "other"; if (kind === "binary") binaryTotal += payload.length; const item = { kind, text: payload.toString("utf8"), bytes: payload, atMs: performance.now() }; received.push(item); for (const l of listeners) l(item); if (kind === "close") { closed = true; for (const r of closeWaiters) r(); } } buffer = Buffer.from(buffer.subarray(offset)); settle(); }; const onClose = () => { closed = true; for (const r of closeWaiters) r(); settle(); }; // Before the upgrade completes, a socket error is a call that never // connected, and the caller is still holding the receiver. Swallowing it // left `await connect(...)` pending forever: a terminal caller dialling a // dead port sat on "ATDT5551995" with no carrier and no error, which is // the one thing a modem never did. const onError = (e ) => { clearTimeout(deadline); if (!upgraded) reject(e); }; const begin = (line ) => { socket = line; line.setNoDelay(true); line.on("data", onData); line.on("close", onClose); line.on("error", onError); }; const trust = { rejectUnauthorized: options.insecure !== true }; if (options.proxy === undefined) { begin(options.secure === true // Name the server out loud, as the proxy path below does. Some networks // hand back the wrong certificate when the name is left unsaid, and the // caller hears NO CARRIER for a line that was there all along. ? tls.connect({ host, port, servername: net.isIP(host) === 0 ? host : undefined, ...trust }, open) : net.createConnection(port, host, open)); } else { throughProxy(options.proxy, host, port).then((tunnel) => { if (options.secure === true) { begin(tls.connect({ socket: tunnel, host, servername: net.isIP(host) === 0 ? host : undefined, ...trust }, open)); } else { begin(tunnel); open(); } }, (e ) => { clearTimeout(deadline); reject(e); }); } }); } // ---- the terminal ---------------------------------------------------------------- /** * A terminal caller. * * npm run dial -- --port=7777 * * The board already had a raw-socket door, but it was the VT100 tier: no * login, no shelf, no chat. This is the other thing — a terminal program of * the kind you dialled a board *with*, speaking the same protocol the browser * speaks, through the same throttle, with the same wait. It exists so that * anyone with a shell can sit at the table: a person at a keyboard, or a * model in one, and the board does not know or ask which. * * It renders what it understands and prints the rest as it came. Pictures * paint a row at a time in colour, because that is how they arrive. Files * land in ./downloads at line speed, with a bar driven by bytes received. */ import fs from "node:fs"; import path from "node:path"; const MIN_PASSWORD = 4; const MAX_ANIMALS = 600; const MAX_ONELINER = 96; const MAX_BODY = 600; /** * The number this program calls when you do not name one. Here, a board on * this machine. In the copy the board hands out as dial.mjs, this line is * stamped with the board's own public address (see src/make-dial.ts), so * `node dial.mjs` on its own calls the board you downloaded it from. */ const BOARD = { host: "bbs.prot3.com", port: 443, tls: true }; /** * Everything this program takes on its command line, and nothing else. * * It used to take anything. A word it did not know was dropped without a * sound, `--help` included, and then it did the one thing it does: it called * the board. Asking a program what it does is not asking it to do it, and * the copy strangers are handed calls a live board with people on it. So a * command line it cannot read in full is answered in words, here, and nothing * is dialled. */ const FLAGS = { host: { value: "H", says: "call the board at H instead" }, port: { value: "P", says: `on port P (${BOARD.port} if you name none)` }, tls: { value: null, says: "dial wss://. name another host and you say this yourself" }, secure: { value: null, says: "the same as --tls" }, insecure: { value: null, says: "accept a certificate nobody signed, for a board using its own" }, from: { value: "STREET", says: "the house you are calling from, if the board listens for it" }, name: { value: "NAME", says: "come in under NAME for this call. no password is asked, and nothing is kept" }, "dial-timeout": { value: "MS", says: "how long to let it ring before giving up (20000)" }, "no-pictures": { value: null, says: "do not print the pictures as they land. they still come down the line; icons shows them" }, help: { value: null, says: "this. nothing is dialled. -h is the same" }, }; function usage() { const me = path.basename(process.argv[1] ?? "dial.mjs"); const home = `${BOARD.host}, port ${BOARD.port}${BOARD.tls ? ", over tls" : ""}`; return [ `usage: node ${me} [--host=H] [--port=P] [--tls] [other options]`, "", ` with nothing named it calls ${home}.`, ` to call another board: node ${me} --host=H --port=P (and --tls if it speaks wss://)`, "", ...Object.entries(FLAGS).map(([name, f]) => ` ${`--${name}${f.value === null ? "" : `=${f.value}`}`.padEnd(19)} ${f.says}`), "", " once you are in, ? lists what you can do, and bye hangs up.", " from a pipe, each line answers the next thing asked; when the input ends, it hangs up.", " if HTTPS_PROXY is set, the call goes out through that proxy: 9 for an outside line.", "", ].join("\n"); } /** What is wrong with one word of the command line, or null if nothing is. */ function unreadable(arg ) { if (!arg.startsWith("-")) return `${arg} is not something this program takes. a board to call goes as --host=${arg}.`; const m = /^--([a-z-]+)(?:=(.*))?$/s.exec(arg); if (m === null || m[1] === "help" || !Object.hasOwn(FLAGS, m[1] )) return `${arg} is not something this program takes.`; const [, name, value] = m; const flag = FLAGS[name ] ; if (flag.value === null) return value === undefined ? null : `--${name} takes nothing after it.`; if (value === undefined || value === "") return `--${name} needs a value: --${name}=${flag.value}.`; if (name === "port" && !(/^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 65535)) { return `--port is a number from 1 to 65535, not ${value}.`; } if (name === "dial-timeout" && !/^\d+$/.test(value)) return `--dial-timeout is a number of milliseconds, not ${value}.`; return null; } const args = new Map (); const unread = []; let wantsHelp = false; for (const arg of process.argv.slice(2)) { if (arg === "-h" || arg === "--help") { wantsHelp = true; continue; } const wrong = unreadable(arg); if (wrong !== null) { unread.push(wrong); continue; } const [name, ...value] = arg.slice(2).split("="); args.set(name , value.join("=")); } if (wantsHelp || unread.length > 0) { // Said and flushed before leaving, and nothing after this line runs: the // modem is further down, and it is never reached from here. const said = unread.length > 0 ? `${unread.map((u) => ` ${u}`).join("\n")}\n\n${usage()}` : usage(); const to = unread.length > 0 ? process.stderr : process.stdout; await new Promise ((resolve) => { to.write(said, () => resolve()); }); process.exit(unread.length > 0 ? 2 : 0); } const HOST = args.get("host") ?? BOARD.host; const PORT = Number(args.get("port") ?? BOARD.port); const NUMBER = "5551995"; /** Which house you are calling from. The board only listens if the operator said to. */ const FROM = args.get("from"); /** * The name to come in under, said on the command line rather than down the * pipe (src/name-door.ts). With it, no line of the input is spent on the * name, no password is ever asked, and nothing is offered to keep: a name * that is somebody's, or that the board will not take, is said, and the * phone goes down. A copy of this program from before the name door refuses * the flag and dials nothing, so an old copy cannot be walked into asking * for a password by a caller who reads that it won't. */ const NAME = args.get("name"); /** * Whether the pictures are printed as they land. Every call, the board sends * its pictures, one row per frame, and this program painted each of them: a * few hundred lines before the lobby, every time. A caller, a Claude in a * chat, asked for a way to skip them on return visits, for agents keeping an * eye on their context. The board's own rule for the page is that after your * first call you may turn the speaker off, and the clock stays the board's. * Here every call on a name is a first call, so the choice is the caller's * program's, on its command line, on any call. The pictures still come down * the line at the same pace, for everyone: only the printing is left out, and * `icons` shows them. */ const PICTURES = !args.has("no-pictures"); /** The input is a pipe, not somebody at a keyboard: nobody is there to be asked again. */ const PIPED = !process.stdin.isTTY; /** * Nobody read the question this answers: the input is a pipe, or whole lines * were typed before it was asked (a program writing into a terminal all at * once, which is a keyboard to this end and a pipe in all but name). What * comes next was written for something else, so the board's no ends the call * rather than ask again. Asked when the board's answer arrives. */ function unattended() { return PIPED || typedAhead.includes("\n"); } /** Dial wss://. Needed for any board that is not on this machine. Name another host and you say it yourself. */ const SECURE = args.has("tls") || args.has("secure") || (BOARD.tls && !args.has("host")); /** Accept a certificate nobody signed. For a board using its own. */ const INSECURE = args.has("insecure"); /** How long the modem holds the line before giving up. */ const DIAL_TIMEOUT_MS = Number(args.get("dial-timeout") ?? 20_000); /** * The longest line `put` can send. Each line is one frame, and the board * takes 8 KB in a frame at most (MAX_FRAME_BYTES, src/wire/websocket.ts), * "PUTLINE" and its tab included. Written out rather than imported: this * file is handed to strangers as one file. */ const MAX_LINE_BYTES = 8 * 1024 - "PUTLINE\t".length; const out = process.stdout; /** * Plain text: no colour and no cursor codes. Written to a pipe or a file, a * transcript used to be full of escapes, and a line from the board redrew * the prompt with a carriage return and an erase that a transcript keeps as * noise. So when this is not a terminal, or NO_COLOR is set (no-color.org), * it writes what a teletype would have: the words, a line at a time. */ const PLAIN = !out.isTTY || (process.env["NO_COLOR"] ?? "") !== ""; const paint = (code ) => (s ) => (PLAIN ? s : `\x1b[${code}m${s}\x1b[0m`); const dim = paint("2"); const peer = paint("36"); const bold = paint("1"); const red = paint("31"); /** A letter taken back. On paper it cannot be rubbed out, so it is shown the way a teletype showed it. */ const RUBOUT = PLAIN ? "^H" : "\b \b"; /** The terminal's bell, with each RING: only at a keyboard, never down a pipe or into a file. */ const BELL = process.stdin.isTTY === true && out.isTTY === true ? "\x07" : ""; /** Whether the last thing written ended a line. */ let atLineStart = true; function write(s ) { if (s === "") return; out.write(s); atLineStart = s.endsWith("\n"); } // --- what the board has told us -------------------------------------------- let bps = 28800; let myHandle = ""; let busy = false; let carrier = false; const shelf = []; const confs = []; const wall = []; let lobby = []; let lobbyBuilding = []; /** Who is pulling what, from the lobby's XFER lines. Shown under the callers by `who`. */ let pullsBuilding = []; const icons = new Map (); /** * The file coming down. Its bytes go into `fd`, which is downloads/.part * (partOf), and it becomes downloads/ only once all of it is down. With * no fd, it is one refused for its name: its bytes go by unsaved. `had` is a * file of that name already in downloads, from before, which is left as it was * unless this one arrives whole. */ let xfer = null; /** * A name the shelf can have: what an upload is held to (src/incoming.ts), in * either case, since a name the operator typed into the catalogue is held to * nothing. The name comes down the wire, and this program is handed to * strangers to call boards it cannot vouch for. It was joined into a path as * it came, so "../.bashrc" could write over the one in the folder you called from. * So no separator of any system, no dot to start, no colon (a drive, or a * stream, on Windows), no control character; and none of the names Windows * keeps for devices, where COM1.TXT is a port and not a file. Refused the same * everywhere, so a board's file is kept or not whatever the caller runs. */ const PLAIN_NAME = /^[A-Z0-9][A-Z0-9_-]{0,20}\.[A-Z0-9]{2,4}$/i; const DEVICE = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])\./i; function plainFileName(name ) { if (!PLAIN_NAME.test(name) || DEVICE.test(name)) return false; // And, should the pattern ever loosen, it still lands in downloads and nowhere under it. return path.dirname(path.resolve("downloads", name)) === path.resolve("downloads"); } /** * Where a file is written while it is still coming: beside its name, never as * it. A plain name has one dot, so no name a board sends can be this one. */ const partOf = (name ) => path.join("downloads", `${name}.part`); /** * The file coming down did not come down whole. A caller piped `bye` straight * after `get tank` and the phone went down mid-file, as they had said it * should, and left an empty TANK.BAS in downloads, looking exactly like the * file, without a word (JOURNAL.md, "The file that was not there"). A line * dropping kept half of one the same way. Now what had come goes, and the * caller is told how much had come and that none of it was kept. There is no * resuming here, so half a file is only something to mistake for the file. * Said only when something was being saved: a refused name was said already. */ function leaveUnfinished(lead ) { const x = xfer; if (x === null) return; xfer = null; if (x.fd === null) return; dropPart(x); if (!atLineStart) write("\n"); say(` ${lead} ${x.name} ${x.got} of ${x.total} bytes down. none of it was kept${ x.had ? `, and downloads/${x.name} is still the one you had before` : ""}.`); } function dropPart(x ) { if (x.fd === null) return; try { fs.closeSync(x.fd); } catch { /* closed already */ } x.fd = null; fs.rmSync(partOf(x.name), { force: true }); } // However else the program ends mid-file, nothing is left half written: the // terminal window closed (SIGHUP), the program stopped (SIGTERM, or ^C to a // pipe), or any exit nobody here foresaw. Not said, since there may be nobody // to say it to; the signal then does what it always did. // // And the keyboard is given back first. Node puts a terminal back the way it // found it when SIGTERM or SIGINT ends a program that has not listened for // them; this one listens, and the signal sent again after goes straight to // the system, which does not. A caller at a keyboard stopped from outside was // left at a prompt that showed nothing they typed. process.on("exit", () => { if (xfer !== null) dropPart(xfer); }); for (const signal of ["SIGHUP", "SIGTERM", "SIGINT"] ) { process.once(signal, () => { if (xfer !== null) dropPart(xfer); if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch { /* the terminal went first */ } } process.kill(process.pid, signal); }); } /** * Asked for and not yet answered: a file (until XFER-BEGIN) and an upload * (until PUT-OK or PUT-BAD). When the input runs out, these are finished * before the phone goes down. A "?" answers either. */ let awaitingFile = false; let awaitingPut = false; /** * Said and not yet answered: a line for the wall (SAY), a post (POST), or * words for the animals wall (ANIMALS). The board answers each one: with * the words landing (your own ONELINER, MSG or ANIMAL-NEW), or with why not * (TOO-LONG, HELD, REPEAT or NOT-KEPT, each naming which, or a "?"). From a * pipe, the next command waits for the answer (underIt). */ let answerFor = null; /** The board has answered `what`, if that is what was waiting. */ function heardBack(what ) { if (answerFor === what) answerFor = null; } /** * When the board last said something that answers something. The lobby, sent * every second while anybody downloads, and somebody arriving or leaving, * answer nothing and do not count. */ let lastWordAt = performance.now(); const UNASKED = new Set(["LINES", "CALLER", "XFER", "END", "RATE", "HELLO", "GONE", "CONFBUMP"]); /** * The lists the board sends on arrival, and whether each has finished * arriving. From a keyboard, asking for one early says it is still coming, * and you ask again. From a pipe there is nobody to ask again: the command * was typed ahead, and it waits for the list instead. */ const landed = new Set (); let chatting = null; /** * A chat, on this terminal, is lines, each of them one person's. A line that * holds the other end's keys starts with their name, the way the page shows * them, so nothing they type, an Enter and then " sysop: ..." included, reads * as one of the board's lines, none of which starts with a caller's name. A * key from one of you on a line that holds the other's starts a new line, * with what that one has typed of their line so far, so their keys and yours * never share one. `chatLine` is whose keys the line being written holds; * `theirLine` and `myLine`, each one's line as typed so far. */ let chatLine = null; let theirLine = ""; let myLine = ""; /** The other end's key (null for their rubout), on a line of theirs. */ function theirKey(key ) { if (key === null && theirLine === "") return; theirLine = key === null ? [...theirLine].slice(0, -1).join("") : theirLine + key; if (chatLine === "theirs" && !atLineStart) { write(key === null ? RUBOUT : peer(key)); return; } if (!atLineStart) write("\n"); write(peer(`${chatting ?? "they"}: ${theirLine}`)); chatLine = "theirs"; } /** The other end's Enter. Their line, as it was last shown, stands. */ function theirEnter() { theirLine = ""; if (chatLine === "theirs" && !atLineStart) write("\n"); chatLine = null; } /** This end's key (null for a rubout), on a line of its own. */ function myKey(key ) { if (key === null && myLine === "") return; myLine = key === null ? [...myLine].slice(0, -1).join("") : myLine + key; if (chatLine === "mine" && !atLineStart) { write(key ?? RUBOUT); return; } if (!atLineStart) write("\n"); write(myLine); chatLine = "mine"; } /** A chat begins or ends: nothing typed in the last one is on anybody's line. */ function newChat() { chatLine = null; theirLine = ""; myLine = ""; } /** * A ring (src/ring.ts). `rungBy` is who is ringing this line, from their * first RING until it stops or is picked up; `ringingOut`, who this line is * ringing. `phoneAsked`: a chat or a pickup has gone and the board has not * said how it went, which a pipe waits for before its next line. */ let rungBy = null; let ringingOut = null; let phoneAsked = false; /** * Lines that were already typed ahead when this line began to ring. None of * them answers it, a pickup among them included: it was written before * anything rang, for whatever came next. Counted off as they are read. */ let typedBeforeRing = 0; /** The line ask() last gave back was one of those. */ let readBeforeRing = false; /** * The conference last asked for with `read`. A post is shown only in its own * conference: this one, or, for a post of your own, wherever it went. */ let reading = null; /** * The conference asked for with `read`, while it is still arriving: until the * board's MSG-END for it, or the "?" it says to one that is not there. */ let conferenceArriving = null; /** * Until a caller is in, the board hangs up on silence 45 seconds after it * asks, and a newcomer inventing a user id and a password is not silence. * So while the board is asking, this says HERE, which is all the board needs * to hear: at the first key of each question, at each Enter, and otherwise * at most once every ten seconds of typing. Once in, it says nothing extra. */ let saidHereAt = -Infinity; const HERE_EVERY_MS = 10_000; function typingAtTheDoor(now ) { if (myHandle !== "" || !carrier || ws === null) return; if (!now && performance.now() - saidHereAt < HERE_EVERY_MS) return; saidHereAt = performance.now(); ws.sendText("HERE"); } /** * The animals wall, while it is arriving: asked for by `animals`, and over at * ANIMALS-END. What this caller said when they signed up comes first and is * held until then, so it is printed last, just above the prompt, the way the * browser shows it above the empty box. */ let animalsArriving = false; let animalsShown = 0; let animalsSignup = ""; /** * How It Was Made, while it is arriving: asked for by `made`, and over at * MADE-END. Each line is printed as it lands, wrapped to the terminal. Only * what follows the board's MADE-BEGIN is printed, so the tail of an earlier * asking still on the line is not mixed into this one. */ let madeArriving = false; let madeReading = false; let madeShown = 0; /** * The Desk (Sprint 8): the sysop's, and the co-sysops'. The board sends its * picture only to whoever has it, and that is how this program knows to * offer the desk commands. The board checks every one of them anyway. */ let atTheDesk = false; /** A Desk listing or look on its way, for a pipe to wait on. */ let deskArriving = false; /** What the desk has listed so far on this `desk`, to put headings over. */ let deskFiles = 0; let deskCallers = 0; let deskNames = 0; /** The upload being looked at: its name, and, if it is a picture that draws, its palette. */ let deskLook = null; /** * Text as it is, not as it acts: a control character in something somebody * else wrote is shown as a symbol, never sent to this terminal to act on it. * Everything another caller wrote goes through here on its way to the * screen: the wall and the mat under it, posts, handles, a chat partner's * keys, the queue, the catalogue. The wall and the posts once went to the * terminal as they came, so one caller could clear another's screen, retitle * their window or worse, and the wall kept doing it to everybody who read it. * (The server desk does the same, in src/sysop.ts.) */ function shownAsIs(text ) { let shown = ""; for (const ch of text) { const c = ch.codePointAt(0) ; // The line and paragraph separators are not controls, but a program // reading this one's output may end a line at either, and then whatever // somebody typed after one began a line of its own. shown += c < 0x20 ? String.fromCodePoint(0x2400 + c) : c === 0x7f ? "\u2421" : (c >= 0x80 && c <= 0x9f) || c === 0x2028 || c === 0x2029 ? "?" : ch; } return shown; } /** * The board has said it heard nothing for a while, and will hang up unless * you say something. Much of what you type here never reaches it (`who`, * `shelf`, an empty line), so the next key you press tells it you are there. */ let askedIfThere = false; // --- the keyboard -------------------------------------------------------------- let mode = { kind: "idle" }; let ws = null; /** * Keys pressed before anyone asked. Pasted or piped input lands here and is * used in order. Kept as it came: whether a line can be written plainly * depends on what it answers, and a password is not written plainly. */ let typedAhead = ""; /** The input has ended: a pipe with nothing more in it. */ let stdinEnded = false; /** * The terminal is 7-bit, as a 1995 one was, and the board's lines are * plain ASCII from here. What has a plain spelling gets it (an em dash is * two hyphens, é is e, curly quotes are straight). A password keeps no * spellings: a letter in it that cannot go is left out, and said to be. */ const PLAIN_SPELLING = { "\u2014": "--", "\u2013": "-", "\u2012": "-", "\u2010": "-", "\u2011": "-", "\u2212": "-", "\u2018": "'", "\u2019": "'", "\u201a": "'", "\u201b": "'", "\u2032": "'", "\u201c": "\"", "\u201d": "\"", "\u201e": "\"", "\u201f": "\"", "\u2033": "\"", "\u00ab": "<<", "\u00bb": ">>", "\u2039": "<", "\u203a": ">", "\u2026": "...", "\u00a0": " ", "\u2022": "*", "\u00b7": ".", "\u00d7": "x", "\u00f7": "/", "\u00df": "ss", "\u00e6": "ae", "\u00c6": "AE", "\u0153": "oe", "\u0152": "OE", "\u00f8": "o", "\u00d8": "O", "\u0142": "l", "\u0141": "L", "\u0111": "d", "\u0110": "D", "\u00f0": "d", "\u00d0": "D", "\u00fe": "th", "\u00de": "Th", }; /** What of this can be typed on this terminal, and how many characters could not. */ function sendable(raw , hidden ) { let text = "", dropped = 0; for (const ch of raw) { const spelt = hidden ? ch : PLAIN_SPELLING[ch] ?? ch.normalize("NFKD").replace(/\p{M}/gu, ""); for (const c of spelt) { if (c >= " " && c <= "~") text += c; else if (c.codePointAt(0) > 0x7f) dropped++; } } return { text, dropped }; } /** Said after the line it happened to, so the caller knows what went is not what they typed. */ function leftOut(n ) { if (n > 0) say(dim(` (${n === 1 ? "1 character this terminal can't send was" : `${n} characters this terminal can't send were`} left out)`)); } function ask(prompt , hidden = false) { return new Promise((resolve) => { const nl = typedAhead.indexOf("\n"); readBeforeRing = false; if (nl !== -1) { const { text, dropped } = sendable(typedAhead.slice(0, nl), hidden); typedAhead = typedAhead.slice(nl + 1); if (typedBeforeRing > 0) { typedBeforeRing--; readBeforeRing = true; } if (PLAIN && !atLineStart) write("\n"); write(`${prompt}${hidden ? "" : text}\n`); leftOut(dropped); resolve(text); return; } // Asked, and there is nothing more coming to answer it with. if (stdinEnded) { void finishAndHangUp(); return; } // Keys typed before it was asked, with no Enter yet, are the start of // its answer, as whole lines typed ahead are answers. They used to be // left behind, unshown, and joined onto whatever line came next: from a // pipe while the board was still arriving, half a command went nowhere. const { text, dropped } = sendable(typedAhead, hidden); typedAhead = ""; if (PLAIN && !atLineStart) write("\n"); write(`${prompt}${hidden ? "" : text}`); saidHereAt = -Infinity; mode = { kind: "line", prompt, hidden, buffer: text, dropped, resolve }; }); } /** The prompt, said again in plain text once the board has finished talking over it. */ let promptAgain = null; /** Print a line from the board without trampling whatever is being typed. */ function say(line ) { if (mode.kind === "line" && !PLAIN) { write(`\r\x1b[K${line}\n${mode.prompt}${mode.hidden ? "" : mode.buffer}`); return; } // Plain text cannot take the prompt back, so the line goes under it, and // the prompt, with anything half-typed, comes again when the board stops. if (!atLineStart) write("\n"); write(`${line}\n`); if (mode.kind !== "line") return; if (promptAgain !== null) clearTimeout(promptAgain); promptAgain = setTimeout(() => { promptAgain = null; if (mode.kind === "line" && atLineStart) write(`${mode.prompt}${mode.hidden ? "" : mode.buffer}`); }, 250); } /** Set once the phone is going down on purpose, so the close is not reported as a drop. */ let hangingUp = false; function hangUp() { if (hangingUp) return; hangingUp = true; // Yours to do, mid-file or not. What had come of the file is not kept, and said so. leaveUnfinished("you hung up with"); if (process.stdin.isTTY) process.stdin.setRawMode(false); write("\n"); if (ws === null) process.exit(0); // Put down properly: the board is told, then the line closes. void ws.putDown().then(() => process.exit(0)); } /** * The input has run out. Whatever is on its way is finished first — a file * coming down, an upload waiting for its answer, and whatever the board is * still saying back to what was sent — and then the phone goes down, the * way `bye` puts it down. It used to stay on the line, saying nothing, * until the board hung up on the quiet eleven minutes later. */ let finishing = false; async function finishAndHangUp() { if (finishing) return; finishing = true; const QUIET_MS = 1_500, LONGEST_MS = 20_000; const started = performance.now(); for (;;) { const now = performance.now(); const inFlight = xfer !== null || awaitingFile || awaitingPut; if (!inFlight && (now - lastWordAt >= QUIET_MS || now - started >= LONGEST_MS)) break; await new Promise((r) => setTimeout(r, 100)); } say(dim(" (that was everything you typed. hanging up.)")); hangUp(); } /** The last key was a carriage return, which may have a line feed behind it. */ let afterCR = false; /** Letters typed in a chat that could not go, said at the Enter after them. */ let chatDropped = 0; function onKey(chunk ) { if (askedIfThere && ws !== null) { askedIfThere = false; ws.sendText("HERE"); } for (const ch of chunk) { // CRLF is one Enter. A pipe from Windows, or a tool that ends its lines // that way, used to press Enter twice, and the second answered the next // question with nothing: an empty password, an empty name. const lf = ch === "\n" && afterCR; afterCR = ch === "\r"; if (lf) continue; if (ch === "\x03" || ch === "\x04") { hangUp(); return; } if (mode.kind === "chat") { if (ch === "\x1b") { ws?.sendText("CHAT-END"); continue; } if (ch === "\r" || ch === "\n") { ws?.sendText("KE"); myLine = ""; chatLine = null; write("\n"); leftOut(chatDropped); chatDropped = 0; continue; } if (ch === "\x7f" || ch === "\b") { ws?.sendText("KB"); myKey(null); continue; } const { text, dropped } = sendable(ch, false); chatDropped += dropped; for (const c of text) { ws?.sendText(`K\t${c}`); myKey(c); } continue; } if (mode.kind !== "line") { if (ch === "\r" || ch === "\n") typedAhead += "\n"; else if (ch >= " " && ch !== "\x7f") typedAhead += ch; continue; } // Typing at the board's questions, before being let in (typingAtTheDoor). typingAtTheDoor(ch === "\r" || ch === "\n"); if (ch === "\r" || ch === "\n") { const { buffer, resolve, dropped } = mode; write("\n"); mode = { kind: "idle" }; leftOut(dropped); resolve(buffer); continue; } if (ch === "\x7f" || ch === "\b") { if (mode.buffer.length > 0) { mode.buffer = mode.buffer.slice(0, -1); if (!mode.hidden) write(RUBOUT); } continue; } if (ch === "\x1b") continue; // arrows and friends: not today const { text, dropped } = sendable(ch, mode.hidden); mode.dropped += dropped; mode.buffer += text; if (!mode.hidden) write(text); } } // --- rendering ----------------------------------------------------------------- const eta = (bytes ) => { const s = bytes / (bps / 10); return s < 60 ? `${s.toFixed(0)}s` : `${Math.floor(s / 60)}m${(s % 60).toFixed(0).padStart(2, "0")}s`; }; /** * A picture's name, and in plain text the key to it: each character and its * colour, as a picture is written in the format callers leave them in. */ function iconName(name , pic ) { if (!PLAIN) return dim(` ${name}`); return ` ${name} ${[...pic.pal].map(([ch, hex]) => `${ch} ${hex}`).join(" ")}`; } /** One row of a picture: in colour, or in plain text the characters it is written in. */ function iconRow(pic , row ) { if (PLAIN) return ` ${row}`; let line = " "; for (const ch of row) { const hex = pic.pal.get(ch) ?? "#000000"; const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); line += `\x1b[48;2;${r};${g};${b}m `; } return line + "\x1b[0m"; } function paintIcon(name ) { const pic = icons.get(name); if (pic === undefined) return; say(iconName(name, pic)); for (const row of pic.rows) say(iconRow(pic, row)); } /** * The board says everything it has to say at line speed, so for the first * half-minute of a call the catalogue, the wall and the conferences are still * coming down the wire. Printing a bare heading over nothing reads as "empty", * which is a different and untrue thing. Say which it is. */ function stillComing(what ) { say(dim(` the ${what} is still coming down the line. it is 28800 bps. try again in a moment.`)); } function showShelf() { if (shelf.length === 0) { stillComing("shelf"); return; } say(bold(" the shelf") + dim(` (${bps} bps — every file takes as long as it takes)`)); for (const it of shelf) say(` ${it.id.padEnd(10)} ${shownAsIs(it.name).padEnd(14)} ${String(it.bytes).padStart(7)}B ~${eta(it.bytes).padStart(6)} ${shownAsIs(it.about)}`); } function showConfs() { if (confs.length === 0) { stillComing("conference list"); return; } say(bold(" conferences")); for (const c of confs) say(` ${c.id.padEnd(10)} ${c.name.padEnd(18)} ${c.unread > 0 ? red(`${c.unread} new`) : dim("read")} ${c.about}`); } function showWall() { if (wall.length === 0) { stillComing("wall"); return; } say(bold(" the wall")); // The names in a column as wide as the longest of them, and never cut: // "Claude Opus 5.5" ran straight into its own words, past a column twelve // wide. Two spaces after it, because a name can have one space in it but // never two together, so where a name ends is never a guess. const width = Math.max(12, ...wall.map((w) => [...shownAsIs(w.handle)].length)); for (const w of wall) { const name = shownAsIs(w.handle); say(` ${dim(name + " ".repeat(width - [...name].length))} ${shownAsIs(w.text)}`); } } function showLobby() { if (lobby.length === 0) { stillComing("lobby"); return; } for (const l of lobby) say(` ${l}`); } /** An entry's date, in this machine's own calendar. */ function dated(at ) { const d = new Date(at); return Number.isFinite(d.getTime()) ? d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : ""; } /** Words laid into lines no wider than a terminal, each indented the same. A word longer than a line is left whole. */ function wrapped(text , indent , width = 76) { const lines = []; let line = ""; for (const word of text.split(" ")) { if (line !== "" && indent.length + line.length + 1 + word.length > width) { lines.push(indent + line); line = word; } else line = line === "" ? word : `${line} ${word}`; } if (line !== "") lines.push(indent + line); return lines; } /** * One entry on the animals wall: who and when, then the words under them. * Somebody else wrote them, so a control character in them is shown, never * sent to this terminal to act on (shownAsIs). */ function showAnimal(handle , at , text ) { say(` ${peer(shownAsIs(handle))} ${dim(dated(at))}`); for (const l of wrapped(shownAsIs(text), " ")) say(l); } function help() { for (const l of [ " who who is on shelf what is on the shelf", " get pull a file wall the one-liners", " say leave one line confs the conferences", " read read one post post to one", " animals the animals wall animal write about yours", " chat ring somebody icons the pictures again", " pickup answer a ring made how it was made", " put leave something bye hang up", // The Desk's, shown only to whoever the board sent the Desk. ...(atTheDesk ? [ " desk what waits for you look see it, all of it", " shelve put it on the shelf, once seen whole", " back send it back hello say hello to a new caller", " wait leave them waiting lockout lock a new caller out", " delete delete what waits under a name. it never goes up.", ] : []), dim(" everything arrives at line speed. that is not a fault."), ]) say(l); } // --- the board talking -------------------------------------------------------- function onFrame(r ) { if (r.kind === "binary") { if (xfer === null) return; lastWordAt = performance.now(); if (xfer.fd === null) return; fs.writeSync(xfer.fd, r.bytes); xfer.got += r.bytes.length; // The bytes come so far, against the total the board declared. That // total comes down the wire from a board this program cannot vouch for: // 0 made the bar infinitely long and below 0 less than nothing, and // either threw, and took the program down mid-call. A total that says // nothing is a bar that is full; one smaller than the file, full early. const pct = xfer.total > 0 ? Math.min(1, xfer.got / xfer.total) : 1; const secs = (performance.now() - xfer.startedAt) / 1000; const rate = `${(xfer.got / Math.max(secs, 0.1)).toFixed(0)} B/s`; // In plain text a bar cannot be redrawn, so it is a line a quarter. if (PLAIN) { const quarters = Math.floor(pct * 4); if (quarters > xfer.quarters && quarters < 4) { xfer.quarters = quarters; say(` ${xfer.name} ${quarters * 25}% ${xfer.got}/${xfer.total} bytes ${rate}`); } return; } const bar = "#".repeat(Math.round(pct * 30)).padEnd(30, "."); write(`\r ${xfer.name.padEnd(14)} [${bar}] ${String(xfer.got).padStart(7)}/${xfer.total} ${rate}`); return; } if (r.kind !== "text") return; const text = r.text; const tabs = text.split("\t"); const [verb, ...rest] = text.split(/[ \t]/); if (!UNASKED.has(verb ?? "")) lastWordAt = performance.now(); switch (verb) { // RING alone is ringback: the board's phone ringing in the handshake, or // the line this end is ringing. With a name, it is this line ringing. case "RING": { if (text === "RING") { say(dim(" ring")); return; } const first = rungBy === null; rungBy = text.slice("RING ".length); if (first) typedBeforeRing = (typedAhead.match(/\n/g) ?? []).length; say(`${BELL}${bold("RING")} ${peer(shownAsIs(rungBy))}`); if (first) say(dim(" type pickup to answer, or let it ring.")); return; } case "RANG": rungBy = null; say(dim(" it stopped ringing.")); return; case "RINGING": ringingOut = shownAsIs(rest.join(" ")); say(dim(`ringing ${ringingOut}...`)); return; // A modem's result code: what this end heard, and nothing about the other. case "NO": { if (text !== "NO ANSWER") { say(text); return; } ringingOut = null; phoneAsked = false; say(bold("NO ANSWER")); return; } // The second ring to the same name in one call: the recording, in the // board's words. The tones are the page's; a terminal has the words. case "INTERCEPT": phoneAsked = false; for (const line of tabs.slice(1)) say(line); return; case "ANSWER": say(dim(" (the board picks up)")); return; case "BUSY": busy = true; say(bold("BUSY")); return; case "CONNECT": carrier = true; say(bold(text)); bps = Number(rest[0]); return; case "RATE": bps = Number(rest[0]); return; case "LINE": say(dim(` line ${rest[0]}`)); return; // The mat, and under it the last thing somebody left on the wall. case "MAT": say(shownAsIs(tabs.slice(1).join("\t"))); return; case "MAT-END": say(""); return; case "LOGIN": void door(); return; // The name door's answers (src/name-door.ts). case "NAME-HELD": nameAsked = false; void heldName(doorName); return; case "NAME-OK": nameAsked = false; void questions(doorName); return; case "NAME-BAD": { nameAsked = false; say(` ${tabs.slice(2).join("\t") || "the board would not take that name."}`); // From a pipe, typed ahead, or named on the command line, nobody is // there to give another: what comes next was written to answer // something else. The call ends here, in the board's words. if (unattended() || NAME !== undefined) { nobodyToAsk(); return; } // The board is taking user ids and passwords only, for now. if (rest[0] === "closed") { legacy = true; void login(); return; } void door(); return; } // A name call's words, not kept: its house has brought its five new names this week. case "NOT-KEPT": heardBack(tabs[1]); say(` ${tabs.slice(2).join("\t")}`); return; case "LOGIN-BAD": // The password was right and the account is on another line: one // account, one line. "Minutes" is measured, not a manner of speaking. A // call that closes is heard at once; one that just goes quiet holds its // line until the house picks up the extension on it, a median of about // four minutes, about thirteen for somebody who lives alone, and // sometimes past twenty (the LOGIN branch in web-door.ts). if (rest[0] === "on-a-line") { say(" that account is already on a line. if that was you and your call dropped,"); say(" the board can take several minutes to notice, sometimes longer. try again in a few minutes."); } else if (rest[0] === "locked") { // The operator has locked it. The board's own words, said only to // somebody who had the password. say(` ${tabs.slice(1).join("\t") || "that account is locked."}`); } else { // A newcomer who picked a user id somebody already has hears this // too, and the board cannot tell them from its owner: so it says // what the id is, and what a newcomer does about it. say(" not recognised. that user id is somebody's, and that isn't its password."); say(legacy ? " new here? choose a different user id." : " new here? a name is enough to come in: any name that isn't somebody's user id."); } // From a pipe, or typed ahead, the next line was written for something // else: the call ends here, in the board's words, rather than try it as // a password. if (unattended()) { nobodyToAsk(); return; } if (legacy) { void login(); return; } if (rest[0] === undefined) void heldName(heldId); else void door(); return; case "REGISTER-NEEDED": void register(); return; // This house has brought its five new people this week. The board says // what to do, with the house's code in it, and asks who you are again: // you may already have an account. case "REGISTER-FULL": say(` ${tabs.slice(1).join("\t")}`); if (unattended()) { nobodyToAsk(); return; } if (keeping !== null) { keeping = null; void door(); } else void login(); return; case "REGISTER-BAD": { const why = rest[0] ?? ""; // A name the board would not take for what it is comes with the // board's own words for why, after a tab: what is wrong with it. They // are said instead of this end's. const boardSays = tabs.slice(1).join("\t"); say(` ${boardSays || ({ "id-taken": "somebody already uses that user id.", "handle-taken": "somebody already calls themselves that.", // Nobody has the name: something said or left under it is waiting. // What goes up under a name makes it nobody's for good; what is let // go or sent back leaves it free. The same words either way. "handle-waiting": "words or a file left under that name are still waiting, so it can't be kept now, and if they go up, it never can be. words wait about a day; a file, until the sysop looks at it.", "bad-id": "a user id is 2-20 letters, digits, dot, dash or underscore.", "bad-handle": "a name is 2-20 characters from A-Z, a-z, 0-9, space, dot, dash and underscore, starting with a letter or digit.", "short-password": `a password needs at least ${MIN_PASSWORD} characters.`, }[why] ?? "the board would not accept that.")}`); // From a pipe, or typed ahead, the next lines were written for // something else: never a user id, a password or a name of somebody's // choosing. The call ends. if (unattended()) { nobodyToAsk(); return; } // Re-ask the part that was wrong. Re-asking the household questions // when the password was too short is how a caller gets stuck forever. const theId = why === "bad-id" || why === "id-taken" || why === "short-password"; if (keeping !== null) { // Keeping a name: a user id or password the board would not take is // asked again; a name it would not take means another name, at the door. if (theId) void keepIt(); else { keeping = null; void door(); } return; } if (theId) void login(); else void register(); return; } case "WELCOME": { keeping = null; myHandle = tabs[1] ?? ""; const calls = Number(tabs[2] ?? 0); say(""); say(bold(` kittensnrainbows`) + dim(` -- you are ${shownAsIs(myHandle)}, ${calls > 0 ? `call ${calls + 1}` : "first call"}`)); say(dim(" ? for help. it all arrives at line speed.")); void commandLoop(); return; } case "BACK": say(dim(" " + shownAsIs(tabs.slice(1).join("\t")))); return; case "BACK-END": say(""); return; case "ICON": icons.set(rest[0] , { w: Number(rest[1]), h: Number(rest[2]), pal: new Map(), rows: [] }); if (!PICTURES && icons.size === 1) say(dim(" the pictures are coming down the line, not printed. icons shows them.")); // Its picture is how the board says this caller has the Desk. if (rest[0] === "desk") atTheDesk = true; return; case "IPAL": icons.get(rest[0] )?.pal.set(rest[1] , rest[2] ); return; case "IROW": { // Paint the row as it lands — this is the whole reason they come one per frame. const pic = icons.get(rest[0] ); if (pic === undefined) return; pic.rows.push(rest[1] ?? ""); if (!PICTURES) return; if (pic.rows.length === 1) say(iconName(rest[0] , pic)); say(iconRow(pic, rest[1] ?? "")); return; } case "ICON-END": return; case "ONELINER": { wall.push({ handle: tabs[1] ?? "", text: tabs[2] ?? "" }); // Your own line, landing: the answer to your say. if (tabs[1] === myHandle) heardBack("SAY"); return; } case "ONELINER-END": landed.add("wall"); showWall(); return; // The Animals I Live With. Painted as each entry lands, newest on top. case "ANIMALS-SIGNUP": animalsSignup = tabs.slice(1).join("\t"); return; case "ANIMAL": { const [, , at, handle, ...words] = tabs; if (animalsShown === 0) say(bold(" the animals I live with") + dim(" newest on top")); animalsShown++; showAnimal(handle ?? "", Number(at), words.join("\t")); return; } case "ANIMAL-NEW": { const [, , at, handle, ...words] = tabs; if (handle === myHandle) { heardBack("ANIMALS"); // Your own, just written: it is at the top of the wall now. say(dim(" on the animals wall now, at the top:")); showAnimal(handle, Number(at), words.join("\t")); } else { // Somebody else's, landing while you are here. Said the way an // arrival is, and it is there in full the next time you look. say(dim(` ${shownAsIs(handle ?? "somebody")} wrote on the animals wall.`)); } return; } case "ANIMALS-END": { if (animalsShown === 0) say(dim(" nobody has written on the animals wall yet.")); if (animalsSignup !== "") for (const l of wrapped(`when you signed up you said: ${shownAsIs(animalsSignup)}`, " ")) say(l); say(dim(" animal writes about the animals you live with. typed once, never edited.")); animalsArriving = false; return; } case "TOO-LONG": heardBack(tabs[1]); say(` ${tabs.slice(2).join("\t")}`); return; // How It Was Made. Printed a line at a time as it lands, as it was // written: wrapped to the terminal, never reworded. A MADE-MORE is the // rest of a line too long for one frame, and carries on under it. case "MADE-BEGIN": madeReading = true; madeShown = 0; say(""); return; case "MADE-LINE": case "MADE-MORE": { if (!madeReading) return; madeShown++; const line = shownAsIs(tabs.slice(1).join("\t").replace(/\t/g, " ")); if (line.trim() === "") { say(""); return; } const lead = /^ */.exec(line) [0]; for (const l of wrapped(line.slice(lead.length), ` ${lead}`)) say(l); return; } case "MADE-END": { if (!madeReading) return; madeReading = false; madeArriving = false; say(dim(madeShown === 0 ? " there is nothing in it just now." : " (read only. made shows it again.)")); return; } // The Desk. Only whoever has it is ever sent these. case "DESK-FILE": { const [, name, bytes, maker] = tabs; if (deskFiles++ === 0) say(bold(" the queue") + dim(" left for the sysop, and on nobody's shelf yet")); say(` ${(name ?? "").padEnd(18)} ${String(bytes).padStart(6)}B ${maker ? `left by ${shownAsIs(maker)}` : dim("nobody's name on it")}`); return; } case "DESK-CALLER": { const [, handle, at, calls, , helloIn] = tabs; if (deskCallers++ === 0) say(bold(" new callers") + dim(" their words wait until somebody says hello")); const when = helloIn === undefined || helloIn === "" ? "" : `; ${shownAsIs(helloIn)}`; say(` ${peer(shownAsIs(handle ?? ""))} ${dim(`registered ${dated(Number(at))}, ${calls} ${calls === "1" ? "call" : "calls"}${when}`)}`); return; } // What waits under a name alone (src/name-door.ts): by the name, and when it goes up by itself. case "DESK-NAME": { const [, name, , upIn] = tabs; if (deskNames++ === 0) say(bold(" names") + dim(" came in under a name alone; their words go up by themselves")); say(` ${peer(shownAsIs(name ?? ""))} ${dim(shownAsIs(upIn ?? ""))}`); return; } case "DESK-HELD": case "DESK-NAME-HELD": { const [, , where, ...body] = tabs; for (const l of wrapped(`${where}: ${shownAsIs(body.join("\t"))}`, " ")) say(l); return; } case "DESK-END": { if (deskFiles === 0) say(dim(" nothing waiting in the queue.")); if (deskCallers === 0) say(dim(" nobody waiting to be said hello to.")); if (deskFiles > 0) say(dim(" look to see one, all of it. it comes at line speed.")); if (deskCallers > 0) say(dim(" hello , wait or lockout for a new caller.")); if (deskNames > 0) say(dim(" delete deletes what waits under a name. it never goes up.")); deskArriving = false; return; } case "DESK-LOOK": { const [, name, bytes, maker] = tabs; deskLook = { name: name ?? "", rows: 0, pic: null }; say(bold(` ${name}`) + dim(` ${bytes} bytes${maker ? `, left by ${shownAsIs(maker)}` : ""}`)); return; } case "DESK-ROW": { if (deskLook === null) return; const row = tabs.slice(1).join("\t"); deskLook.rows++; // A picture is drawn as it lands, a row at a time, as the icons are. if (deskLook.name.endsWith(".KNR")) { if (deskLook.rows === 1 && /^KNR \d{1,3} \d{1,3}$/.test(row)) { deskLook.pic = { pal: new Map(), drawing: false }; say(dim(` ${row}`)); return; } const pic = deskLook.pic; if (pic !== null) { const colour = /^(\S) (#[0-9a-fA-F]{6})$/.exec(row); if (colour !== null && !pic.drawing) { pic.pal.set(colour[1] , colour[2] ); if (PLAIN) say(` ${row}`); return; } pic.drawing = true; say(iconRow(pic, row)); return; } } say(` | ${shownAsIs(row)}`); return; } case "DESK-LOOKED": { say(dim(` that is all of ${tabs[1]}. shelve ${tabs[1]} puts it on the shelf; back ${tabs[1]} sends it back.`)); deskLook = null; deskArriving = false; return; } case "DESK-OK": case "DESK-NO": { say(` ${verb === "DESK-NO" ? "no: " : ""}${shownAsIs(tabs.slice(3).join("\t"))}`); deskArriving = false; return; } // What the Desk just shelved, on this caller's own shelf too. case "SHELF-NEW": shelf.push({ id: rest[0] , bytes: Number(rest[1]), name: rest[2] , about: rest.slice(3).join(" ") }); return; case "CONF": confs.push({ id: tabs[1] , name: tabs[2] , unread: Number(tabs[3]), about: tabs[4] ?? "" }); return; case "CONF-END": landed.add("confs"); showConfs(); return; case "SHELF": shelf.push({ id: rest[0] , bytes: Number(rest[1]), name: rest[2] , about: rest.slice(3).join(" ") }); return; case "SHELF-END": landed.add("shelf"); showShelf(); return; case "LINES": lobbyBuilding = [`${rest[0]} of ${rest[1]} lines in use`]; pullsBuilding = []; return; // Somebody else arrived, or left. Printed where you are, the way a board // with more than one line always did it. say() redraws the prompt and // whatever you had half-typed underneath, so nothing you were doing is // lost to it. case "HELLO": say(dim(` ${shownAsIs(tabs[1] ?? "")} is on line ${tabs[2]}.`)); return; case "GONE": say(dim(` ${shownAsIs(tabs[1] ?? "")} hung up.`)); return; case "CALLER": { // The name is whatever sits between the line number and the seconds: a // handle may have a space in it, and this arrives split on spaces. const [line, ...mid] = rest; const held = mid.pop(); lobbyBuilding.push(`line ${line} ${shownAsIs(mid.join(" ")).padEnd(14)} ${dim(`${held}s`)}`); return; } // Somebody pulling a file. Part of the lobby, like CALLER: the board sends // the whole lobby again every second while anyone is downloading, and it // used to fall through to the bottom of this switch and print, raw, once a // second, over whatever you were doing. It waits for `who` now, with the // rest of the lobby. Handle in the middle, as with CALLER: it may have a space. case "XFER": { const [line, ...mid] = rest; const total = Number(mid.pop()), sent = Number(mid.pop()), name = mid.pop() ?? ""; const pct = total > 0 ? Math.floor((sent / total) * 100) : 0; pullsBuilding.push(dim(`line ${line} ${shownAsIs(mid.join(" "))} is pulling ${shownAsIs(name)}, ${sent} of ${total} bytes (${pct}%)`)); return; } case "END": lobby = [...lobbyBuilding, ...pullsBuilding]; landed.add("lobby"); return; case "XFER-BEGIN": { awaitingFile = false; const name = rest[1] ?? ""; // Not a plain file name: nothing is opened, and it is said so. It still // comes down the line, and is still the file in flight until XFER-DONE, // so a get typed ahead waits for it, as it would for any other. if (!plainFileName(name)) { xfer = { id: rest[0] ?? "", name, total: Number(rest[2]), got: 0, startedAt: performance.now(), quarters: 0, fd: null, had: false }; say(dim(` the board is sending a file called "${shownAsIs(name)}", which is not a plain file name. it comes down the line all the same, and is not saved.`)); return; } fs.mkdirSync("downloads", { recursive: true }); // Written beside its name until it is all here (partOf), so nothing is // ever under its name that is not the whole of it. xfer = { id: rest[0] , name, total: Number(rest[2]), got: 0, startedAt: performance.now(), quarters: 0, fd: fs.openSync(partOf(name), "w"), had: fs.existsSync(path.join("downloads", name)), }; say(dim(` pulling ${shownAsIs(name)}, ${xfer.total} bytes, about ${eta(xfer.total)}`)); return; } case "XFER-DONE": { if (xfer === null) return; if (xfer.fd === null) { xfer = null; return; } // The board says that is the end of it. Fewer bytes than it said the // file was is not the file, and is not kept as it. if (xfer.total > 0 && xfer.got < xfer.total) { leaveUnfinished("the board stopped sending with"); return; } const secs = (performance.now() - xfer.startedAt) / 1000; fs.closeSync(xfer.fd); xfer.fd = null; fs.renameSync(partOf(xfer.name), path.join("downloads", xfer.name)); if (!atLineStart) write("\n"); say(` saved downloads/${xfer.name} ${xfer.got}/${xfer.total} bytes in ${secs.toFixed(1)}s (${(xfer.got / secs).toFixed(0)} B/s)`); xfer = null; return; } case "MSG": { const [, , where, depth, handle, ...body] = tabs; // The conference rides in front of the parent: / (msgLine // in web-door.ts). A board from before that sends the parent alone. const slash = (where ?? "").lastIndexOf("/"); const conf = slash === -1 ? null : where .slice(0, slash); const post = `${" ".repeat(Number(depth) + 1)}${dim(shownAsIs(handle ?? ""))}: ${shownAsIs(body.join("\t"))}`; // Your own post, landing: the answer to it. if (handle === myHandle) heardBack("POST"); if (conf === null || conf === reading) { say(post); return; } // Your own, posted somewhere you are not reading: said where it went. if (handle === myHandle) { say(dim(` posted in ${conf}:`)); say(post); } // Anybody else's, somewhere else, is not printed into this one. The // board says there is something new there (CONFBUMP), and read shows it. return; } case "MSG-END": if (tabs[1] === conferenceArriving) conferenceArriving = null; say(""); return; case "CONFBUMP": say(dim(` something new in ${rest[0]}`)); return; case "CHAT-WITH": { chatting = shownAsIs(rest.join(" ")); newChat(); if (rungBy !== null || ringingOut !== null) say("picked up."); rungBy = null; ringingOut = null; phoneAsked = false; say(bold(` chatting with ${chatting}.`) + dim(" every key you press, they see. Esc to put the phone down.")); mode = { kind: "chat" }; // Lines already written behind the pickup, or behind the chat that // rang, are for this chat: a pipe's next lines go to them. Left // waiting, they ran as commands once the chat was over, and a say // meant for one person went up on the wall. const waiting = typedAhead; typedAhead = ""; typedBeforeRing = 0; if (waiting !== "") onKey(waiting); return; } case "CHAT-BUSY": phoneAsked = false; say(" they are already talking to somebody."); return; case "CHAT-OVER": { if (mode.kind === "chat") mode = { kind: "idle" }; newChat(); say(""); say(dim(` ${chatting ?? "they"} put the phone down.`)); chatting = null; void commandLoop(); return; } // Everything after the tab is their keys, a tab in them too, on a line of theirs. case "PEER-K": theirKey(shownAsIs(tabs.slice(1).join("\t"))); return; case "PEER-KB": theirKey(null); return; case "PEER-KE": theirEnter(); return; case "PICKUP": say(`\n ${bold(tabs[1] ?? "")}`); return; // The board is hanging up on somebody who did not get in in time, and // says why first. What was being asked is not asked again. case "HANGING-UP": { if (mode.kind === "line") { mode = { kind: "idle" }; if (!atLineStart) write("\n"); } say(`\n ${bold(tabs.slice(1).join("\t"))}`); return; } // The board has heard nothing from you for a while. Any key answers it. case "IDLE": { askedIfThere = true; say(`\n ${bold(tabs.slice(1).join("\t"))}`); say(dim(" (any key will do.)")); return; } // The same line, or the same post, twice running. Not kept, and said so. case "REPEAT": heardBack(tabs[1]); say(` ${tabs.slice(2).join("\t")}`); return; // A new caller's words, kept for the sysop rather than put up, or a new // caller's page, not rung. Said so, in the board's words. case "HELD": if (tabs[1] === "CHAT") phoneAsked = false; heardBack(tabs[1]); say(` ${tabs.slice(2).join("\t")}`); return; // The sysop, before the board goes down. It is the last thing the line // carries, so it lands just above NO CARRIER and stays in the scrollback. case "SYSOP": { if (xfer !== null && !atLineStart) write("\n"); say(`\n ${bold(`sysop: ${tabs.slice(1).join("\t")}`)}`); return; } case "PUT-OK": awaitingPut = false; say(` left ${rest[0]} (${rest[1]} bytes) for the operator to look at.`); return; // One refusal can name several things to fix, one to a line. Each one // lines up under the first, so the list reads as a list in scrollback. case "PUT-BAD": { awaitingPut = false; const [first, ...more] = text.slice("PUT-BAD ".length).split("\n"); say(` no: ${first ?? ""}`); for (const line of more) say(` ${line}`); return; } case "?": { // A board from before the name door does not know NAME. It asks for a // user id and a password, the way it always did, and so does this end. if (nameAsked) { nameAsked = false; legacy = true; // From a pipe, or typed ahead, the lines that come next were written // for this door, not for that board's user id and password: none is // sent as either. if (NAME !== undefined || unattended()) { say(" this board takes a user id and a password, not a name alone. hanging up."); hangUp(); return; } void login(); return; } awaitingFile = false; awaitingPut = false; answerFor = null; deskArriving = false; madeArriving = false; phoneAsked = false; conferenceArriving = null; say(dim(" ?")); return; } default: say(text); return; } } // --- talking back -------------------------------------------------------------- /** The name this end last gave at the door. */ let doorName = ""; /** A NAME has gone, and the board has not answered it yet. A "?" then is a board from before the name door. */ let nameAsked = false; /** The board asks for a user id and a password, the way it did before the name door: on a "?" to NAME, or with the door shut. */ let legacy = false; /** The door's one line, said once a call. */ let saidTheDoor = false; /** The user id the board said was somebody's, while its password is asked. */ let heldId = ""; /** Keeping a name between calls: the name, and its answers to the house questions, while a user id and password are asked. */ let keeping = null; /** * Who's calling. A name is enough to come in, for this call; a user id gets * its password. A blank line asks again, so an Enter typed ahead during the * handshake seats nobody. With --name, no line of the input is spent on the * name, and it goes as a typed one would (sendable): a tab in it was a field * once, and the name came in before the questions were asked. */ async function door() { if (NAME !== undefined) { const { text, dropped } = sendable(NAME.trim(), false); leftOut(dropped); giveName(text); return; } if (!saidTheDoor) { saidTheDoor = true; say(" a name is enough to come in. a user id and password keep it between calls."); } let name = ""; while (name === "") name = (await ask(" your name, or your user id: ")).trim(); giveName(name); } function giveName(name ) { doorName = name; nameAsked = true; ws?.sendText(`NAME\t${name}`); } /** * The name is exactly somebody's user id. Only a y, on its own, asks for its * password: a line written for something else (a house question, a * command) is never sent as somebody's password. At a keyboard anything * else is another name, and a blank goes back to the door; from a pipe, or * typed ahead, the next line was written for something else, and is not a * name either. */ async function heldName(id ) { if (NAME !== undefined) { say(" that is somebody's user id, and a name alone can't come in under it. hanging up."); hangUp(); return; } const alone = unattended(); const answer = (await ask(" that is somebody's user id. y to give its password, or another name: ")).trim(); if (answer === "y") { heldId = id; ws?.sendText(`LOGIN\t${id}\t${await password()}`); return; } if (alone) { nobodyToAsk(); return; } if (answer === "") { void door(); return; } giveName(answer); } /** A password, asked without showing it, until it is long enough to be one: the minimum applies to every account. */ async function password() { for (;;) { const pw = await ask(` password (${MIN_PASSWORD}+ characters): `, true); if (pw.length >= MIN_PASSWORD) return pw; say(` too short. ${MIN_PASSWORD} characters or more.`); } } /** * The name is free. Three questions about the house, all optional, and * then return comes in under it, for this call. Only a y, on its own, keeps * the name between calls, with a user id and a password. With --name, the * name is not offered to keep. */ async function questions(name ) { const household = await ask(" people in the house (optional): "); const pets = await ask(" any animals (optional): "); const schedule = await ask(" when are you usually up (optional): "); if (NAME === undefined && (await ask(" return to come in. y to keep this name between calls (a user id and a password): ")).trim() === "y") { keeping = { name, answers: [household, pets, schedule] }; void keepIt(); return; } ws?.sendText(["NAME", name, household, pets, schedule].join("\t")); } /** Keeping the name: a user id and a password, invented here, and the board makes the account. */ async function keepIt() { if (keeping === null) return; let id = ""; while (id === "") id = (await ask(" user id: ")).trim(); const pw = await password(); ws?.sendText(["REGISTER", id, pw, keeping.name, ...keeping.answers].join("\t")); } /** * The board asked again, and nobody here can answer: from a pipe, or typed * ahead, the next line was written to answer something else, and with * --name the name was the whole answer. The phone goes down. */ function nobodyToAsk() { say(dim(NAME !== undefined ? " (the name came from the command line, so the phone goes down. call again with another.)" : " (what comes next was written before this was asked, so there is nobody to ask again, and the phone goes down. call again when you have another answer.)")); hangUp(); } /** Today's login, for a board that asks for a user id and a password: one from before the name door, or with it shut. */ async function login() { const id = await ask(" user id: "); // Checked here rather than at the far end. The board cannot ask about the // password again without first asking a name and the three household // questions, so a short one used to cost a whole round trip and then send // the caller back to answer everything a second time. The rule is knowable locally; the // minimum applies to every account, so nothing under it can log in either. let pw = ""; for (;;) { pw = await ask(` password (${MIN_PASSWORD}+ characters): `, true); if (pw.length >= MIN_PASSWORD) break; say(` too short. ${MIN_PASSWORD} characters or more.`); } ws?.sendText(`LOGIN\t${id}\t${pw}`); // Kept only until the board answers, then thrown away with everything else. pendingId = id; pendingPw = pw; } let pendingId = "", pendingPw = ""; async function register() { say(dim(" new here. the board would like a word.")); const handle = await ask(" what do people call you: "); // Optional, all three, and said so: nobody checks them, and a blank is an answer. const household = await ask(" people in the house (optional): "); const pets = await ask(" any animals (optional): "); const schedule = await ask(" when are you usually up (optional): "); ws?.sendText(["REGISTER", pendingId, pendingPw, handle, household, pets, schedule].join("\t")); } /** Until the board has answered the Desk, when the command was typed ahead down a pipe. */ async function deskAnswered() { if (process.stdin.isTTY) return; const started = performance.now(); while (deskArriving && performance.now() - started < 120_000) await new Promise((r) => setTimeout(r, 50)); } /** One desk command, typed. Names in the queue are one word, in capitals; a handle may have spaces. */ async function deskCommand(cmd , words ) { const file = (words[0] ?? "").toUpperCase(); const handle = words.join(" "); const send = async (line ) => { deskArriving = true; ws?.sendText(line); await deskAnswered(); }; switch (cmd) { case "desk": deskFiles = 0; deskCallers = 0; deskNames = 0; await send("DESK"); return; case "look": if (file === "") { say(" look shows one thing in the queue, all of it: look SUNSET.KNR, say"); return; } await send(`DESK-LOOK\t${file}`); return; case "shelve": { if (file === "") { say(" shelve , once you have looked at all of it"); return; } let line = words.slice(1).join(" ").trim(); if (line === "") line = (await ask(" catalogue line: ")).trim(); if (line === "") { say(" nothing shelved: a catalogue line first, a few words about what it is."); return; } await send(`DESK-SHELVE\t${file}\t${line}`); return; } case "back": { if (file === "") { say(" back sends one thing in the queue back"); return; } if ((await ask(` send ${file} back? it is deleted, not kept. [y/n]: `)).trim().toLowerCase() !== "y") { say(" left waiting"); return; } await send(`DESK-BACK\t${file}`); return; } case "hello": case "wait": case "lockout": { if (handle === "") { say(` ${cmd} , for somebody waiting to be said hello to. desk lists them.`); return; } if (cmd === "lockout" && (await ask(` lock out ${handle}? they cannot log in until the sysop unlocks them, and their held words go. [y/n]: `)).trim().toLowerCase() !== "y") { say(" left as they were"); return; } await send(`${cmd === "hello" ? "DESK-HELLO" : cmd === "wait" ? "DESK-WAIT" : "DESK-LOCK"}\t${handle}`); return; } // Delete was "letgo" until Pro Te named it (2026-10-06), and the old word // still does it. On the wire it is DESK-LETGO, which callers' copies of // this program from before still send. case "delete": case "letgo": { if (handle === "") { say(" delete , for words waiting under a name. desk lists them."); return; } if ((await ask(` delete what waits under ${handle}? it never goes up. [y/n]: `)).trim().toLowerCase() !== "y") { say(" left to go up by itself"); return; } await send(`DESK-LETGO\t${handle}`); return; } } } /** * Wait for one of the arrival's lists, when the command asking for it was * typed ahead down a pipe. From a keyboard, it is shown or said to be still * coming at once. A minute is far longer than the arrival takes at the * slowest line the board gives; past that, it is still coming. */ async function arrived(what ) { if (process.stdin.isTTY) return; const started = performance.now(); while (!landed.has(what) && performance.now() - started < 60_000) { await new Promise((r) => setTimeout(r, 50)); } } /** * From a pipe, no command is read until the board has finished arriving. It * sends everything for a new call in one breath at WELCOME: the pictures, * the wall, the conferences, the lobby, and the shelf last of all (#admit * in src/web-door.ts). Whatever it says back to a command comes behind all * of that. A command read and sent at once had its answer printed after the * lot, hundreds of lines below the line that asked, and a caller who piped * say and then wall read that as the board saying nothing. A board with no * shelf has finished when it goes quiet, as at the end of the input. Once a * call; from a keyboard, never. */ let settledIn = false; async function arrival() { if (process.stdin.isTTY || settledIn) return; const QUIET_MS = 1_500; while (!landed.has("shelf") && performance.now() - lastWordAt < QUIET_MS) { await new Promise((r) => setTimeout(r, 50)); } settledIn = true; } /** * From a pipe, a command that the board answers waits for its answer, so the * answer prints under the line that asked, before the next is read. Only a * minute in which the board answers nothing ends the wait. From a keyboard * nothing waits: the answer prints where you are, as it always did. */ async function underIt(waiting ) { if (process.stdin.isTTY) return; const asked = performance.now(); while (waiting() && performance.now() - Math.max(asked, lastWordAt) < 60_000) { await new Promise((r) => setTimeout(r, 50)); } } /** * The board sends one file at a time, and answers a second asked for while * the first is coming with "?". From a keyboard that is said at once, and you * ask again when it is done. Typed ahead down a pipe, there is nobody to ask * again, so the next `get` waits for the file before it, and both arrive. */ async function oneFileAtATime() { if (process.stdin.isTTY) return; while (xfer !== null || awaitingFile) await new Promise((r) => setTimeout(r, 50)); } /** * A chat or a pickup has gone. From a keyboard, a moment for the board to * answer. From a pipe, until it has: a ring takes as long as it rings. */ async function answered() { if (process.stdin.isTTY) { await new Promise((r) => setTimeout(r, 400)); return; } const started = performance.now(); while (phoneAsked && performance.now() - started < 60_000) await new Promise((r) => setTimeout(r, 50)); } /** * A post is shown only in the conference being read, and `read` used to go * straight on. Typed ahead down a pipe, the next `read` went out before this * one's posts had come, made this one somewhere else, and they were not * printed: three reads in a row brought back one conference, without a word. * So the next command waits for the conference, all of it, however long it * is at whatever speed the line is; only a minute in which the board answers * nothing ends the wait. From a keyboard nothing waits, as before. */ async function conferenceArrived() { if (process.stdin.isTTY) return; const asked = performance.now(); while (conferenceArriving !== null && performance.now() - Math.max(asked, lastWordAt) < 60_000) { await new Promise((r) => setTimeout(r, 50)); } } async function commandLoop() { await arrival(); for (;;) { if (mode.kind === "chat") return; const line = (await ask("> ")).trim(); if (mode.kind === "chat") return; const [cmd, ...restWords] = line.split(" "); const restText = restWords.join(" "); switch ((cmd ?? "").toLowerCase()) { case "": break; case "?": case "help": help(); break; case "who": await arrived("lobby"); showLobby(); break; case "shelf": case "ls": await arrived("shelf"); showShelf(); break; case "wall": await arrived("wall"); showWall(); break; case "confs": await arrived("confs"); showConfs(); break; case "icons": for (const n of icons.keys()) paintIcon(n); break; // Answered when the file starts to come, or with "?". It comes down // while the next command is read, and the end of the input waits for it. case "get": await oneFileAtATime(); awaitingFile = true; ws?.sendText(`GET ${restWords[0] ?? ""}`); await underIt(() => awaitingFile); break; // Longer than the wall or a post takes is said here, before anything // is sent, as the animals wall does. Nothing is cut short: the board // used to cut these without a word, and the rest was just gone. case "say": { const n = restText.trim().length; if (n > MAX_ONELINER) { say(` that is ${n} characters. the wall takes ${MAX_ONELINER} at most, so none of it was sent.`); break; } answerFor = "SAY"; ws?.sendText(`SAY\t${restText}`); await underIt(() => answerFor !== null); break; } case "read": { reading = restWords[0] ?? ""; conferenceArriving = reading; ws?.sendText(`OPEN\t${reading}`); await conferenceArrived(); break; } case "post": { const words = restWords.slice(1).join(" "); const n = words.trim().length; if (n > MAX_BODY) { say(` that is ${n} characters. a post takes ${MAX_BODY} at most, so none of it was sent.`); break; } answerFor = "POST"; ws?.sendText(`POST\t${restWords[0] ?? ""}\t-\t${words}`); await underIt(() => answerFor !== null); break; } case "animals": { // Reading only. Words after it are not written anywhere: said so, // rather than put on the wall by a caller who meant to look. if (restText.trim() !== "") { say(" animals shows the wall. to write on it: animal "); break; } animalsArriving = true; animalsShown = 0; animalsSignup = ""; ws?.sendText("ANIMALS"); // Typed ahead down a pipe, the next command waits for the wall. if (!process.stdin.isTTY) { const started = performance.now(); while (animalsArriving && performance.now() - started < 60_000) await new Promise((r) => setTimeout(r, 50)); } break; } case "animal": { const text = restText.trim(); if (text === "") { say(" animal writes about the animals you live with. animals shows the wall."); break; } // Said here, before anything is sent. Nothing is cut short. if (text.length > MAX_ANIMALS) { say(` that is ${text.length} characters. the animals wall takes ${MAX_ANIMALS} at most, so none of it was sent.`); break; } answerFor = "ANIMALS"; ws?.sendText(`ANIMALS-SAY\t${text}`); await underIt(() => answerFor !== null); break; } case "made": { // Reading only. Nothing typed here goes into it, and words after it // are not sent anywhere: said so, rather than sent as something else. if (restText.trim() !== "") { say(" made shows How It Was Made. it is read only: nothing typed here goes into it."); break; } madeArriving = true; ws?.sendText("MADE"); // Typed ahead down a pipe, the next command waits for the document. if (!process.stdin.isTTY) { const started = performance.now(); while (madeArriving && performance.now() - started < 120_000) await new Promise((r) => setTimeout(r, 50)); } break; } // It rings them, and that is all. From a keyboard you go on while it // rings. Typed ahead down a pipe, the next line waits to hear how it went. case "chat": phoneAsked = true; ws?.sendText(`CHAT\t${restText}`); await answered(); break; // The only thing that answers a ring: the word, on its own, typed once // it has rung. Nothing typed before it rang, and nothing else. case "pickup": case "ata": { if (restText.trim() !== "") { say(dim(" ? for help")); break; } if (rungBy === null) { say(dim(" nothing is ringing.")); break; } if (readBeforeRing) { say(dim(" that pickup was typed before it rang. type pickup to answer.")); break; } phoneAsked = true; ws?.sendText(`PICKUP\t${rungBy}`); await answered(); break; } // The Desk. The board sends its picture only to whoever has it, and // checks every one of these whatever this program thinks. case "desk": case "look": case "shelve": case "back": case "hello": case "wait": case "lockout": case "delete": case "letgo": { if (!atTheDesk) { say(dim(" ? for help")); break; } await deskCommand((cmd ?? "").toLowerCase(), restWords.filter((w) => w !== "")); break; } case "put": { // Each of these used to be "put " and nothing else, whatever // was wrong: a caller with a file called MY SUN.KNR tried it three // ways, heard the same line three times, and guessed the rest. const words = restWords.filter((w) => w !== ""); if (words.length === 0) { say(` put leaves a file from ${process.cwd()}: put SUNSET.KNR, say`); break; } if (words.length > 1) { const typed = path.basename(words.join(" ").replace(/["'\\]/g, "")); say(/\s/.test(typed) ? ` a name on the board is one word, with no spaces. rename it ${typed.replace(/\s+/g, "_").toUpperCase()}, say, and put that.` : " put takes one file, with no spaces anywhere in its path."); break; } const file = words[0] ; if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { say(` there is no file called ${path.basename(file)} in ${path.dirname(path.resolve(file))}`); break; } const lines = fs.readFileSync(file, "utf8").split(/\r?\n/); // One line goes as one frame, and the board hangs up on a frame over // its limit rather than read it. Said here, before a byte is sent. const long = lines.findIndex((l) => Buffer.byteLength(l, "utf8") > MAX_LINE_BYTES); if (long !== -1) { const size = Buffer.byteLength(lines[long] , "utf8").toLocaleString("en-US"); // An old Mac ends a line with a carriage return alone, and to // everybody else that is one very long line. say(lines[long] .includes("\r") ? ` not sent: ${path.basename(file)} ends its lines with a carriage return alone, the way an old Mac saves a file,` : ` not sent: line ${long + 1} of ${path.basename(file)} is ${size} bytes with no break in it,`); say(lines[long] .includes("\r") ? ` so line ${long + 1} is ${size} bytes to the board, which takes ${MAX_LINE_BYTES.toLocaleString("en-US")} at most. save it with ordinary line endings.` : ` and the board takes ${MAX_LINE_BYTES.toLocaleString("en-US")} at most on one line. break it into shorter lines.`); break; } const name = path.basename(file).toUpperCase(); ws?.sendText(`PUT\t${name}`); for (const l of lines) ws?.sendText(`PUTLINE\t${l}`); ws?.sendText("PUT-END"); awaitingPut = true; await underIt(() => awaitingPut); break; } case "bye": case "quit": case "exit": hangUp(); return; default: if (/^[A-Z][A-Z-]*$/.test(cmd ?? "")) ws?.sendText(line.replace(/\\t/g, "\t")); else say(dim(" ? for help")); } } } // --- off hook -------------------------------------------------------------------- if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.setEncoding("utf8"); process.stdin.on("data", (c) => onKey(String(c))); process.stdin.on("end", () => { stdinEnded = true; // A last line with no Enter after it is still a line. if (mode.kind === "line") { if (mode.buffer !== "") { onKey("\n"); return; } mode = { kind: "idle" }; void finishAndHangUp(); return; } if (typedAhead !== "" && !typedAhead.endsWith("\n")) typedAhead += "\n"; if (mode.kind === "chat") void finishAndHangUp(); }); process.stdin.resume(); /** This machine, which no outside line is needed to reach. */ const LOOPBACK = (host ) => host === "localhost" || host === "::1" || host === "[::1]" || /^127\./.test(host); /** * Dial 9 for an outside line. A sandbox that reaches the internet only * through a proxy says where it is in HTTPS_PROXY, and every other program * in it goes that way; this one used to try the board direct, hear nothing, * and say NO CARRIER. Now the call goes through the proxy, which is asked * for a tunnel to the board with an HTTP CONNECT, and TLS runs inside it. * It is still one socket to the board, and the board still meters it. This * machine, and anything NO_PROXY names, is dialled direct. */ function outsideLine() { const env = process.env; const raw = env["https_proxy"] || env["HTTPS_PROXY"] || ""; if (raw === "" || LOOPBACK(HOST)) return undefined; const host = HOST.toLowerCase(); for (const entry of (env["no_proxy"] || env["NO_PROXY"] || "").split(/[\s,]+/)) { const name = entry.toLowerCase().replace(/:\d+$/, "").replace(/^\*?\./, ""); if (name === "") continue; if (name === "*" || host === name || host.endsWith(`.${name}`)) return undefined; } try { return new URL(raw.includes("://") ? raw : `http://${raw}`); } catch { write(dim(` (HTTPS_PROXY is set, but not to an address this program can read, so it dials direct.)\n`)); return undefined; } } const PROXY = outsideLine(); /** The proxy, as it is said out loud: where it is, and never who you are to it. */ const PROXY_AT = PROXY === undefined ? "" : `${PROXY.hostname}:${PROXY.port || (PROXY.protocol === "https:" ? 443 : 80)}`; write(PROXY === undefined ? `ATDT${NUMBER}\n` : `ATDT9,${NUMBER}\n`); if (PROXY !== undefined) write(dim(` (9 for an outside line: through ${PROXY_AT})\n`)); try { // A modem dialled, waited, and gave up. It did not sit there forever. ws = await Promise.race([ connect(HOST, PORT, FROM === undefined ? "/" : `/?from=${encodeURIComponent(FROM)}`, { secure: SECURE, insecure: INSECURE, proxy: PROXY }), new Promise ((_, reject) => setTimeout(() => reject(new Error("no answer")), DIAL_TIMEOUT_MS)), ]); } catch (e) { const local = LOOPBACK(HOST); // What the outside line said, if it was the outside line that said no. // Otherwise, what is true for everybody: the board did not pick up. It may // be down, or it may be up and not taking this call (a fifth still dialling // from one address, or a door with too many at it), and from here those // sound the same. "Nothing answered ... is the board up?" told callers of // the real board something that was not so. The hint to start one is only // for a board on this machine, which is the only one npm run dev starts. const why = e instanceof Error && e.name === "OutsideLine" ? `${e.message}, at ${PROXY_AT}` : `the board at ${HOST}:${PORT} didn't pick up${PROXY === undefined ? "" : ` through the outside line at ${PROXY_AT}`}. try again in a little while` + `${local ? ". on this machine, is it running? npm run dev" : ""}`; write(`NO CARRIER ${dim(`(${why})`)}\n`); process.exit(1); } // Anything that came in with the answer, then everything after it, as the // page's harness does (src/testing/page.ts). The board's first frames can // share a read with the upgrade, and the modem hands those out before there // is anybody here to listen. CONNECT was lost that way, and with it the // carrier: no HERE was said, and the board hung up on somebody typing. for (const r of ws.messages()) onFrame(r); ws.subscribe(onFrame); void ws.whenClosed(24 * 3600_000).then(() => { // Put down on purpose: bye, or the input running out. Not a drop. if (hangingUp) return; // The line went, or the board hung up, partway through a file. leaveUnfinished("the line went with"); if (!busy) say(bold("\nNO CARRIER")); if (process.stdin.isTTY) process.stdin.setRawMode(false); process.exit(busy ? 2 : 0); });