@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.
- package/dist/index.js +72 -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
|
|
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
|
-
|
|
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.
|
|
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": {
|
|
9
|
-
|
|
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": [
|
|
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": {
|
|
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
|
}
|