pubblue 0.3.0 → 0.4.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.
- package/dist/chunk-56IKFMJ2.js +40 -0
- package/dist/chunk-77HFJKLW.js +342 -0
- package/dist/chunk-BV423NLA.js +57 -0
- package/dist/index.js +464 -68
- package/dist/tunnel-daemon-QBJSX4JM.js +7 -0
- package/dist/tunnel-daemon-entry.d.ts +2 -0
- package/dist/tunnel-daemon-entry.js +24 -0
- package/package.json +7 -5
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/lib/bridge-protocol.ts
|
|
2
|
+
var CONTROL_CHANNEL = "_control";
|
|
3
|
+
var CHANNELS = {
|
|
4
|
+
CHAT: "chat",
|
|
5
|
+
CANVAS: "canvas",
|
|
6
|
+
AUDIO: "audio",
|
|
7
|
+
MEDIA: "media",
|
|
8
|
+
FILE: "file"
|
|
9
|
+
};
|
|
10
|
+
var idCounter = 0;
|
|
11
|
+
function generateMessageId() {
|
|
12
|
+
const ts = Date.now().toString(36);
|
|
13
|
+
const seq = (idCounter++).toString(36);
|
|
14
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
15
|
+
return `${ts}-${seq}-${rand}`;
|
|
16
|
+
}
|
|
17
|
+
function encodeMessage(msg) {
|
|
18
|
+
return JSON.stringify(msg);
|
|
19
|
+
}
|
|
20
|
+
function decodeMessage(raw) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (parsed && typeof parsed.id === "string" && typeof parsed.type === "string") {
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
var MAX_TUNNEL_EXPIRY_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
32
|
+
var DEFAULT_TUNNEL_EXPIRY_MS = 24 * 60 * 60 * 1e3;
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
CONTROL_CHANNEL,
|
|
36
|
+
CHANNELS,
|
|
37
|
+
generateMessageId,
|
|
38
|
+
encodeMessage,
|
|
39
|
+
decodeMessage
|
|
40
|
+
};
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHANNELS,
|
|
3
|
+
CONTROL_CHANNEL,
|
|
4
|
+
decodeMessage,
|
|
5
|
+
encodeMessage
|
|
6
|
+
} from "./chunk-56IKFMJ2.js";
|
|
7
|
+
|
|
8
|
+
// src/lib/tunnel-daemon.ts
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as net from "net";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
var OFFER_TIMEOUT_MS = 1e4;
|
|
13
|
+
async function startDaemon(config) {
|
|
14
|
+
const { tunnelId, apiClient, socketPath, infoPath } = config;
|
|
15
|
+
const ndc = await import("node-datachannel");
|
|
16
|
+
const buffer = { messages: [] };
|
|
17
|
+
const startTime = Date.now();
|
|
18
|
+
let connected = false;
|
|
19
|
+
let pollingInterval = null;
|
|
20
|
+
let lastBrowserCandidateCount = 0;
|
|
21
|
+
let remoteDescriptionApplied = false;
|
|
22
|
+
const pendingRemoteCandidates = [];
|
|
23
|
+
const peer = new ndc.PeerConnection("agent", {
|
|
24
|
+
iceServers: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"]
|
|
25
|
+
});
|
|
26
|
+
const channels = /* @__PURE__ */ new Map();
|
|
27
|
+
const pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
|
|
28
|
+
function openDataChannel(name) {
|
|
29
|
+
const existing = channels.get(name);
|
|
30
|
+
if (existing) return existing;
|
|
31
|
+
const dc = peer.createDataChannel(name, { ordered: true });
|
|
32
|
+
setupChannel(name, dc);
|
|
33
|
+
return dc;
|
|
34
|
+
}
|
|
35
|
+
async function waitForChannelOpen(dc, timeoutMs = 5e3) {
|
|
36
|
+
if (dc.isOpen()) return;
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
let settled = false;
|
|
39
|
+
const timeout = setTimeout(() => {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
reject(new Error("DataChannel open timed out"));
|
|
43
|
+
}, timeoutMs);
|
|
44
|
+
dc.onOpen(() => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
clearTimeout(timeout);
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function setupChannel(name, dc) {
|
|
53
|
+
channels.set(name, dc);
|
|
54
|
+
dc.onMessage((data) => {
|
|
55
|
+
if (typeof data === "string") {
|
|
56
|
+
const msg = decodeMessage(data);
|
|
57
|
+
if (msg) {
|
|
58
|
+
if (msg.type === "binary" && !msg.data) {
|
|
59
|
+
pendingInboundBinaryMeta.set(name, msg);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
buffer.messages.push({ channel: name, msg, timestamp: Date.now() });
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
const pendingMeta = pendingInboundBinaryMeta.get(name);
|
|
66
|
+
if (pendingMeta) pendingInboundBinaryMeta.delete(name);
|
|
67
|
+
const binMsg = pendingMeta ? {
|
|
68
|
+
id: pendingMeta.id,
|
|
69
|
+
type: "binary",
|
|
70
|
+
data: data.toString("base64"),
|
|
71
|
+
meta: { ...pendingMeta.meta, size: data.length }
|
|
72
|
+
} : {
|
|
73
|
+
id: `bin-${Date.now()}`,
|
|
74
|
+
type: "binary",
|
|
75
|
+
data: data.toString("base64"),
|
|
76
|
+
meta: { size: data.length }
|
|
77
|
+
};
|
|
78
|
+
buffer.messages.push({ channel: name, msg: binMsg, timestamp: Date.now() });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
openDataChannel(CONTROL_CHANNEL);
|
|
83
|
+
openDataChannel(CHANNELS.CHAT);
|
|
84
|
+
openDataChannel(CHANNELS.CANVAS);
|
|
85
|
+
const localCandidates = [];
|
|
86
|
+
peer.onLocalCandidate((candidate, mid) => {
|
|
87
|
+
localCandidates.push(JSON.stringify({ candidate, sdpMid: mid }));
|
|
88
|
+
});
|
|
89
|
+
peer.onStateChange((state) => {
|
|
90
|
+
if (state === "connected") {
|
|
91
|
+
connected = true;
|
|
92
|
+
if (pollingInterval) {
|
|
93
|
+
clearInterval(pollingInterval);
|
|
94
|
+
pollingInterval = null;
|
|
95
|
+
}
|
|
96
|
+
} else if (state === "disconnected" || state === "failed") {
|
|
97
|
+
connected = false;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
peer.onDataChannel((dc) => {
|
|
101
|
+
setupChannel(dc.getLabel(), dc);
|
|
102
|
+
});
|
|
103
|
+
if (fs.existsSync(socketPath)) {
|
|
104
|
+
let stale = true;
|
|
105
|
+
try {
|
|
106
|
+
const raw = fs.readFileSync(infoPath, "utf-8");
|
|
107
|
+
const info = JSON.parse(raw);
|
|
108
|
+
process.kill(info.pid, 0);
|
|
109
|
+
stale = false;
|
|
110
|
+
} catch {
|
|
111
|
+
stale = true;
|
|
112
|
+
}
|
|
113
|
+
if (stale) {
|
|
114
|
+
try {
|
|
115
|
+
fs.unlinkSync(socketPath);
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
throw new Error(`Daemon already running (socket: ${socketPath})`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const ipcServer = net.createServer((conn) => {
|
|
123
|
+
let data = "";
|
|
124
|
+
conn.on("data", (chunk) => {
|
|
125
|
+
data += chunk.toString();
|
|
126
|
+
const newlineIdx = data.indexOf("\n");
|
|
127
|
+
if (newlineIdx === -1) return;
|
|
128
|
+
const line = data.slice(0, newlineIdx);
|
|
129
|
+
data = data.slice(newlineIdx + 1);
|
|
130
|
+
let request;
|
|
131
|
+
try {
|
|
132
|
+
request = JSON.parse(line);
|
|
133
|
+
} catch {
|
|
134
|
+
conn.write(`${JSON.stringify({ ok: false, error: "Invalid JSON" })}
|
|
135
|
+
`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
handleIpcRequest(request).then((response) => conn.write(`${JSON.stringify(response)}
|
|
139
|
+
`)).catch((err) => conn.write(`${JSON.stringify({ ok: false, error: String(err) })}
|
|
140
|
+
`));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
ipcServer.listen(socketPath);
|
|
144
|
+
const infoDir = path.dirname(infoPath);
|
|
145
|
+
if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
|
|
146
|
+
fs.writeFileSync(
|
|
147
|
+
infoPath,
|
|
148
|
+
JSON.stringify({ pid: process.pid, tunnelId, socketPath, startedAt: startTime })
|
|
149
|
+
);
|
|
150
|
+
async function cleanup() {
|
|
151
|
+
if (pollingInterval) clearInterval(pollingInterval);
|
|
152
|
+
for (const dc of channels.values()) dc.close();
|
|
153
|
+
peer.close();
|
|
154
|
+
ipcServer.close();
|
|
155
|
+
try {
|
|
156
|
+
fs.unlinkSync(socketPath);
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
fs.unlinkSync(infoPath);
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
await apiClient.close(tunnelId).catch(() => {
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async function shutdown() {
|
|
167
|
+
await cleanup();
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
process.on("SIGTERM", () => void shutdown());
|
|
171
|
+
process.on("SIGINT", () => void shutdown());
|
|
172
|
+
let offer;
|
|
173
|
+
try {
|
|
174
|
+
offer = await generateOffer(peer, OFFER_TIMEOUT_MS);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
177
|
+
await cleanup();
|
|
178
|
+
throw new Error(`Failed to generate WebRTC offer: ${message}`);
|
|
179
|
+
}
|
|
180
|
+
await apiClient.signal(tunnelId, { offer });
|
|
181
|
+
setTimeout(async () => {
|
|
182
|
+
if (localCandidates.length > 0) {
|
|
183
|
+
await apiClient.signal(tunnelId, { candidates: localCandidates }).catch(() => {
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}, 1e3);
|
|
187
|
+
let lastSentCandidateCount = 0;
|
|
188
|
+
const candidateInterval = setInterval(async () => {
|
|
189
|
+
if (localCandidates.length > lastSentCandidateCount) {
|
|
190
|
+
const newOnes = localCandidates.slice(lastSentCandidateCount);
|
|
191
|
+
lastSentCandidateCount = localCandidates.length;
|
|
192
|
+
await apiClient.signal(tunnelId, { candidates: newOnes }).catch(() => {
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}, 500);
|
|
196
|
+
setTimeout(() => clearInterval(candidateInterval), 3e4);
|
|
197
|
+
pollingInterval = setInterval(async () => {
|
|
198
|
+
try {
|
|
199
|
+
const tunnel = await apiClient.get(tunnelId);
|
|
200
|
+
if (tunnel.browserAnswer && !remoteDescriptionApplied) {
|
|
201
|
+
try {
|
|
202
|
+
const answer = JSON.parse(tunnel.browserAnswer);
|
|
203
|
+
peer.setRemoteDescription(answer.sdp, answer.type);
|
|
204
|
+
remoteDescriptionApplied = true;
|
|
205
|
+
while (pendingRemoteCandidates.length > 0) {
|
|
206
|
+
const next = pendingRemoteCandidates.shift();
|
|
207
|
+
if (!next) break;
|
|
208
|
+
try {
|
|
209
|
+
peer.addRemoteCandidate(next.candidate, next.sdpMid);
|
|
210
|
+
} catch {
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (tunnel.browserCandidates.length > lastBrowserCandidateCount) {
|
|
217
|
+
const newCandidates = tunnel.browserCandidates.slice(lastBrowserCandidateCount);
|
|
218
|
+
lastBrowserCandidateCount = tunnel.browserCandidates.length;
|
|
219
|
+
for (const c of newCandidates) {
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(c);
|
|
222
|
+
if (typeof parsed.candidate !== "string") continue;
|
|
223
|
+
const sdpMid = typeof parsed.sdpMid === "string" ? parsed.sdpMid : "0";
|
|
224
|
+
if (!remoteDescriptionApplied) {
|
|
225
|
+
pendingRemoteCandidates.push({ candidate: parsed.candidate, sdpMid });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
peer.addRemoteCandidate(parsed.candidate, sdpMid);
|
|
229
|
+
} catch {
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
}
|
|
235
|
+
}, 500);
|
|
236
|
+
async function handleIpcRequest(req) {
|
|
237
|
+
switch (req.method) {
|
|
238
|
+
case "write": {
|
|
239
|
+
const channel = req.params.channel || CHANNELS.CHAT;
|
|
240
|
+
const msg = req.params.msg;
|
|
241
|
+
const binaryBase64 = typeof req.params.binaryBase64 === "string" ? req.params.binaryBase64 : void 0;
|
|
242
|
+
const dc = channels.get(channel);
|
|
243
|
+
let targetDc = dc;
|
|
244
|
+
if (!targetDc) {
|
|
245
|
+
const newDc = openDataChannel(channel);
|
|
246
|
+
targetDc = newDc;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await waitForChannelOpen(targetDc);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
252
|
+
return { ok: false, error: `Channel "${channel}" not open: ${message}` };
|
|
253
|
+
}
|
|
254
|
+
if (msg.type === "binary" && binaryBase64) {
|
|
255
|
+
const payload = Buffer.from(binaryBase64, "base64");
|
|
256
|
+
targetDc.sendMessage(
|
|
257
|
+
encodeMessage({
|
|
258
|
+
...msg,
|
|
259
|
+
meta: {
|
|
260
|
+
...msg.meta || {},
|
|
261
|
+
size: payload.length
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
);
|
|
265
|
+
targetDc.sendMessageBinary(payload);
|
|
266
|
+
} else {
|
|
267
|
+
targetDc.sendMessage(encodeMessage(msg));
|
|
268
|
+
}
|
|
269
|
+
return { ok: true };
|
|
270
|
+
}
|
|
271
|
+
case "read": {
|
|
272
|
+
const channel = req.params.channel;
|
|
273
|
+
let msgs;
|
|
274
|
+
if (channel) {
|
|
275
|
+
msgs = buffer.messages.filter((m) => m.channel === channel);
|
|
276
|
+
buffer.messages = buffer.messages.filter((m) => m.channel !== channel);
|
|
277
|
+
} else {
|
|
278
|
+
msgs = [...buffer.messages];
|
|
279
|
+
buffer.messages = [];
|
|
280
|
+
}
|
|
281
|
+
return { ok: true, messages: msgs };
|
|
282
|
+
}
|
|
283
|
+
case "channels": {
|
|
284
|
+
const chList = [...channels.keys()].map((name) => ({
|
|
285
|
+
name,
|
|
286
|
+
direction: "bidi"
|
|
287
|
+
}));
|
|
288
|
+
return { ok: true, channels: chList };
|
|
289
|
+
}
|
|
290
|
+
case "status": {
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
connected,
|
|
294
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3),
|
|
295
|
+
channels: [...channels.keys()],
|
|
296
|
+
bufferedMessages: buffer.messages.length
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
case "close": {
|
|
300
|
+
void shutdown();
|
|
301
|
+
return { ok: true };
|
|
302
|
+
}
|
|
303
|
+
default:
|
|
304
|
+
return { ok: false, error: `Unknown method: ${req.method}` };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function generateOffer(peer, timeoutMs) {
|
|
309
|
+
return new Promise((resolve, reject) => {
|
|
310
|
+
let resolved = false;
|
|
311
|
+
const done = (sdp, type) => {
|
|
312
|
+
if (resolved) return;
|
|
313
|
+
resolved = true;
|
|
314
|
+
clearTimeout(timeout);
|
|
315
|
+
resolve(JSON.stringify({ sdp, type }));
|
|
316
|
+
};
|
|
317
|
+
peer.onLocalDescription((sdp, type) => {
|
|
318
|
+
done(sdp, type);
|
|
319
|
+
});
|
|
320
|
+
peer.onGatheringStateChange((state) => {
|
|
321
|
+
if (state === "complete" && !resolved) {
|
|
322
|
+
const desc = peer.localDescription();
|
|
323
|
+
if (desc) done(desc.sdp, desc.type);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
const timeout = setTimeout(() => {
|
|
327
|
+
if (resolved) return;
|
|
328
|
+
const desc = peer.localDescription();
|
|
329
|
+
if (desc) {
|
|
330
|
+
done(desc.sdp, desc.type);
|
|
331
|
+
} else {
|
|
332
|
+
resolved = true;
|
|
333
|
+
reject(new Error(`Timed out after ${timeoutMs}ms`));
|
|
334
|
+
}
|
|
335
|
+
}, timeoutMs);
|
|
336
|
+
peer.setLocalDescription();
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export {
|
|
341
|
+
startDaemon
|
|
342
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/lib/tunnel-api.ts
|
|
2
|
+
var TunnelApiClient = class {
|
|
3
|
+
constructor(baseUrl, apiKey) {
|
|
4
|
+
this.baseUrl = baseUrl;
|
|
5
|
+
this.apiKey = apiKey;
|
|
6
|
+
}
|
|
7
|
+
async request(path, options = {}) {
|
|
8
|
+
const url = new URL(path, this.baseUrl);
|
|
9
|
+
const res = await fetch(url, {
|
|
10
|
+
...options,
|
|
11
|
+
headers: {
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
14
|
+
...options.headers
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
let data;
|
|
18
|
+
try {
|
|
19
|
+
data = await res.json();
|
|
20
|
+
} catch {
|
|
21
|
+
data = {};
|
|
22
|
+
}
|
|
23
|
+
if (!res.ok) throw new Error(data.error || `Request failed: ${res.status}`);
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
async create(opts) {
|
|
27
|
+
return this.request("/api/v1/tunnels", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
body: JSON.stringify(opts)
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async get(tunnelId) {
|
|
33
|
+
const data = await this.request(
|
|
34
|
+
`/api/v1/tunnels/${encodeURIComponent(tunnelId)}`
|
|
35
|
+
);
|
|
36
|
+
return data.tunnel;
|
|
37
|
+
}
|
|
38
|
+
async list() {
|
|
39
|
+
const data = await this.request("/api/v1/tunnels/");
|
|
40
|
+
return data.tunnels;
|
|
41
|
+
}
|
|
42
|
+
async signal(tunnelId, opts) {
|
|
43
|
+
await this.request(`/api/v1/tunnels/${encodeURIComponent(tunnelId)}/signal`, {
|
|
44
|
+
method: "PATCH",
|
|
45
|
+
body: JSON.stringify(opts)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async close(tunnelId) {
|
|
49
|
+
await this.request(`/api/v1/tunnels/${encodeURIComponent(tunnelId)}`, {
|
|
50
|
+
method: "DELETE"
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
TunnelApiClient
|
|
57
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,19 +1,469 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
TunnelApiClient
|
|
4
|
+
} from "./chunk-BV423NLA.js";
|
|
5
|
+
import {
|
|
6
|
+
generateMessageId
|
|
7
|
+
} from "./chunk-56IKFMJ2.js";
|
|
2
8
|
|
|
3
9
|
// src/index.ts
|
|
4
|
-
import * as
|
|
5
|
-
import * as
|
|
10
|
+
import * as fs3 from "fs";
|
|
11
|
+
import * as path3 from "path";
|
|
6
12
|
import { createInterface } from "readline/promises";
|
|
7
13
|
import { Command } from "commander";
|
|
8
14
|
|
|
15
|
+
// src/commands/tunnel.ts
|
|
16
|
+
import { fork } from "child_process";
|
|
17
|
+
import * as fs2 from "fs";
|
|
18
|
+
import * as path2 from "path";
|
|
19
|
+
|
|
20
|
+
// src/lib/config.ts
|
|
21
|
+
import * as fs from "fs";
|
|
22
|
+
import * as os from "os";
|
|
23
|
+
import * as path from "path";
|
|
24
|
+
var DEFAULT_BASE_URL = "https://silent-guanaco-514.convex.site";
|
|
25
|
+
function getConfigDir(homeDir) {
|
|
26
|
+
const home = homeDir || os.homedir();
|
|
27
|
+
return path.join(home, ".config", "pubblue");
|
|
28
|
+
}
|
|
29
|
+
function getConfigPath(homeDir) {
|
|
30
|
+
const dir = getConfigDir(homeDir);
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
32
|
+
try {
|
|
33
|
+
fs.chmodSync(dir, 448);
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
return path.join(dir, "config.json");
|
|
37
|
+
}
|
|
38
|
+
function loadConfig(homeDir) {
|
|
39
|
+
const configPath = getConfigPath(homeDir);
|
|
40
|
+
if (!fs.existsSync(configPath)) return null;
|
|
41
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
42
|
+
return JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
function saveConfig(config, homeDir) {
|
|
45
|
+
const configPath = getConfigPath(homeDir);
|
|
46
|
+
fs.writeFileSync(configPath, `${JSON.stringify({ apiKey: config.apiKey }, null, 2)}
|
|
47
|
+
`, {
|
|
48
|
+
mode: 384
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
fs.chmodSync(configPath, 384);
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function getConfig(homeDir) {
|
|
56
|
+
const envKey = process.env.PUBBLUE_API_KEY;
|
|
57
|
+
const envUrl = process.env.PUBBLUE_URL;
|
|
58
|
+
const baseUrl = envUrl || DEFAULT_BASE_URL;
|
|
59
|
+
if (envKey) {
|
|
60
|
+
return { apiKey: envKey, baseUrl };
|
|
61
|
+
}
|
|
62
|
+
const saved = loadConfig(homeDir);
|
|
63
|
+
if (!saved) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Not configured. Run `pubblue configure` or set PUBBLUE_API_KEY environment variable."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
apiKey: saved.apiKey,
|
|
70
|
+
baseUrl
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/lib/tunnel-ipc.ts
|
|
75
|
+
import * as net from "net";
|
|
76
|
+
function getSocketPath(tunnelId) {
|
|
77
|
+
return `/tmp/pubblue-${tunnelId}.sock`;
|
|
78
|
+
}
|
|
79
|
+
async function ipcCall(socketPath, request) {
|
|
80
|
+
return new Promise((resolve3, reject) => {
|
|
81
|
+
let settled = false;
|
|
82
|
+
let timeoutId = null;
|
|
83
|
+
const finish = (fn) => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
87
|
+
fn();
|
|
88
|
+
};
|
|
89
|
+
const client = net.createConnection(socketPath, () => {
|
|
90
|
+
client.write(`${JSON.stringify(request)}
|
|
91
|
+
`);
|
|
92
|
+
});
|
|
93
|
+
let data = "";
|
|
94
|
+
client.on("data", (chunk) => {
|
|
95
|
+
data += chunk.toString();
|
|
96
|
+
const newlineIdx = data.indexOf("\n");
|
|
97
|
+
if (newlineIdx !== -1) {
|
|
98
|
+
const line = data.slice(0, newlineIdx);
|
|
99
|
+
client.end();
|
|
100
|
+
try {
|
|
101
|
+
finish(() => resolve3(JSON.parse(line)));
|
|
102
|
+
} catch {
|
|
103
|
+
finish(() => reject(new Error("Invalid response from daemon")));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
client.on("error", (err) => {
|
|
108
|
+
if (err.code === "ECONNREFUSED" || err.code === "ENOENT") {
|
|
109
|
+
finish(() => reject(new Error("Daemon not running. Is the tunnel still active?")));
|
|
110
|
+
} else {
|
|
111
|
+
finish(() => reject(err));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
client.on("end", () => {
|
|
115
|
+
if (!data.includes("\n")) {
|
|
116
|
+
finish(() => reject(new Error("Daemon closed connection unexpectedly")));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
timeoutId = setTimeout(() => {
|
|
120
|
+
client.destroy();
|
|
121
|
+
finish(() => reject(new Error("Daemon request timed out")));
|
|
122
|
+
}, 1e4);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/commands/tunnel.ts
|
|
127
|
+
var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
128
|
+
".txt",
|
|
129
|
+
".md",
|
|
130
|
+
".markdown",
|
|
131
|
+
".json",
|
|
132
|
+
".csv",
|
|
133
|
+
".xml",
|
|
134
|
+
".yaml",
|
|
135
|
+
".yml",
|
|
136
|
+
".js",
|
|
137
|
+
".mjs",
|
|
138
|
+
".cjs",
|
|
139
|
+
".ts",
|
|
140
|
+
".tsx",
|
|
141
|
+
".jsx",
|
|
142
|
+
".css",
|
|
143
|
+
".scss",
|
|
144
|
+
".sass",
|
|
145
|
+
".less",
|
|
146
|
+
".log"
|
|
147
|
+
]);
|
|
148
|
+
function getMimeType(filePath) {
|
|
149
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
150
|
+
const mimeByExt = {
|
|
151
|
+
".html": "text/html; charset=utf-8",
|
|
152
|
+
".htm": "text/html; charset=utf-8",
|
|
153
|
+
".txt": "text/plain; charset=utf-8",
|
|
154
|
+
".md": "text/markdown; charset=utf-8",
|
|
155
|
+
".markdown": "text/markdown; charset=utf-8",
|
|
156
|
+
".json": "application/json",
|
|
157
|
+
".csv": "text/csv; charset=utf-8",
|
|
158
|
+
".xml": "application/xml",
|
|
159
|
+
".yaml": "application/x-yaml",
|
|
160
|
+
".yml": "application/x-yaml",
|
|
161
|
+
".png": "image/png",
|
|
162
|
+
".jpg": "image/jpeg",
|
|
163
|
+
".jpeg": "image/jpeg",
|
|
164
|
+
".gif": "image/gif",
|
|
165
|
+
".webp": "image/webp",
|
|
166
|
+
".svg": "image/svg+xml",
|
|
167
|
+
".pdf": "application/pdf",
|
|
168
|
+
".zip": "application/zip",
|
|
169
|
+
".mp3": "audio/mpeg",
|
|
170
|
+
".wav": "audio/wav",
|
|
171
|
+
".mp4": "video/mp4"
|
|
172
|
+
};
|
|
173
|
+
return mimeByExt[ext] || "application/octet-stream";
|
|
174
|
+
}
|
|
175
|
+
function tunnelInfoDir() {
|
|
176
|
+
const dir = path2.join(
|
|
177
|
+
process.env.HOME || process.env.USERPROFILE || "/tmp",
|
|
178
|
+
".config",
|
|
179
|
+
"pubblue",
|
|
180
|
+
"tunnels"
|
|
181
|
+
);
|
|
182
|
+
if (!fs2.existsSync(dir)) fs2.mkdirSync(dir, { recursive: true });
|
|
183
|
+
return dir;
|
|
184
|
+
}
|
|
185
|
+
function tunnelInfoPath(tunnelId) {
|
|
186
|
+
return path2.join(tunnelInfoDir(), `${tunnelId}.json`);
|
|
187
|
+
}
|
|
188
|
+
function createApiClient() {
|
|
189
|
+
const config = getConfig();
|
|
190
|
+
return new TunnelApiClient(config.baseUrl, config.apiKey);
|
|
191
|
+
}
|
|
192
|
+
async function ensureNodeDatachannelAvailable() {
|
|
193
|
+
try {
|
|
194
|
+
await import("node-datachannel");
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
197
|
+
console.error("node-datachannel native module is not available.");
|
|
198
|
+
console.error("Run `pnpm rebuild node-datachannel` in the cli package and retry.");
|
|
199
|
+
console.error(`Details: ${message}`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function isDaemonRunning(tunnelId) {
|
|
204
|
+
const infoPath = tunnelInfoPath(tunnelId);
|
|
205
|
+
if (!fs2.existsSync(infoPath)) return false;
|
|
206
|
+
try {
|
|
207
|
+
const info = JSON.parse(fs2.readFileSync(infoPath, "utf-8"));
|
|
208
|
+
process.kill(info.pid, 0);
|
|
209
|
+
return true;
|
|
210
|
+
} catch {
|
|
211
|
+
try {
|
|
212
|
+
fs2.unlinkSync(infoPath);
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function registerTunnelCommands(program2) {
|
|
219
|
+
const tunnel = program2.command("tunnel").description("P2P encrypted tunnel to browser");
|
|
220
|
+
tunnel.command("start").description("Start a new tunnel (spawns background daemon)").option("--title <title>", "Tunnel title").option("--expires <duration>", "Auto-close after duration (e.g. 4h, 1d)", "24h").option("--foreground", "Run in foreground (don't fork)").action(async (opts) => {
|
|
221
|
+
await ensureNodeDatachannelAvailable();
|
|
222
|
+
const apiClient = createApiClient();
|
|
223
|
+
const result = await apiClient.create({
|
|
224
|
+
title: opts.title,
|
|
225
|
+
expiresIn: opts.expires
|
|
226
|
+
});
|
|
227
|
+
const socketPath = getSocketPath(result.tunnelId);
|
|
228
|
+
const infoPath = tunnelInfoPath(result.tunnelId);
|
|
229
|
+
if (opts.foreground) {
|
|
230
|
+
const { startDaemon } = await import("./tunnel-daemon-QBJSX4JM.js");
|
|
231
|
+
console.log(`Tunnel started: ${result.url}`);
|
|
232
|
+
console.log(`Tunnel ID: ${result.tunnelId}`);
|
|
233
|
+
console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
|
|
234
|
+
console.log("Running in foreground. Press Ctrl+C to stop.");
|
|
235
|
+
try {
|
|
236
|
+
await startDaemon({
|
|
237
|
+
tunnelId: result.tunnelId,
|
|
238
|
+
apiClient,
|
|
239
|
+
socketPath,
|
|
240
|
+
infoPath
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
244
|
+
console.error(`Daemon failed: ${message}`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
const daemonScript = path2.join(import.meta.dirname, "tunnel-daemon-entry.js");
|
|
249
|
+
const config = getConfig();
|
|
250
|
+
const child = fork(daemonScript, [], {
|
|
251
|
+
detached: true,
|
|
252
|
+
stdio: "ignore",
|
|
253
|
+
env: {
|
|
254
|
+
...process.env,
|
|
255
|
+
PUBBLUE_DAEMON_TUNNEL_ID: result.tunnelId,
|
|
256
|
+
PUBBLUE_DAEMON_BASE_URL: config.baseUrl,
|
|
257
|
+
PUBBLUE_DAEMON_API_KEY: config.apiKey,
|
|
258
|
+
PUBBLUE_DAEMON_SOCKET: socketPath,
|
|
259
|
+
PUBBLUE_DAEMON_INFO: infoPath
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
child.unref();
|
|
263
|
+
const ready = await waitForDaemonReady(infoPath, child, 5e3);
|
|
264
|
+
if (!ready) {
|
|
265
|
+
console.error("Daemon failed to start. Cleaning up tunnel...");
|
|
266
|
+
await apiClient.close(result.tunnelId).catch(() => {
|
|
267
|
+
});
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
console.log(`Tunnel started: ${result.url}`);
|
|
271
|
+
console.log(`Tunnel ID: ${result.tunnelId}`);
|
|
272
|
+
console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
tunnel.command("write").description("Write data to a channel").argument("[message]", "Text message (or use --file)").option("-t, --tunnel <tunnelId>", "Tunnel ID (auto-detected if one active)").option("-c, --channel <channel>", "Channel name", "chat").option("-f, --file <file>", "Read content from file").action(
|
|
276
|
+
async (messageArg, opts) => {
|
|
277
|
+
let msg;
|
|
278
|
+
let binaryBase64;
|
|
279
|
+
if (opts.file) {
|
|
280
|
+
const filePath = path2.resolve(opts.file);
|
|
281
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
282
|
+
const bytes = fs2.readFileSync(filePath);
|
|
283
|
+
const filename = path2.basename(filePath);
|
|
284
|
+
if (ext === ".html" || ext === ".htm") {
|
|
285
|
+
msg = {
|
|
286
|
+
id: generateMessageId(),
|
|
287
|
+
type: "html",
|
|
288
|
+
data: bytes.toString("utf-8"),
|
|
289
|
+
meta: { title: filename, filename, mime: getMimeType(filePath), size: bytes.length }
|
|
290
|
+
};
|
|
291
|
+
} else if (TEXT_FILE_EXTENSIONS.has(ext)) {
|
|
292
|
+
msg = {
|
|
293
|
+
id: generateMessageId(),
|
|
294
|
+
type: "text",
|
|
295
|
+
data: bytes.toString("utf-8"),
|
|
296
|
+
meta: { filename, mime: getMimeType(filePath), size: bytes.length }
|
|
297
|
+
};
|
|
298
|
+
} else {
|
|
299
|
+
msg = {
|
|
300
|
+
id: generateMessageId(),
|
|
301
|
+
type: "binary",
|
|
302
|
+
meta: { filename, mime: getMimeType(filePath), size: bytes.length }
|
|
303
|
+
};
|
|
304
|
+
binaryBase64 = bytes.toString("base64");
|
|
305
|
+
}
|
|
306
|
+
} else if (messageArg) {
|
|
307
|
+
msg = {
|
|
308
|
+
id: generateMessageId(),
|
|
309
|
+
type: "text",
|
|
310
|
+
data: messageArg
|
|
311
|
+
};
|
|
312
|
+
} else {
|
|
313
|
+
const chunks = [];
|
|
314
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
315
|
+
msg = {
|
|
316
|
+
id: generateMessageId(),
|
|
317
|
+
type: "text",
|
|
318
|
+
data: Buffer.concat(chunks).toString("utf-8").trim()
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const tunnelId = opts.tunnel || await resolveActiveTunnel();
|
|
322
|
+
const socketPath = getSocketPath(tunnelId);
|
|
323
|
+
const response = await ipcCall(socketPath, {
|
|
324
|
+
method: "write",
|
|
325
|
+
params: { channel: opts.channel, msg, binaryBase64 }
|
|
326
|
+
});
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
console.error(`Failed: ${response.error}`);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
tunnel.command("read").description("Read buffered messages from channels").argument("[tunnelId]", "Tunnel ID (auto-detected if one active)").option("-c, --channel <channel>", "Filter by channel").option("--follow", "Stream messages continuously").action(
|
|
334
|
+
async (tunnelIdArg, opts) => {
|
|
335
|
+
const tunnelId = tunnelIdArg || await resolveActiveTunnel();
|
|
336
|
+
const socketPath = getSocketPath(tunnelId);
|
|
337
|
+
if (opts.follow) {
|
|
338
|
+
while (true) {
|
|
339
|
+
const response = await ipcCall(socketPath, {
|
|
340
|
+
method: "read",
|
|
341
|
+
params: { channel: opts.channel }
|
|
342
|
+
}).catch(() => null);
|
|
343
|
+
if (!response) {
|
|
344
|
+
console.error("Daemon disconnected.");
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
if (response.messages && response.messages.length > 0) {
|
|
348
|
+
for (const m of response.messages) {
|
|
349
|
+
console.log(JSON.stringify(m));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
const response = await ipcCall(socketPath, {
|
|
356
|
+
method: "read",
|
|
357
|
+
params: { channel: opts.channel }
|
|
358
|
+
});
|
|
359
|
+
if (!response.ok) {
|
|
360
|
+
console.error(`Failed: ${response.error}`);
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
console.log(JSON.stringify(response.messages || [], null, 2));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
);
|
|
367
|
+
tunnel.command("channels").description("List active channels").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
|
|
368
|
+
const tunnelId = tunnelIdArg || await resolveActiveTunnel();
|
|
369
|
+
const socketPath = getSocketPath(tunnelId);
|
|
370
|
+
const response = await ipcCall(socketPath, { method: "channels", params: {} });
|
|
371
|
+
if (response.channels) {
|
|
372
|
+
for (const ch of response.channels) {
|
|
373
|
+
console.log(` ${ch.name} [${ch.direction}]`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
tunnel.command("status").description("Check tunnel connection status").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
|
|
378
|
+
const tunnelId = tunnelIdArg || await resolveActiveTunnel();
|
|
379
|
+
const socketPath = getSocketPath(tunnelId);
|
|
380
|
+
const response = await ipcCall(socketPath, { method: "status", params: {} });
|
|
381
|
+
console.log(` Status: ${response.connected ? "connected" : "waiting"}`);
|
|
382
|
+
console.log(` Uptime: ${response.uptime}s`);
|
|
383
|
+
const chNames = Array.isArray(response.channels) ? response.channels.map((c) => typeof c === "string" ? c : String(c)) : [];
|
|
384
|
+
console.log(` Channels: ${chNames.join(", ")}`);
|
|
385
|
+
console.log(` Buffered: ${response.bufferedMessages ?? 0} messages`);
|
|
386
|
+
});
|
|
387
|
+
tunnel.command("list").description("List active tunnels").action(async () => {
|
|
388
|
+
const apiClient = createApiClient();
|
|
389
|
+
const tunnels = await apiClient.list();
|
|
390
|
+
if (tunnels.length === 0) {
|
|
391
|
+
console.log("No active tunnels.");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
for (const t of tunnels) {
|
|
395
|
+
const age = Math.floor((Date.now() - t.createdAt) / 6e4);
|
|
396
|
+
const running = isDaemonRunning(t.tunnelId) ? "running" : "no daemon";
|
|
397
|
+
const conn = t.hasConnection ? "connected" : "waiting";
|
|
398
|
+
console.log(
|
|
399
|
+
` ${t.tunnelId} ${t.title || "(untitled)"} ${conn} ${running} ${age}m ago`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
tunnel.command("close").description("Close a tunnel and stop its daemon").argument("<tunnelId>", "Tunnel ID").action(async (tunnelId) => {
|
|
404
|
+
const socketPath = getSocketPath(tunnelId);
|
|
405
|
+
let closedByDaemon = false;
|
|
406
|
+
try {
|
|
407
|
+
const daemonResult = await ipcCall(socketPath, { method: "close", params: {} });
|
|
408
|
+
closedByDaemon = daemonResult.ok;
|
|
409
|
+
} catch {
|
|
410
|
+
closedByDaemon = false;
|
|
411
|
+
}
|
|
412
|
+
if (!closedByDaemon) {
|
|
413
|
+
const apiClient = createApiClient();
|
|
414
|
+
try {
|
|
415
|
+
await apiClient.close(tunnelId);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
418
|
+
console.error(`Failed to close tunnel ${tunnelId}: ${message}`);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
console.log(`Closed: ${tunnelId}`);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
async function resolveActiveTunnel() {
|
|
426
|
+
const dir = tunnelInfoDir();
|
|
427
|
+
const files = fs2.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
428
|
+
const active = [];
|
|
429
|
+
for (const f of files) {
|
|
430
|
+
const tunnelId = f.replace(".json", "");
|
|
431
|
+
if (isDaemonRunning(tunnelId)) active.push(tunnelId);
|
|
432
|
+
}
|
|
433
|
+
if (active.length === 0) {
|
|
434
|
+
console.error("No active tunnels. Run `pubblue tunnel start` first.");
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
if (active.length === 1) return active[0];
|
|
438
|
+
console.error(`Multiple active tunnels: ${active.join(", ")}. Specify one.`);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
function waitForDaemonReady(infoPath, child, timeoutMs) {
|
|
442
|
+
return new Promise((resolve3) => {
|
|
443
|
+
let settled = false;
|
|
444
|
+
const done = (value) => {
|
|
445
|
+
if (settled) return;
|
|
446
|
+
settled = true;
|
|
447
|
+
clearInterval(poll);
|
|
448
|
+
clearTimeout(timeout);
|
|
449
|
+
resolve3(value);
|
|
450
|
+
};
|
|
451
|
+
child.on("exit", () => done(false));
|
|
452
|
+
const poll = setInterval(() => {
|
|
453
|
+
if (fs2.existsSync(infoPath)) done(true);
|
|
454
|
+
}, 100);
|
|
455
|
+
const timeout = setTimeout(() => done(false), timeoutMs);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
9
459
|
// src/lib/api.ts
|
|
10
460
|
var PubApiClient = class {
|
|
11
461
|
constructor(baseUrl, apiKey) {
|
|
12
462
|
this.baseUrl = baseUrl;
|
|
13
463
|
this.apiKey = apiKey;
|
|
14
464
|
}
|
|
15
|
-
async request(
|
|
16
|
-
const url = new URL(
|
|
465
|
+
async request(path4, options = {}) {
|
|
466
|
+
const url = new URL(path4, this.baseUrl);
|
|
17
467
|
const res = await fetch(url, {
|
|
18
468
|
...options,
|
|
19
469
|
headers: {
|
|
@@ -71,60 +521,6 @@ var PubApiClient = class {
|
|
|
71
521
|
}
|
|
72
522
|
};
|
|
73
523
|
|
|
74
|
-
// src/lib/config.ts
|
|
75
|
-
import * as fs from "fs";
|
|
76
|
-
import * as os from "os";
|
|
77
|
-
import * as path from "path";
|
|
78
|
-
var DEFAULT_BASE_URL = "https://silent-guanaco-514.convex.site";
|
|
79
|
-
function getConfigDir(homeDir) {
|
|
80
|
-
const home = homeDir || os.homedir();
|
|
81
|
-
return path.join(home, ".config", "pubblue");
|
|
82
|
-
}
|
|
83
|
-
function getConfigPath(homeDir) {
|
|
84
|
-
const dir = getConfigDir(homeDir);
|
|
85
|
-
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
86
|
-
try {
|
|
87
|
-
fs.chmodSync(dir, 448);
|
|
88
|
-
} catch {
|
|
89
|
-
}
|
|
90
|
-
return path.join(dir, "config.json");
|
|
91
|
-
}
|
|
92
|
-
function loadConfig(homeDir) {
|
|
93
|
-
const configPath = getConfigPath(homeDir);
|
|
94
|
-
if (!fs.existsSync(configPath)) return null;
|
|
95
|
-
const raw = fs.readFileSync(configPath, "utf-8");
|
|
96
|
-
return JSON.parse(raw);
|
|
97
|
-
}
|
|
98
|
-
function saveConfig(config, homeDir) {
|
|
99
|
-
const configPath = getConfigPath(homeDir);
|
|
100
|
-
fs.writeFileSync(configPath, `${JSON.stringify({ apiKey: config.apiKey }, null, 2)}
|
|
101
|
-
`, {
|
|
102
|
-
mode: 384
|
|
103
|
-
});
|
|
104
|
-
try {
|
|
105
|
-
fs.chmodSync(configPath, 384);
|
|
106
|
-
} catch {
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
function getConfig(homeDir) {
|
|
110
|
-
const envKey = process.env.PUBBLUE_API_KEY;
|
|
111
|
-
const envUrl = process.env.PUBBLUE_URL;
|
|
112
|
-
const baseUrl = envUrl || DEFAULT_BASE_URL;
|
|
113
|
-
if (envKey) {
|
|
114
|
-
return { apiKey: envKey, baseUrl };
|
|
115
|
-
}
|
|
116
|
-
const saved = loadConfig(homeDir);
|
|
117
|
-
if (!saved) {
|
|
118
|
-
throw new Error(
|
|
119
|
-
"Not configured. Run `pubblue configure` or set PUBBLUE_API_KEY environment variable."
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
return {
|
|
123
|
-
apiKey: saved.apiKey,
|
|
124
|
-
baseUrl
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
524
|
// src/index.ts
|
|
129
525
|
var program = new Command();
|
|
130
526
|
function createClient() {
|
|
@@ -173,17 +569,17 @@ async function resolveConfigureApiKey(opts) {
|
|
|
173
569
|
return readApiKeyFromPrompt();
|
|
174
570
|
}
|
|
175
571
|
function readFile(filePath) {
|
|
176
|
-
const resolved =
|
|
177
|
-
if (!
|
|
572
|
+
const resolved = path3.resolve(filePath);
|
|
573
|
+
if (!fs3.existsSync(resolved)) {
|
|
178
574
|
console.error(`File not found: ${resolved}`);
|
|
179
575
|
process.exit(1);
|
|
180
576
|
}
|
|
181
577
|
return {
|
|
182
|
-
content:
|
|
183
|
-
basename:
|
|
578
|
+
content: fs3.readFileSync(resolved, "utf-8"),
|
|
579
|
+
basename: path3.basename(resolved)
|
|
184
580
|
};
|
|
185
581
|
}
|
|
186
|
-
program.name("pubblue").description("Publish static content and get shareable URLs").version("0.
|
|
582
|
+
program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.1");
|
|
187
583
|
program.command("configure").description("Configure the CLI with your API key").option("--api-key <key>", "Your API key (less secure: appears in shell history)").option("--api-key-stdin", "Read API key from stdin").action(async (opts) => {
|
|
188
584
|
try {
|
|
189
585
|
const apiKey = await resolveConfigureApiKey(opts);
|
|
@@ -195,7 +591,7 @@ program.command("configure").description("Configure the CLI with your API key").
|
|
|
195
591
|
process.exit(1);
|
|
196
592
|
}
|
|
197
593
|
});
|
|
198
|
-
program.command("create").description("Create a new publication").argument("[file]", "Path to the file (reads stdin if omitted)").option("--slug <slug>", "Custom slug for the URL").option("--title <title>", "Title for the publication").option("--
|
|
594
|
+
program.command("create").description("Create a new publication").argument("[file]", "Path to the file (reads stdin if omitted)").option("--slug <slug>", "Custom slug for the URL").option("--title <title>", "Title for the publication").option("--private", "Make the publication private (default)").option("--expires <duration>", "Auto-delete after duration (e.g. 1h, 24h, 7d)").action(
|
|
199
595
|
async (fileArg, opts) => {
|
|
200
596
|
const client = createClient();
|
|
201
597
|
let content;
|
|
@@ -212,7 +608,7 @@ program.command("create").description("Create a new publication").argument("[fil
|
|
|
212
608
|
filename,
|
|
213
609
|
title: opts.title,
|
|
214
610
|
slug: opts.slug,
|
|
215
|
-
isPublic:
|
|
611
|
+
isPublic: false,
|
|
216
612
|
expiresIn: opts.expires
|
|
217
613
|
});
|
|
218
614
|
console.log(`Created: ${result.url}`);
|
|
@@ -237,7 +633,7 @@ program.command("get").description("Get details of a publication").argument("<sl
|
|
|
237
633
|
console.log(` Updated: ${new Date(pub.updatedAt).toLocaleDateString()}`);
|
|
238
634
|
console.log(` Size: ${pub.content.length} bytes`);
|
|
239
635
|
});
|
|
240
|
-
program.command("update").description("Update a publication's content and/or metadata").argument("<slug>", "Slug of the publication to update").option("--file <file>", "New content from file").option("--title <title>", "New title").option("--
|
|
636
|
+
program.command("update").description("Update a publication's content and/or metadata").argument("<slug>", "Slug of the publication to update").option("--file <file>", "New content from file").option("--title <title>", "New title").option("--private", "Make the publication private").option("--slug <newSlug>", "Rename the slug").action(
|
|
241
637
|
async (slug, opts) => {
|
|
242
638
|
const client = createClient();
|
|
243
639
|
let content;
|
|
@@ -248,8 +644,7 @@ program.command("update").description("Update a publication's content and/or met
|
|
|
248
644
|
filename = file.basename;
|
|
249
645
|
}
|
|
250
646
|
let isPublic;
|
|
251
|
-
if (opts.
|
|
252
|
-
else if (opts.private) isPublic = false;
|
|
647
|
+
if (opts.private) isPublic = false;
|
|
253
648
|
const result = await client.update({
|
|
254
649
|
slug,
|
|
255
650
|
content,
|
|
@@ -283,4 +678,5 @@ program.command("delete").description("Delete a publication").argument("<slug>",
|
|
|
283
678
|
await client.remove(slug);
|
|
284
679
|
console.log(`Deleted: ${slug}`);
|
|
285
680
|
});
|
|
681
|
+
registerTunnelCommands(program);
|
|
286
682
|
program.parse();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TunnelApiClient
|
|
3
|
+
} from "./chunk-BV423NLA.js";
|
|
4
|
+
import {
|
|
5
|
+
startDaemon
|
|
6
|
+
} from "./chunk-77HFJKLW.js";
|
|
7
|
+
import "./chunk-56IKFMJ2.js";
|
|
8
|
+
|
|
9
|
+
// src/tunnel-daemon-entry.ts
|
|
10
|
+
var tunnelId = process.env.PUBBLUE_DAEMON_TUNNEL_ID;
|
|
11
|
+
var baseUrl = process.env.PUBBLUE_DAEMON_BASE_URL;
|
|
12
|
+
var apiKey = process.env.PUBBLUE_DAEMON_API_KEY;
|
|
13
|
+
var socketPath = process.env.PUBBLUE_DAEMON_SOCKET;
|
|
14
|
+
var infoPath = process.env.PUBBLUE_DAEMON_INFO;
|
|
15
|
+
if (!tunnelId || !baseUrl || !apiKey || !socketPath || !infoPath) {
|
|
16
|
+
console.error("Missing required env vars for daemon.");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
var apiClient = new TunnelApiClient(baseUrl, apiKey);
|
|
20
|
+
void startDaemon({ tunnelId, apiClient, socketPath, infoPath }).catch((error) => {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
console.error(`Tunnel daemon failed to start: ${message}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
package/package.json
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pubblue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "CLI tool for publishing static content via pub.blue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"pubblue": "./dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
11
|
-
"dev": "tsup src/index.ts --format esm --watch",
|
|
10
|
+
"build": "tsup src/index.ts src/tunnel-daemon-entry.ts --format esm --dts --clean",
|
|
11
|
+
"dev": "tsup src/index.ts src/tunnel-daemon-entry.ts --format esm --watch",
|
|
12
12
|
"test": "vitest run",
|
|
13
13
|
"test:watch": "vitest",
|
|
14
14
|
"lint": "tsc --noEmit"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"commander": "^13.0.0"
|
|
17
|
+
"commander": "^13.0.0",
|
|
18
|
+
"node-datachannel": "^0.32.0"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
|
20
21
|
"@types/node": "22.10.2",
|
|
@@ -35,7 +36,8 @@
|
|
|
35
36
|
},
|
|
36
37
|
"pnpm": {
|
|
37
38
|
"onlyBuiltDependencies": [
|
|
38
|
-
"esbuild"
|
|
39
|
+
"esbuild",
|
|
40
|
+
"node-datachannel"
|
|
39
41
|
]
|
|
40
42
|
}
|
|
41
43
|
}
|