@termfleet/lucarne 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +72 -2
  2. package/package.json +18 -5
package/dist/index.js CHANGED
@@ -9,6 +9,8 @@
9
9
  // • /sessions/:id/view* → reverse-proxied to lucarne's porthole
10
10
  // (so the console can proxy it over the tunnel)
11
11
  import http from "node:http";
12
+ import net from "node:net";
13
+ import { Server as IOServer } from "socket.io";
12
14
  const LUCARNE_URL = process.env.LUCARNE_URL ?? "http://127.0.0.1:7800";
13
15
  const LUCARNE_TOKEN = process.env.LUCARNE_TOKEN;
14
16
  const CONSOLE_URL = process.env.TERMFLEET_CONSOLE_URL ?? "http://127.0.0.1:7373";
@@ -19,6 +21,12 @@ const PROVIDER_KEY = encodeURIComponent(PUBLIC_URL);
19
21
  const windowIds = new Map();
20
22
  let nextWindowId = 1;
21
23
  let revision = 0;
24
+ function sessionIdForWindow(wid) {
25
+ for (const [sid, id] of windowIds)
26
+ if (id === wid)
27
+ return sid;
28
+ return undefined;
29
+ }
22
30
  async function lucarne(path) {
23
31
  const headers = {};
24
32
  if (LUCARNE_TOKEN)
@@ -67,12 +75,18 @@ async function snapshot() {
67
75
  return {
68
76
  epoch: "lucarne-bridge",
69
77
  instanceId: "lucarne-bridge",
70
- provider: "lucarne",
78
+ // termfleet's snapshot `provider` is a CLOSED enum (iterm|virtual-tmux|wezterm)
79
+ // validated by @termfleet/core — an unknown value is rejected ("unsupported
80
+ // provider") and the provider shows offline. There's no "browser"/"external"
81
+ // kind yet, so we present a valid one; the board still shows the "lucarne"
82
+ // label (from registration) and renders our iframe windows regardless of kind.
83
+ provider: "virtual-tmux",
71
84
  revision: ++revision,
72
85
  observedAt: new Date().toISOString(),
73
86
  displayBounds: { left: 0, top: 0, right: 1920, bottom: 1080, width: 1920, height: 1080 },
74
87
  windows,
75
- lifecycle: { pid: process.pid, panes: [] },
88
+ // @termfleet/core parseProviderSnapshot requires lifecycle with BOTH arrays
89
+ lifecycle: { panes: [], sessions: [] },
76
90
  };
77
91
  }
78
92
  // reverse-proxy /sessions/:id/view* → lucarne (inject the lucarne token here)
@@ -122,6 +136,62 @@ const server = http.createServer(async (req, res) => {
122
136
  res.end(JSON.stringify({ error: String(e.message ?? e) }));
123
137
  }
124
138
  });
139
+ // termfleet provider control channel. The frontend marks a provider "connected"
140
+ // only once its socket.io connection (path /control/socket.io) is up AND it has
141
+ // received a `provider:snapshot` with displayBounds — without this the provider
142
+ // shows offline / 0 windows. (Protocol owned by termfleet; see @termfleet/core.)
143
+ const io = new IOServer(server, {
144
+ path: "/control/socket.io",
145
+ transports: ["websocket"],
146
+ cors: { origin: true }, // access control is the console proxy's job
147
+ });
148
+ io.on("connection", (socket) => {
149
+ void snapshot().then((s) => socket.emit("provider:snapshot", s)).catch(() => { });
150
+ // closing a window destroys the underlying lucarne session
151
+ socket.on("window:close", async (p, ack) => {
152
+ const sid = typeof p?.id === "number" ? sessionIdForWindow(p.id) : undefined;
153
+ if (sid) {
154
+ const headers = {};
155
+ if (LUCARNE_TOKEN)
156
+ headers["authorization"] = `Bearer ${LUCARNE_TOKEN}`;
157
+ await fetch(`${LUCARNE_URL}/sessions/${sid}`, { method: "DELETE", headers }).catch(() => { });
158
+ }
159
+ ack?.({ ok: true });
160
+ });
161
+ // read-only for the rest: acknowledge so the UI never hangs on a command
162
+ for (const ev of ["window:create", "window:move", "display:resize", "terminal:input",
163
+ "agent:create", "agent-session:input", "agent-session:close",
164
+ "agent-session:subscribe", "agent-session:unsubscribe", "terminal:effect"]) {
165
+ socket.on(ev, (_p, ack) => ack?.({ ok: true }));
166
+ }
167
+ });
168
+ // push live snapshots so new/closed sessions appear on the board
169
+ setInterval(() => { void snapshot().then((s) => io.emit("provider:snapshot", s)).catch(() => { }); }, 3000);
170
+ // Proxy the porthole WebSocket (/sessions/:id/view/ws) through to lucarne, raw.
171
+ // socket.io owns /control/socket.io; we only act on the porthole ws path.
172
+ server.on("upgrade", (req, socket, head) => {
173
+ const pathname = new URL(req.url ?? "/", "http://x").pathname;
174
+ if (!/^\/sessions\/.+\/view\/ws$/.test(pathname))
175
+ return; // leave /control/socket.io to socket.io
176
+ const target = new URL(LUCARNE_URL);
177
+ const qs = LUCARNE_TOKEN ? `?token=${encodeURIComponent(LUCARNE_TOKEN)}` : "";
178
+ const upstream = net.connect(Number(target.port || 80), target.hostname, () => {
179
+ const lines = [`GET ${pathname}${qs} HTTP/1.1`];
180
+ for (const [k, v] of Object.entries(req.headers)) {
181
+ if (k.toLowerCase() === "host")
182
+ continue;
183
+ lines.push(`${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
184
+ }
185
+ lines.push(`host: ${target.host}`, "", "");
186
+ upstream.write(lines.join("\r\n"));
187
+ if (head?.length)
188
+ upstream.write(head);
189
+ socket.pipe(upstream);
190
+ upstream.pipe(socket);
191
+ });
192
+ upstream.on("error", () => socket.destroy());
193
+ socket.on("error", () => upstream.destroy());
194
+ });
125
195
  async function registerWithConsole() {
126
196
  for (let i = 0; i < 5; i++) {
127
197
  try {
package/package.json CHANGED
@@ -1,26 +1,39 @@
1
1
  {
2
2
  "name": "@termfleet/lucarne",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Optional bridge that surfaces a lucarne browser-session engine inside a termfleet console as iframe-porthole windows. termfleet and lucarne stay independent; this is the only module that knows both.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Aaron Volter",
8
- "engines": { "node": ">=22" },
9
- "bin": { "termfleet-lucarne": "dist/index.js" },
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "bin": {
12
+ "termfleet-lucarne": "dist/index.js"
13
+ },
10
14
  "main": "dist/index.js",
11
15
  "types": "dist/index.d.ts",
12
- "files": ["dist", "README.md"],
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
13
20
  "scripts": {
14
21
  "build": "tsc",
15
22
  "prepublishOnly": "npm run build"
16
23
  },
24
+ "dependencies": {
25
+ "socket.io": "^4.8.0"
26
+ },
17
27
  "peerDependencies": {
18
28
  "lucarne": ">=0.3.0"
19
29
  },
20
30
  "peerDependenciesMeta": {
21
- "lucarne": { "optional": true }
31
+ "lucarne": {
32
+ "optional": true
33
+ }
22
34
  },
23
35
  "devDependencies": {
36
+ "@termfleet/core": "^0.1.0",
24
37
  "@types/node": "^22.10.0",
25
38
  "typescript": "^5.7.0"
26
39
  }