viewable-one 0.0.0-stage → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +747 -0
  20. package/lib/codexApps.mjs +332 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +605 -0
  28. package/lib/devserver.mjs +659 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
package/lib/picker.mjs ADDED
@@ -0,0 +1,300 @@
1
+ // Arrow-key checkbox picker (brief 31, ruling 2026-09-12: "when selecting a
2
+ // model I prefer not to type anything: go up and down with arrows to select
3
+ // the right one or even multiple ones"). Step 1 of `glytch init` used to be a
4
+ // numbered list and a comma-separated answer; this is the checkbox list that
5
+ // replaces it for a real terminal.
6
+ //
7
+ // A PURE FUNCTION OF ITS STREAMS. No global terminal state, no dependency:
8
+ // `input`/`output` are injected so a test can drive it with a fake stream
9
+ // that emits key sequences and read back exactly what was painted, and so
10
+ // the non-TTY path in agent-selection.mjs never has to know this module
11
+ // exists. Paint goes through cli-output.mjs's helpers only (`bold`, `ok`,
12
+ // `warn`, `dim`), so `color: false` (NO_COLOR, a pipe, TERM=dumb) renders a
13
+ // plain `>` and `[x]` with no escapes at all - only the cursor-hide/show and
14
+ // redraw sequences remain, because those are structural, not decoration.
15
+ //
16
+ // RENDERS IN PLACE: hide the cursor once, then on every keypress move the
17
+ // cursor back up over the block just drawn and clear each line before
18
+ // repainting it. The block is always `items.length + 3` lines - the agent
19
+ // rows, a trailing "Continue" row, the empty-selection warning (blank when
20
+ // there is nothing to warn about), and a one-line legend - so the redraw
21
+ // math never has to account for a growing or shrinking frame.
22
+ //
23
+ // ENTER SELECTS (2026-09-12, real run): the user arrowed onto opencode and
24
+ // pressed Enter expecting to check it; Enter confirmed the whole picker
25
+ // instead, with only Claude checked, and there was no way back short of
26
+ // re-running init. The expectation is the rule now - Enter on an agent row
27
+ // toggles it, exactly like Space - so confirming needs its own row. "Continue"
28
+ // is that row: it carries the cursor like any other, has no checkbox, and is
29
+ // the only row where Enter finishes the picker.
30
+ import { bold, dim, ok, warn, wrap } from "./cli-output.mjs";
31
+
32
+ const HIDE_CURSOR = "\x1b[?25l";
33
+ const SHOW_CURSOR = "\x1b[?25h";
34
+ const up = (n) => `\x1b[${n}A`;
35
+ const CLEAR_LINE = "\x1b[2K";
36
+ // 79 code points: fits inside one 80-column row by default, unlike the
37
+ // wording this replaced (82 code points - one over). P1 (review
38
+ // 2026-09-12): draw() counted one physical terminal row per logical line,
39
+ // so the moment the legend itself wrapped at 80 columns the cursor-up count
40
+ // fell one row short on every redraw and the whole picker smeared. A
41
+ // narrower `columns` still wraps this (draw() below now measures physical
42
+ // rows rather than assuming one each), but the common case needs no wrap.
43
+ const LEGEND = "↑↓ move · Enter/Space select · a all · Enter on Continue to finish · Esc cancel";
44
+
45
+ // Strip the SGR escapes this module ever paints with before measuring -
46
+ // colour codes take zero columns on a real terminal. Every remaining code
47
+ // point then counts as one column; true for the arrows and middle dot this
48
+ // legend uses, not a general-purpose East-Asian-width table, but nothing
49
+ // this picker renders needs one.
50
+ const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
51
+ function displayWidth(text) {
52
+ return [...String(text).replace(ANSI_RE, "")].length;
53
+ }
54
+
55
+ // How many terminal rows one rendered line actually occupies, so draw()'s
56
+ // cursor-up count matches what the terminal did with it instead of
57
+ // assuming one array entry is always one row (P1).
58
+ function physicalRows(line, columns) {
59
+ return Math.max(1, Math.ceil(displayWidth(line) / columns));
60
+ }
61
+
62
+ /**
63
+ * @param {object} args
64
+ * @param {{key: string, label: string}[]} args.items - rows, in display order.
65
+ * @param {string[]} [args.checked] - keys checked at start.
66
+ * @param {NodeJS.ReadStream} [args.input]
67
+ * @param {NodeJS.WriteStream} [args.output]
68
+ * @param {boolean} [args.color]
69
+ * @param {number} [args.columns] - terminal width for redraw math (P1);
70
+ * defaults to `output.columns`, falling back to 80 for a pipe or a fake
71
+ * stream in a test that reports none.
72
+ * @returns {Promise<string[]|null>} the checked keys in `items` order, or
73
+ * `null` if the user cancelled with Escape, Ctrl-C, Ctrl-D, or stdin ending.
74
+ */
75
+ export function pickMany({
76
+ items, checked = [], input = process.stdin, output = process.stdout, color = false,
77
+ columns = output.columns || 80,
78
+ }) {
79
+ return new Promise((resolvePromise) => {
80
+ // The cursor ranges over the agent rows plus one more: `items.length`
81
+ // itself is the Continue row, kept as a plain index rather than a
82
+ // sentinel so Up/Down/j/k treat it exactly like any other row.
83
+ const continueIndex = items.length;
84
+ const rowCount = items.length + 1;
85
+ const state = { cursor: 0, checked: new Set(checked), warning: "" };
86
+ let drawn = 0;
87
+ let settled = false;
88
+
89
+ // Fake streams in tests have no setRawMode; skip raw mode rather than
90
+ // throw, per the module's own contract.
91
+ const canRaw = typeof input.setRawMode === "function";
92
+ const wasRaw = canRaw ? input.isRaw : undefined;
93
+
94
+ function renderLines() {
95
+ const lines = items.map((item, i) => {
96
+ const marker = i === state.cursor ? bold(">", { color }) : " ";
97
+ const box = state.checked.has(item.key) ? ok("[x]", { color }) : "[ ]";
98
+ return `${marker} ${box} ${item.label}`;
99
+ });
100
+ const continueMarker = state.cursor === continueIndex ? bold(">", { color }) : " ";
101
+ const continueLabel = state.checked.size > 0 ? bold("Continue", { color }) : dim("Continue", { color });
102
+ lines.push(`${continueMarker} ${continueLabel}`);
103
+ lines.push(state.warning ? warn(state.warning, { color }) : "");
104
+ // Wrapped explicitly into its own rows (rather than left to the
105
+ // terminal) so each pushed entry is already <= `columns` wide: draw()
106
+ // can then paint one CLEAR_LINE per entry and have it actually own
107
+ // the physical row it clears (P1).
108
+ for (const row of wrap(LEGEND, columns, "")) lines.push(dim(row, { color }));
109
+ return lines;
110
+ }
111
+
112
+ function draw() {
113
+ const lines = renderLines();
114
+ if (drawn > 0) output.write(up(drawn));
115
+ for (const line of lines) output.write(`\r${CLEAR_LINE}${line}\n`);
116
+ drawn = lines.reduce((total, line) => total + physicalRows(line, columns), 0);
117
+ }
118
+
119
+ // P2: a bare ESC, or ESC + "[" with nothing after it yet, held here when
120
+ // it lands at the very end of a `data` chunk - it might be the first
121
+ // byte(s) of a CSI arrow sequence split across two chunks by a slow pty
122
+ // (reproduced by hand 2026-09-12), or it might really be the Escape key
123
+ // on its own. `escapeTimer` decides which: cleared the moment another
124
+ // chunk arrives (so the arrow's remaining bytes complete it normally),
125
+ // and fired after 50ms of silence to treat the hold as Escape.
126
+ let pendingEscape = "";
127
+ let escapeTimer = null;
128
+
129
+ function clearEscapeTimer() {
130
+ if (escapeTimer) { clearTimeout(escapeTimer); escapeTimer = null; }
131
+ }
132
+
133
+ function cleanup() {
134
+ input.removeListener("data", onData);
135
+ input.removeListener("end", onEnd);
136
+ input.removeListener("close", onEnd);
137
+ process.removeListener("SIGINT", onSigint);
138
+ process.removeListener("SIGTERM", onSigterm);
139
+ process.removeListener("SIGHUP", onSighup);
140
+ clearEscapeTimer();
141
+ if (canRaw && wasRaw !== undefined) input.setRawMode(wasRaw);
142
+ // Adding the `data` listener below switches a real stdin into flowing
143
+ // mode; removing the listener does not by itself put it back to
144
+ // sleep. Left flowing, the process never sees an empty event loop and
145
+ // `glytch init` hangs after printing its last line until Ctrl-C - seen
146
+ // for real 2026-09-12. Pause explicitly, on every exit path (confirm,
147
+ // cancel, SIGINT) - they all funnel through here via `finish`.
148
+ if (typeof input.pause === "function") input.pause();
149
+ output.write(SHOW_CURSOR);
150
+ }
151
+
152
+ function finish(result) {
153
+ if (settled) return;
154
+ settled = true;
155
+ cleanup();
156
+ resolvePromise(result);
157
+ }
158
+
159
+ // A real SIGINT (raw mode disables the tty's own ISIG handling of ^C, so
160
+ // this fires only for a signal sent from outside - `kill -INT`, a parent
161
+ // process, Windows' console handler) must still leave the cursor shown.
162
+ // Cancelling is the right response either way: there is no "resume the
163
+ // picker" story for a signal that arrived out of band.
164
+ function onSigint() { finish(null); }
165
+
166
+ // P3: SIGTERM/SIGHUP used to reach no handler here at all, so `cleanup`
167
+ // never ran and a killed `glytch init` left the shell in raw mode with
168
+ // the cursor hidden. `finish` runs cleanup (and, via it, removes these
169
+ // very listeners) before resolving; re-raising afterwards restores the
170
+ // signal's own default action - now unopposed, because our listener is
171
+ // already gone - instead of this module silently swallowing a kill.
172
+ function onSigterm() { finish(null); process.kill(process.pid, "SIGTERM"); }
173
+ function onSighup() { finish(null); process.kill(process.pid, "SIGHUP"); }
174
+
175
+ // P3: stdin ending or closing out from under the picker (piped input
176
+ // exhausted, the terminal disconnected) used to leave the promise
177
+ // pending forever with the cursor hidden. Treat it exactly like Escape.
178
+ function onEnd() { finish(null); }
179
+
180
+ function toggleCursor() {
181
+ const itemKey = items[state.cursor].key;
182
+ if (state.checked.has(itemKey)) state.checked.delete(itemKey);
183
+ else state.checked.add(itemKey);
184
+ state.warning = "";
185
+ draw();
186
+ }
187
+
188
+ // A chunk is not a keystroke: a fast typist, a paste, or (proven while
189
+ // driving this by hand through a pty) a handful of printf'd keys can all
190
+ // land in one `data` event holding several key events back to back. Walk
191
+ // it token by token - a three-byte arrow sequence, or one character -
192
+ // instead of matching the whole chunk, or every key after the first is
193
+ // silently dropped.
194
+ function handleKey(key) {
195
+ if (settled) return;
196
+ // \x04 is Ctrl-D (EOF) in raw mode - it never reaches "data" as end of
197
+ // stream the way it would in cooked mode, so it must be read as a key
198
+ // like any other (P3).
199
+ if (key === "\x03" || key === "\x1b" || key === "\x04") { finish(null); return; }
200
+ if (key === "\r" || key === "\n") {
201
+ // Enter selects (the rule, 2026-09-12): on an agent row it toggles
202
+ // that row, same as Space. Only Enter on the Continue row confirms.
203
+ if (state.cursor !== continueIndex) { toggleCursor(); return; }
204
+ if (state.checked.size === 0) {
205
+ state.warning = "choose at least one";
206
+ draw();
207
+ return;
208
+ }
209
+ finish(items.filter((item) => state.checked.has(item.key)).map((item) => item.key));
210
+ return;
211
+ }
212
+ if (key === "\x1b[A" || key === "k") {
213
+ state.cursor = (state.cursor - 1 + rowCount) % rowCount;
214
+ state.warning = "";
215
+ draw();
216
+ return;
217
+ }
218
+ if (key === "\x1b[B" || key === "j") {
219
+ state.cursor = (state.cursor + 1) % rowCount;
220
+ state.warning = "";
221
+ draw();
222
+ return;
223
+ }
224
+ if (key === " ") {
225
+ // Continue has no checkbox; Space on it does nothing rather than
226
+ // guessing at a confirm it was never asked for.
227
+ if (state.cursor === continueIndex) return;
228
+ toggleCursor();
229
+ return;
230
+ }
231
+ if (key === "a" || key === "A") {
232
+ if (state.checked.size === items.length) state.checked.clear();
233
+ else for (const item of items) state.checked.add(item.key);
234
+ state.warning = "";
235
+ draw();
236
+ return;
237
+ }
238
+ // Anything else (a stray escape sequence, an unmapped letter) is
239
+ // ignored rather than mis-parsed as a selection.
240
+ }
241
+
242
+ // P2 (confirmed, reproduced): a slow pty can split a 3-byte CSI arrow
243
+ // across two "data" events, most often right after the ESC byte. The old
244
+ // walk treated a trailing bare \x1b as a complete, standalone Escape the
245
+ // instant it saw it - cancelling the whole picker on the first half of
246
+ // an arrow key. Held bytes at the end of a chunk are stashed in
247
+ // `pendingEscape` and given 50ms to be completed by the next chunk
248
+ // before `flushPending` resolves them as ordinary keys (a lone \x1b is
249
+ // still Escape; anything else is still ignored).
250
+ function flushPending() {
251
+ escapeTimer = null;
252
+ const held = pendingEscape;
253
+ pendingEscape = "";
254
+ for (const ch of held) {
255
+ if (settled) break;
256
+ handleKey(ch);
257
+ }
258
+ }
259
+
260
+ function onData(chunk) {
261
+ clearEscapeTimer();
262
+ const data = pendingEscape + chunk.toString("utf8");
263
+ pendingEscape = "";
264
+ let i = 0;
265
+ while (i < data.length && !settled) {
266
+ // The only multi-byte sequences this picker knows are the 3-byte
267
+ // CSI arrows; a bare \x1b not followed by `[A`/`[B` is the Escape
268
+ // key on its own and cancels, exactly as it always did - UNLESS the
269
+ // chunk simply ran out before the sequence could finish, handled
270
+ // below.
271
+ if (data[i] === "\x1b" && data[i + 1] === "[" && (data[i + 2] === "A" || data[i + 2] === "B")) {
272
+ handleKey(data.slice(i, i + 3));
273
+ i += 3;
274
+ continue;
275
+ }
276
+ if (data[i] === "\x1b" && (i === data.length - 1 || (data[i + 1] === "[" && i + 1 === data.length - 1))) {
277
+ pendingEscape = data.slice(i);
278
+ break;
279
+ }
280
+ handleKey(data[i]);
281
+ i += 1;
282
+ }
283
+ // Deliberately NOT unref'd: a held ESC is a real decision still
284
+ // waiting to be made (Escape, or the rest of an arrow), and the
285
+ // process should wait the 50ms for it rather than being free to exit
286
+ // with the picker's promise never settled.
287
+ if (pendingEscape && !settled) escapeTimer = setTimeout(flushPending, 50);
288
+ }
289
+
290
+ process.on("SIGINT", onSigint);
291
+ process.on("SIGTERM", onSigterm);
292
+ process.on("SIGHUP", onSighup);
293
+ if (canRaw) input.setRawMode(true);
294
+ input.on("data", onData);
295
+ input.on("end", onEnd);
296
+ input.on("close", onEnd);
297
+ output.write(HIDE_CURSOR);
298
+ draw();
299
+ });
300
+ }
@@ -0,0 +1,60 @@
1
+ // WHAT MACHINE THE RUNNER IS ON, in the one place the app can read it.
2
+ //
3
+ // Brief 21 session 5 (2026-09-07), rulings 3A and 6C. Two things need
4
+ // this and neither can guess:
5
+ //
6
+ // - THE APP MUST NOT GUESS FROM `navigator`. The words a spawn prompt
7
+ // carries for starting a detached dev server are POSIX on macOS/Linux and
8
+ // PowerShell on Windows (ruling 3A), and the session runs where the RUNNER
9
+ // is - which since brief 19 need not be where the browser is. A board open
10
+ // in Chrome on a Mac, linked to a folder on a Windows box through a
11
+ // synced directory, would be told `setsid` by a `navigator.platform`
12
+ // sniff and hand the session a command it cannot run. So the runner says
13
+ // what it is, in the mark file it already writes every beat, and the app
14
+ // reads it there or writes the POSIX words it always wrote.
15
+ //
16
+ // - WSL IS NOT WINDOWS AND SAYING SO IS THE POINT (ruling 6C: both count,
17
+ // and doctor names which). Node inside WSL reports `linux`, which is
18
+ // correct and is also exactly what the words need: `setsid` works there,
19
+ // `Start-Process` does not. So `wsl` is reported as its own name rather
20
+ // than folded into either - a consumer that only knows `win32` treats it
21
+ // as the Linux it is, and a consumer that wants to NAME the environment
22
+ // (doctor) has the fact.
23
+ //
24
+ // PURE AND INJECTABLE, the same shape as lib/windows-command.mjs beside it:
25
+ // nothing here reads the real platform or the real filesystem unless the
26
+ // caller lets the defaults stand, so the WSL branch is proven on a Mac.
27
+
28
+ import { readFileSync } from "node:fs";
29
+
30
+ /** Linux's own record of the kernel it is running. Under WSL 1 and WSL 2 this
31
+ * names Microsoft; on a real Linux box it does not. It is the check every
32
+ * other tool uses, and it costs one small read at start rather than a
33
+ * shell-out. */
34
+ export const PROC_VERSION = "/proc/version";
35
+
36
+ /** The runner's platform, as the mark file spells it.
37
+ *
38
+ * Returns `process.platform` (`darwin`, `win32`, `linux`, ...) except inside
39
+ * WSL, where it returns `"wsl"`. Never throws: a `/proc/version` that cannot
40
+ * be read is a machine that is not WSL as far as anyone here is concerned. */
41
+ export function runnerPlatform({ platform = process.platform, readFile = readFileSync } = {}) {
42
+ if (platform !== "linux") return platform;
43
+ try {
44
+ if (/microsoft/i.test(String(readFile(PROC_VERSION, "utf8")))) return "wsl";
45
+ } catch {
46
+ // No /proc/version, or no permission to read it. Plain Linux.
47
+ }
48
+ return platform;
49
+ }
50
+
51
+ /** What the mark file gains, in the shape `markExtra()` returns.
52
+ *
53
+ * A separate function from `runnerPlatform` so the two callers
54
+ * (lib/multiwatch.mjs's daemon and spawn-runner.mjs's hand-started watcher)
55
+ * spread one object rather than each spelling the key, which is how `version`
56
+ * and `agents` already reach the mark. Computed once per beat and cheap: the
57
+ * `/proc/version` read only happens on Linux. */
58
+ export function platformMark(options = {}) {
59
+ return { platform: runnerPlatform(options) };
60
+ }
@@ -0,0 +1,129 @@
1
+ /** Fixed-origin preview proxy. Every HTTP request and WebSocket upgrade is authorized. */
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+
5
+ export function previewOrigin(value) {
6
+ const url = new URL(value);
7
+ if (!["http:", "https:"].includes(url.protocol) || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
8
+ || url.username || url.password) throw new Error("A preview must target a loopback HTTP dev server.");
9
+ return url;
10
+ }
11
+
12
+ const AUTH_COOKIE = "__glytch_preview";
13
+ const HOP_HEADERS = ["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"];
14
+ function headersFor(headers, target, upgrade = false) {
15
+ const out = { ...headers };
16
+ const nominated = String(out.connection ?? "").split(",").map((name) => name.trim().toLowerCase());
17
+ for (const name of [...HOP_HEADERS, ...nominated]) delete out[name];
18
+ // Gateway credentials must never reach application code or its logs.
19
+ delete out["x-glytch-preview-grant"];
20
+ if (out.cookie) {
21
+ out.cookie = out.cookie.split(";").filter((part) => part.trim().split("=", 1)[0] !== AUTH_COOKIE).join(";");
22
+ if (!out.cookie.trim()) delete out.cookie;
23
+ }
24
+ out.host = target.host;
25
+ if (upgrade) { out.connection = "Upgrade"; out.upgrade = "websocket"; }
26
+ return out;
27
+ }
28
+ function responseHeaders(headers) {
29
+ const out = { ...headers };
30
+ const nominated = String(out.connection ?? "").split(",").map((name) => name.trim().toLowerCase());
31
+ for (const name of [...HOP_HEADERS, ...nominated]) delete out[name];
32
+ if (out["set-cookie"]) out["set-cookie"] = out["set-cookie"].filter((cookie) => cookie.split("=", 1)[0].trim() !== AUTH_COOKIE);
33
+ out["cache-control"] = "no-store";
34
+ return out;
35
+ }
36
+ const validPath = (path) => typeof path === "string" && path.startsWith("/") && !path.startsWith("//") && !/[\r\n]/.test(path);
37
+
38
+ // Existing callers can return true. Scoped grants return an expiry so open
39
+ // streams and WebSockets cannot outlive membership's bounded lease.
40
+ function expireConnection(authorization, target) {
41
+ if (typeof authorization !== "object" || !Number.isFinite(authorization.expiresAt)) return;
42
+ const timer = setTimeout(() => target.destroy(), Math.max(0, authorization.expiresAt - Date.now()));
43
+ timer.unref?.();
44
+ target.once("close", () => clearTimeout(timer));
45
+ }
46
+
47
+ /** The authorization callback is mandatory; public transport never implies access. */
48
+ export async function createPreviewGateway({ target: value, authorize, handleAuth, timeoutMs = 30_000, maxConnections = 128, requestTimeoutMs = 15_000 }) {
49
+ const target = previewOrigin(value);
50
+ if (typeof authorize !== "function") throw new Error("Preview authorization is required.");
51
+ const transport = target.protocol === "https:" ? https : http;
52
+ const sockets = new Set();
53
+ const upstreams = new Set();
54
+ let closed = false;
55
+ const server = http.createServer(async (req, res) => {
56
+ try {
57
+ if (closed || !validPath(req.url)) { res.writeHead(400); res.end(); return; }
58
+ if (handleAuth && await handleAuth(req, res)) return;
59
+ const authorization = await authorize(req);
60
+ if (!authorization) { res.writeHead(403, { "cache-control": "no-store" }); res.end("Preview access is required."); return; }
61
+ if (closed || res.destroyed) return;
62
+ expireConnection(authorization, res);
63
+ const upstream = transport.request({ protocol: target.protocol, hostname: target.hostname.replace(/^\[|\]$/g, ""), port: target.port,
64
+ method: req.method, path: req.url, headers: headersFor(req.headers, target) }, (reply) => {
65
+ res.writeHead(reply.statusCode ?? 502, responseHeaders(reply.headers));
66
+ reply.pipe(res);
67
+ });
68
+ upstreams.add(upstream);
69
+ upstream.on("close", () => upstreams.delete(upstream));
70
+ upstream.setTimeout(timeoutMs, () => upstream.destroy(new Error("Preview upstream timed out.")));
71
+ upstream.on("error", () => { if (!res.headersSent) res.writeHead(502, { "cache-control": "no-store" }); res.end("The dev server is unavailable."); });
72
+ res.on("close", () => upstream.destroy());
73
+ req.pipe(upstream);
74
+ } catch {
75
+ if (!res.headersSent) res.writeHead(403, { "cache-control": "no-store" });
76
+ res.end("Preview access could not be verified.");
77
+ }
78
+ });
79
+ // Bound unauthenticated slow clients before grant verification or proxying.
80
+ server.maxConnections = maxConnections;
81
+ server.requestTimeout = requestTimeoutMs;
82
+ server.headersTimeout = Math.min(10_000, requestTimeoutMs);
83
+ server.keepAliveTimeout = 5_000;
84
+ server.maxRequestsPerSocket = 100;
85
+ server.setTimeout(requestTimeoutMs, (socket) => socket.destroy());
86
+ server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
87
+ server.on("upgrade", async (req, socket, head) => {
88
+ const refuse = () => socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
89
+ try {
90
+ if (closed || !validPath(req.url) || String(req.headers.upgrade).toLowerCase() !== "websocket") { refuse(); return; }
91
+ const authorization = await authorize(req);
92
+ if (!authorization) { refuse(); return; }
93
+ if (closed || socket.destroyed) return;
94
+ expireConnection(authorization, socket);
95
+ const upstream = transport.request({ protocol: target.protocol, hostname: target.hostname.replace(/^\[|\]$/g, ""), port: target.port,
96
+ method: "GET", path: req.url, headers: headersFor(req.headers, target, true) });
97
+ upstreams.add(upstream);
98
+ upstream.on("close", () => upstreams.delete(upstream));
99
+ upstream.setTimeout(timeoutMs, () => upstream.destroy());
100
+ socket.on("close", () => upstream.destroy());
101
+ upstream.on("error", () => socket.destroy());
102
+ upstream.on("response", () => socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"));
103
+ upstream.on("upgrade", (reply, peer, peerHead) => {
104
+ peer.setTimeout(0); socket.setTimeout(0);
105
+ if (closed || socket.destroyed) { peer.destroy(); return; }
106
+ sockets.add(peer); peer.on("close", () => { sockets.delete(peer); socket.destroy(); });
107
+ socket.on("close", () => peer.destroy()); peer.on("error", () => socket.destroy()); socket.on("error", () => peer.destroy());
108
+ const headers = responseHeaders(reply.headers);
109
+ headers.connection = "Upgrade"; headers.upgrade = "websocket";
110
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(headers).flatMap(([key, values]) => (Array.isArray(values) ? values : [values]).map((v) => `${key}: ${v}\r\n`)).join("")}\r\n`);
111
+ if (peerHead.length) socket.write(peerHead);
112
+ if (head.length) peer.write(head);
113
+ socket.pipe(peer); peer.pipe(socket);
114
+ });
115
+ upstream.end();
116
+ } catch { refuse(); }
117
+ });
118
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
119
+ return {
120
+ origin: `http://127.0.0.1:${server.address().port}`,
121
+ close: async () => {
122
+ if (closed) return;
123
+ closed = true;
124
+ for (const request of upstreams) request.destroy();
125
+ for (const socket of sockets) socket.destroy();
126
+ await new Promise((resolve) => server.close(resolve));
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,85 @@
1
+ /** Outbound transport only. Callers expose an authorized gateway, never a raw dev port. */
2
+ import { spawn as spawnProcess } from "node:child_process";
3
+ import { previewOrigin } from "./previewGateway.mjs";
4
+ import { ensureCloudflared } from "./cloudflared.mjs";
5
+ import { killTree } from "./kill.mjs";
6
+
7
+ export function quickTunnelUrl(text) {
8
+ return /https:\/\/[a-z0-9]+(?:-[a-z0-9]+)*\.trycloudflare\.com(?=[\s/]|$)/i.exec(text)?.[0] ?? null;
9
+ }
10
+
11
+ export async function startQuickPreviewTunnel(gatewayOrigin, {
12
+ bin = process.env.PM_CLOUDFLARED_BIN,
13
+ ensureBinary = ensureCloudflared,
14
+ spawn = spawnProcess,
15
+ timeoutMs = 30_000,
16
+ kill = killTree,
17
+ signal,
18
+ } = {}) {
19
+ const local = previewOrigin(gatewayOrigin);
20
+ if (local.pathname !== "/" || local.search || local.hash) throw new Error("The tunnel requires a gateway origin.");
21
+ if (signal?.aborted) throw new Error("Preview sharing was stopped.");
22
+ if (!bin) bin = await ensureBinary({ signal });
23
+ if (signal?.aborted) throw new Error("Preview sharing was stopped.");
24
+ const child = spawn(bin, ["tunnel", "--no-autoupdate", "--url", local.origin, "--loglevel", "info"], {
25
+ stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
26
+ });
27
+ let exited = false;
28
+ let stopped = false;
29
+ let stopPromise;
30
+ child.once("exit", () => { exited = true; });
31
+ child.once("error", () => { exited = true; });
32
+ const stop = () => {
33
+ if (stopPromise) return stopPromise;
34
+ stopped = true;
35
+ stopPromise = (async () => {
36
+ signal?.removeEventListener("abort", abort);
37
+ if (exited || !child.pid) return;
38
+ try { await kill(child.pid); } catch { /* child may already have exited */ }
39
+ if (!exited) {
40
+ await new Promise((resolve) => {
41
+ const timer = setTimeout(resolve, 1_000);
42
+ child.once("exit", () => { clearTimeout(timer); resolve(); });
43
+ });
44
+ }
45
+ if (!exited) { try { await kill(child.pid, { force: true }); } catch { /* already gone */ } }
46
+ })();
47
+ return stopPromise;
48
+ };
49
+ let cancelStart;
50
+ const abort = () => { cancelStart?.(); void stop(); };
51
+ signal?.addEventListener("abort", abort, { once: true });
52
+ try {
53
+ const url = await new Promise((resolve, reject) => {
54
+ let tail = "";
55
+ const timeout = setTimeout(() => finish(new Error("The preview tunnel did not connect. Check the network and retry.")), timeoutMs);
56
+ const onError = (error) => finish(new Error(error.code === "ENOENT"
57
+ ? "Shared previews need cloudflared installed on your computer. Install it once, then sharing starts on its own."
58
+ : "The preview tunnel could not start."));
59
+ const onExit = () => finish(new Error("The preview tunnel stopped before it connected."));
60
+ const onData = (chunk) => {
61
+ tail = (tail + chunk.toString()).slice(-8192);
62
+ const found = quickTunnelUrl(tail);
63
+ if (found) finish(null, found);
64
+ };
65
+ const finish = (error, found) => {
66
+ cancelStart = undefined;
67
+ clearTimeout(timeout);
68
+ child.removeListener("error", onError); child.removeListener("exit", onExit);
69
+ child.stdout?.removeListener("data", onData); child.stderr?.removeListener("data", onData);
70
+ // Keep draining output without retaining URLs or user project logs.
71
+ child.stdout?.resume(); child.stderr?.resume();
72
+ if (error) reject(error); else resolve(found);
73
+ };
74
+ cancelStart = () => finish(new Error("Preview sharing was stopped."));
75
+ child.once("error", onError); child.once("exit", onExit);
76
+ child.stdout?.on("data", onData); child.stderr?.on("data", onData);
77
+ if (signal?.aborted) finish(new Error("Preview sharing was stopped."));
78
+ });
79
+ if (stopped || signal?.aborted || exited) throw new Error("Preview sharing was stopped.");
80
+ return { url, stop, alive: () => !stopped && !exited };
81
+ } catch (error) {
82
+ await stop();
83
+ throw error;
84
+ }
85
+ }