| 1 | // The `portless` convention, reimplemented so the panel can apply it offline. |
| 2 | // |
| 3 | // portless(1) is a local reverse proxy that takes the port out of the URL: a |
| 4 | // dev server started under it gets a random port and a stable name, and you |
| 5 | // reach it at `https://<name>.localhost`. The name is inferred from the |
| 6 | // project — `package.json`'s `name` with any scope stripped, else the git |
| 7 | // root's directory name, else the working directory's — lowercased and beaten |
| 8 | // into a DNS label. In a git worktree the branch goes in front of it, so a |
| 9 | // second checkout of the same project is `https://<branch>.<project>.localhost` |
| 10 | // and does not collide with the first. |
| 11 | // |
| 12 | // The panel wants the question the other way round: a browser tab is sitting on |
| 13 | // https://mdlab.localhost, and it wants to know which tmux session that page |
| 14 | // belongs to. The daemon already tells us each session's working directory, so |
| 15 | // sanitizing those few names the way portless does and comparing labels answers |
| 16 | // it without a subprocess, without reading ~/.portless/routes.json, and without |
| 17 | // portless being installed or even running. |
| 18 | // |
| 19 | // What that costs, being a name match rather than a lookup: a project whose |
| 20 | // `package.json` name is not its directory name is invisible here, because the |
| 21 | // panel cannot read `package.json` — the pin menu is the answer for those. Only |
| 22 | // `.localhost` is understood; portless's LAN (`.local`) and tunnel hostnames |
| 23 | // are somebody else's machine's idea of a name. |
| 24 | |
| 25 | /** The suffix portless hangs every route off. */ |
| 26 | const PORTLESS_TLD = "localhost"; |
| 27 | |
| 28 | /** DNS's limit, and portless's. See `sanitize`. */ |
| 29 | const MAX_LABEL = 63; |
| 30 | |
| 31 | /** |
| 32 | * A name as portless would spell it in a hostname. |
| 33 | * |
| 34 | * Longer than a DNS label allows, portless truncates and appends six hex |
| 35 | * characters of a sha256 — which we cannot reproduce without hashing, so names |
| 36 | * that long simply never match here. They are rare enough to be worth the |
| 37 | * silence rather than an async detection path. |
| 38 | * |
| 39 | * @param {string} name |
| 40 | * @returns {string} |
| 41 | */ |
| 42 | function sanitize(name) { |
| 43 | if (!name) return ""; |
| 44 | const label = name |
| 45 | .toLowerCase() |
| 46 | .replace(/[^a-z0-9-]/g, "-") |
| 47 | .replace(/-{2,}/g, "-") |
| 48 | .replace(/^-+|-+$/g, ""); |
| 49 | return label.length > MAX_LABEL ? "" : label; |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * A portless hostname split into the parts that carry meaning, or null when the |
| 54 | * host is not one. |
| 55 | * |
| 56 | * `mdlab.localhost` -> `{ name: "mdlab", prefix: "" }` |
| 57 | * `feat-x.mdlab.localhost` -> `{ name: "mdlab", prefix: "feat-x" }` |
| 58 | * |
| 59 | * `.localhost` is the whole test for whether this is local: RFC 6761 reserves |
| 60 | * it, so a hostname ending in it resolves to this machine and nowhere else. |
| 61 | * |
| 62 | * @param {string} hostname |
| 63 | * @returns {{ name: string, prefix: string } | null} |
| 64 | */ |
| 65 | function split(hostname) { |
| 66 | if (!hostname) return null; |
| 67 | const labels = hostname.toLowerCase().split("."); |
| 68 | if (labels.length < 2 || labels[labels.length - 1] !== PORTLESS_TLD) return null; |
| 69 | const name = labels[labels.length - 2]; |
| 70 | if (!name) return null; |
| 71 | // Only ever one prefix label, but a deeper host is not a reason to give up on |
| 72 | // the two labels that do mean something. |
| 73 | return { name, prefix: labels[labels.length - 3] ?? "" }; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Whether `name` — a session's name, or its working directory's — is the |
| 78 | * project a portless hostname points at. |
| 79 | * |
| 80 | * The plain case is one comparison. The worktree case cannot be, because the |
| 81 | * prefix is a *branch* and the session is a *directory*: what we can say is |
| 82 | * that a worktree checked out for branch `feat-x` of project `app` is nearly |
| 83 | * always sitting in a directory called `feat-x`, `app-feat-x` or `feat-x-app`. |
| 84 | * A session that only answers to `app` is deliberately not a match for |
| 85 | * `feat-x.app.localhost` — that is the main checkout, a different session, and |
| 86 | * sending the terminal there would be a confident wrong answer. |
| 87 | * |
| 88 | * @param {string} hostname |
| 89 | * @param {string} name |
| 90 | * @returns {boolean} |
| 91 | */ |
| 92 | function owns(hostname, name) { |
| 93 | const route = split(hostname); |
| 94 | const label = sanitize(name); |
| 95 | if (!route || !label) return false; |
| 96 | if (!route.prefix) return label === route.name; |
| 97 | return ( |
| 98 | label === route.prefix || |
| 99 | label === `${route.name}-${route.prefix}` || |
| 100 | label === `${route.prefix}-${route.name}` |
| 101 | ); |
| 102 | } |
| 103 | |
| 104 | const Portless = { TLD: PORTLESS_TLD, MAX_LABEL, sanitize, split, owns }; |
| 105 | |
| 106 | if (typeof module !== "undefined" && module.exports) { |
| 107 | module.exports = Portless; |
| 108 | } |