anvilsign in

collin/browser-terminal-extension

main / extension / lib / portless.js
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. */
26const PORTLESS_TLD = "localhost";
27
28/** DNS's limit, and portless's. See `sanitize`. */
29const 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 */
42function 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 */
65function 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 */
92function 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
104const Portless = { TLD: PORTLESS_TLD, MAX_LABEL, sanitize, split, owns };
105
106if (typeof module !== "undefined" && module.exports) {
107 module.exports = Portless;
108}