exposeme 0.1.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/README.md +27 -0
- package/dist/chunk-VZEPY6PL.js +683 -0
- package/dist/chunk-VZEPY6PL.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +26 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# exposeme
|
|
2
|
+
|
|
3
|
+
Zero-config, P2P localhost tunnel with a 6-digit code UX.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx exposeme 3000
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
You get a 6-digit code and a shareable link. The person you share it with opens
|
|
10
|
+
the link (or enters the code at [expose.anishroy.com](https://expose.anishroy.com)),
|
|
11
|
+
and your `localhost:3000` streams to their browser over a direct WebRTC data
|
|
12
|
+
channel — no relay servers touching your traffic.
|
|
13
|
+
|
|
14
|
+
## How it works
|
|
15
|
+
|
|
16
|
+
- A Cloudflare Worker mints short-lived 6-digit session codes.
|
|
17
|
+
- Firebase Realtime Database carries the WebRTC signaling handshake.
|
|
18
|
+
- The page itself is fetched peer-to-peer: a Service Worker in the viewer's
|
|
19
|
+
browser forwards requests over the data channel to the CLI, which proxies
|
|
20
|
+
them to your local server.
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
None required. For self-hosted deployments, override via environment variables
|
|
25
|
+
(or a `.env` file): `EXPOSEME_API_URL` and `FIREBASE_*` / `VITE_FIREBASE_*`
|
|
26
|
+
web config. See the [repository](https://github.com/iamanishroy/exposeme) for
|
|
27
|
+
details.
|
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import nodeDataChannel2 from "node-datachannel";
|
|
5
|
+
import { config as loadDotenv } from "dotenv";
|
|
6
|
+
import kleur from "kleur";
|
|
7
|
+
|
|
8
|
+
// ../signaling/dist/index.js
|
|
9
|
+
import { initializeApp, deleteApp } from "firebase/app";
|
|
10
|
+
import {
|
|
11
|
+
getDatabase,
|
|
12
|
+
ref,
|
|
13
|
+
set,
|
|
14
|
+
get,
|
|
15
|
+
push,
|
|
16
|
+
onValue,
|
|
17
|
+
onChildAdded,
|
|
18
|
+
remove,
|
|
19
|
+
off
|
|
20
|
+
} from "firebase/database";
|
|
21
|
+
|
|
22
|
+
// ../shared/dist/index.js
|
|
23
|
+
var CODE_LENGTH = 6;
|
|
24
|
+
var CODE_REGEX = new RegExp(`^\\d{${CODE_LENGTH}}$`);
|
|
25
|
+
var CODE_TTL_MS = 30 * 60 * 1e3;
|
|
26
|
+
var CHUNK_SIZE = 12 * 1024;
|
|
27
|
+
var STUN_SERVERS = [
|
|
28
|
+
"stun:stun.l.google.com:19302",
|
|
29
|
+
"stun:stun1.l.google.com:19302"
|
|
30
|
+
];
|
|
31
|
+
var DATA_CHANNEL_LABEL = "exposeme";
|
|
32
|
+
var sessionPath = (code) => `sessions/${code}`;
|
|
33
|
+
var hostOfferPath = (code) => `sessions/${code}/host/offer`;
|
|
34
|
+
var hostCandidatesPath = (code) => `sessions/${code}/host/candidates`;
|
|
35
|
+
var viewerAnswerPath = (code) => `sessions/${code}/viewer/answer`;
|
|
36
|
+
var viewerCandidatesPath = (code) => `sessions/${code}/viewer/candidates`;
|
|
37
|
+
var encode = (msg) => JSON.stringify(msg);
|
|
38
|
+
var decode = (raw) => JSON.parse(raw);
|
|
39
|
+
var toBase64 = (bytes) => {
|
|
40
|
+
if (typeof Buffer !== "undefined") {
|
|
41
|
+
return Buffer.from(bytes).toString("base64");
|
|
42
|
+
}
|
|
43
|
+
let binary = "";
|
|
44
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
45
|
+
binary += String.fromCharCode(bytes[i]);
|
|
46
|
+
}
|
|
47
|
+
return btoa(binary);
|
|
48
|
+
};
|
|
49
|
+
var fromBase64 = (b64) => {
|
|
50
|
+
if (typeof Buffer !== "undefined") {
|
|
51
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
52
|
+
}
|
|
53
|
+
const binary = atob(b64);
|
|
54
|
+
const bytes = new Uint8Array(binary.length);
|
|
55
|
+
for (let i = 0; i < binary.length; i++) {
|
|
56
|
+
bytes[i] = binary.charCodeAt(i);
|
|
57
|
+
}
|
|
58
|
+
return bytes;
|
|
59
|
+
};
|
|
60
|
+
function chunkBody(id, kind, body) {
|
|
61
|
+
const t = kind === "req" ? "req_chunk" : "res_chunk";
|
|
62
|
+
const chunks = [];
|
|
63
|
+
let seq = 0;
|
|
64
|
+
for (let offset = 0; offset < body.length; offset += CHUNK_SIZE) {
|
|
65
|
+
const slice = body.subarray(offset, offset + CHUNK_SIZE);
|
|
66
|
+
chunks.push({ t, id, seq: seq++, dataB64: toBase64(slice) });
|
|
67
|
+
}
|
|
68
|
+
return chunks;
|
|
69
|
+
}
|
|
70
|
+
var ChunkAssembler = class {
|
|
71
|
+
parts = /* @__PURE__ */ new Map();
|
|
72
|
+
push(seq, dataB64) {
|
|
73
|
+
this.parts.set(seq, fromBase64(dataB64));
|
|
74
|
+
}
|
|
75
|
+
concat() {
|
|
76
|
+
const ordered = [...this.parts.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
|
|
77
|
+
const total = ordered.reduce((n, p) => n + p.length, 0);
|
|
78
|
+
const out = new Uint8Array(total);
|
|
79
|
+
let offset = 0;
|
|
80
|
+
for (const part of ordered) {
|
|
81
|
+
out.set(part, offset);
|
|
82
|
+
offset += part.length;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ../signaling/dist/index.js
|
|
89
|
+
var appCounter = 0;
|
|
90
|
+
function createSignalingClient(config) {
|
|
91
|
+
const app = initializeApp(config, `exposeme-${appCounter++}`);
|
|
92
|
+
const db = getDatabase(app);
|
|
93
|
+
return {
|
|
94
|
+
db,
|
|
95
|
+
app,
|
|
96
|
+
session: (code) => new FirebaseSignalingSession(db, code),
|
|
97
|
+
destroy: () => deleteApp(app)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
var FirebaseSignalingSession = class {
|
|
101
|
+
constructor(db, code) {
|
|
102
|
+
this.db = db;
|
|
103
|
+
this.code = code;
|
|
104
|
+
}
|
|
105
|
+
db;
|
|
106
|
+
code;
|
|
107
|
+
unsubs = [];
|
|
108
|
+
// ── Host role ────────────────────────────────────────────────────────────
|
|
109
|
+
async writeOffer(offer) {
|
|
110
|
+
await set(ref(this.db, hostOfferPath(this.code)), offer);
|
|
111
|
+
}
|
|
112
|
+
async pushHostCandidate(candidate) {
|
|
113
|
+
await push(ref(this.db, hostCandidatesPath(this.code)), candidate);
|
|
114
|
+
}
|
|
115
|
+
onAnswer(cb) {
|
|
116
|
+
const r = ref(this.db, viewerAnswerPath(this.code));
|
|
117
|
+
const handler = onValue(r, (snap) => {
|
|
118
|
+
const val = snap.val();
|
|
119
|
+
if (val) cb(val);
|
|
120
|
+
});
|
|
121
|
+
const unsub = () => off(r, "value", handler);
|
|
122
|
+
this.unsubs.push(unsub);
|
|
123
|
+
return unsub;
|
|
124
|
+
}
|
|
125
|
+
onViewerCandidate(cb) {
|
|
126
|
+
return this.subscribeCandidates(viewerCandidatesPath(this.code), cb);
|
|
127
|
+
}
|
|
128
|
+
// ── Viewer role ──────────────────────────────────────────────────────────
|
|
129
|
+
async readOffer() {
|
|
130
|
+
const snap = await get(ref(this.db, hostOfferPath(this.code)));
|
|
131
|
+
return snap.val() ?? null;
|
|
132
|
+
}
|
|
133
|
+
async writeAnswer(answer) {
|
|
134
|
+
await set(ref(this.db, viewerAnswerPath(this.code)), answer);
|
|
135
|
+
}
|
|
136
|
+
async pushViewerCandidate(candidate) {
|
|
137
|
+
await push(ref(this.db, viewerCandidatesPath(this.code)), candidate);
|
|
138
|
+
}
|
|
139
|
+
onHostCandidate(cb) {
|
|
140
|
+
return this.subscribeCandidates(hostCandidatesPath(this.code), cb);
|
|
141
|
+
}
|
|
142
|
+
// ── Shared ───────────────────────────────────────────────────────────────
|
|
143
|
+
subscribeCandidates(path, cb) {
|
|
144
|
+
const r = ref(this.db, path);
|
|
145
|
+
const handler = onChildAdded(r, (snap) => {
|
|
146
|
+
const val = snap.val();
|
|
147
|
+
if (val) cb(val);
|
|
148
|
+
});
|
|
149
|
+
const unsub = () => off(r, "child_added", handler);
|
|
150
|
+
this.unsubs.push(unsub);
|
|
151
|
+
return unsub;
|
|
152
|
+
}
|
|
153
|
+
async cleanup() {
|
|
154
|
+
for (const unsub of this.unsubs.splice(0)) unsub();
|
|
155
|
+
await remove(ref(this.db, sessionPath(this.code)));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
function firebaseConfigFromEnv(env, prefix = "FIREBASE_") {
|
|
159
|
+
const get2 = (k) => {
|
|
160
|
+
const v = env[`${prefix}${k}`];
|
|
161
|
+
if (!v) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Missing Firebase config env var ${prefix}${k}. See .env.example.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return v;
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
apiKey: get2("API_KEY"),
|
|
170
|
+
authDomain: get2("AUTH_DOMAIN"),
|
|
171
|
+
databaseURL: get2("DATABASE_URL"),
|
|
172
|
+
projectId: get2("PROJECT_ID"),
|
|
173
|
+
appId: get2("APP_ID")
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/api.ts
|
|
178
|
+
var WorkerApi = class {
|
|
179
|
+
constructor(baseUrl) {
|
|
180
|
+
this.baseUrl = baseUrl;
|
|
181
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
182
|
+
}
|
|
183
|
+
baseUrl;
|
|
184
|
+
/** POST /api/sessions — mint a code + session. */
|
|
185
|
+
async createSession() {
|
|
186
|
+
const res = await fetch(`${this.baseUrl}/api/sessions`, { method: "POST" });
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Failed to create session (${res.status}): ${await res.text()}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return await res.json();
|
|
193
|
+
}
|
|
194
|
+
/** DELETE /api/sessions/:code — release the code early. Best-effort. */
|
|
195
|
+
async releaseSession(code, sessionId) {
|
|
196
|
+
const url = `${this.baseUrl}/api/sessions/${code}?sessionId=${encodeURIComponent(
|
|
197
|
+
sessionId
|
|
198
|
+
)}`;
|
|
199
|
+
await fetch(url, { method: "DELETE" }).catch(() => {
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// src/cookies.ts
|
|
205
|
+
var CookieJar = class {
|
|
206
|
+
cookies = /* @__PURE__ */ new Map();
|
|
207
|
+
/** Record cookies from a response's `Set-Cookie` header values. */
|
|
208
|
+
store(setCookieValues, now = Date.now()) {
|
|
209
|
+
for (const raw of setCookieValues) {
|
|
210
|
+
const parsed = parseSetCookie(raw);
|
|
211
|
+
if (!parsed) continue;
|
|
212
|
+
if (parsed.expires !== void 0 && parsed.expires <= now) {
|
|
213
|
+
this.cookies.delete(parsed.name);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
this.cookies.set(parsed.name, {
|
|
217
|
+
value: parsed.value,
|
|
218
|
+
path: parsed.path,
|
|
219
|
+
expires: parsed.expires
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Build a `Cookie` header value for a request path, or null if none apply. */
|
|
224
|
+
header(requestPath, now = Date.now()) {
|
|
225
|
+
const pairs = [];
|
|
226
|
+
for (const [name, c] of this.cookies) {
|
|
227
|
+
if (c.expires !== void 0 && c.expires <= now) {
|
|
228
|
+
this.cookies.delete(name);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (pathMatches(requestPath, c.path)) pairs.push(`${name}=${c.value}`);
|
|
232
|
+
}
|
|
233
|
+
return pairs.length ? pairs.join("; ") : null;
|
|
234
|
+
}
|
|
235
|
+
/** Number of live cookies (for tests/introspection). */
|
|
236
|
+
get size() {
|
|
237
|
+
return this.cookies.size;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
function pathMatches(requestPath, cookiePath) {
|
|
241
|
+
if (cookiePath === "/" || cookiePath === "") return true;
|
|
242
|
+
const reqPath = requestPath.split("?")[0] ?? "/";
|
|
243
|
+
if (reqPath === cookiePath) return true;
|
|
244
|
+
return reqPath.startsWith(cookiePath) && (cookiePath.endsWith("/") || reqPath[cookiePath.length] === "/");
|
|
245
|
+
}
|
|
246
|
+
function parseSetCookie(raw) {
|
|
247
|
+
const parts = raw.split(";");
|
|
248
|
+
const first = parts[0]?.trim() ?? "";
|
|
249
|
+
const eq = first.indexOf("=");
|
|
250
|
+
if (eq <= 0) return null;
|
|
251
|
+
const name = first.slice(0, eq).trim();
|
|
252
|
+
const value = first.slice(eq + 1).trim();
|
|
253
|
+
let path = "/";
|
|
254
|
+
let expires;
|
|
255
|
+
let maxAge;
|
|
256
|
+
for (let i = 1; i < parts.length; i++) {
|
|
257
|
+
const attr = parts[i].trim();
|
|
258
|
+
const aEq = attr.indexOf("=");
|
|
259
|
+
const key = (aEq === -1 ? attr : attr.slice(0, aEq)).toLowerCase();
|
|
260
|
+
const val = aEq === -1 ? "" : attr.slice(aEq + 1).trim();
|
|
261
|
+
if (key === "path") path = val || "/";
|
|
262
|
+
else if (key === "expires") {
|
|
263
|
+
const t = Date.parse(val);
|
|
264
|
+
if (!Number.isNaN(t)) expires = t;
|
|
265
|
+
} else if (key === "max-age") {
|
|
266
|
+
const n = Number(val);
|
|
267
|
+
if (!Number.isNaN(n)) maxAge = n;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (maxAge !== void 0) {
|
|
271
|
+
expires = Date.now() + maxAge * 1e3;
|
|
272
|
+
}
|
|
273
|
+
return { name, value, path, expires };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/peer.ts
|
|
277
|
+
import nodeDataChannel from "node-datachannel";
|
|
278
|
+
var { PeerConnection } = nodeDataChannel;
|
|
279
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
280
|
+
function connectHost(signaling, opts = {}) {
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
const pc = new PeerConnection("exposeme-host", {
|
|
283
|
+
iceServers: opts.iceServers ?? STUN_SERVERS
|
|
284
|
+
});
|
|
285
|
+
const pending = new PendingCandidates(pc);
|
|
286
|
+
pc.onLocalDescription((sdp, type) => {
|
|
287
|
+
void signaling.writeOffer({ type, sdp });
|
|
288
|
+
});
|
|
289
|
+
pc.onLocalCandidate((candidate, mid) => {
|
|
290
|
+
void signaling.pushHostCandidate({ candidate, sdpMid: mid });
|
|
291
|
+
});
|
|
292
|
+
let answerApplied = false;
|
|
293
|
+
signaling.onAnswer((answer) => {
|
|
294
|
+
if (answerApplied) return;
|
|
295
|
+
answerApplied = true;
|
|
296
|
+
try {
|
|
297
|
+
pc.setRemoteDescription(answer.sdp, answer.type);
|
|
298
|
+
pending.flush();
|
|
299
|
+
} catch (err) {
|
|
300
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
signaling.onViewerCandidate((c) => pending.add(c));
|
|
304
|
+
const dc = pc.createDataChannel(DATA_CHANNEL_LABEL);
|
|
305
|
+
finishOnOpen(pc, dc, opts, resolve, reject);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
function connectViewer(signaling, opts = {}) {
|
|
309
|
+
return new Promise((resolve, reject) => {
|
|
310
|
+
const pc = new PeerConnection("exposeme-viewer", {
|
|
311
|
+
iceServers: opts.iceServers ?? STUN_SERVERS
|
|
312
|
+
});
|
|
313
|
+
const pending = new PendingCandidates(pc);
|
|
314
|
+
let dc;
|
|
315
|
+
pc.onLocalDescription((sdp, type) => {
|
|
316
|
+
void signaling.writeAnswer({ type, sdp });
|
|
317
|
+
});
|
|
318
|
+
pc.onLocalCandidate((candidate, mid) => {
|
|
319
|
+
void signaling.pushViewerCandidate({ candidate, sdpMid: mid });
|
|
320
|
+
});
|
|
321
|
+
pc.onDataChannel((incoming) => {
|
|
322
|
+
dc = incoming;
|
|
323
|
+
finishOnOpen(pc, incoming, opts, resolve, reject);
|
|
324
|
+
});
|
|
325
|
+
signaling.onHostCandidate((c) => pending.add(c));
|
|
326
|
+
void pollForOffer(signaling).then((offer) => {
|
|
327
|
+
pc.setRemoteDescription(offer.sdp, offer.type);
|
|
328
|
+
pending.flush();
|
|
329
|
+
}).catch(reject);
|
|
330
|
+
if (opts.timeoutMs !== 0) {
|
|
331
|
+
const t = setTimeout(() => {
|
|
332
|
+
if (!dc) reject(new Error("Timed out waiting for data channel"));
|
|
333
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT);
|
|
334
|
+
t.unref?.();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
async function pollForOffer(signaling, attempts = 40, intervalMs = 250) {
|
|
339
|
+
for (let i = 0; i < attempts; i++) {
|
|
340
|
+
const offer = await signaling.readOffer();
|
|
341
|
+
if (offer) return offer;
|
|
342
|
+
await delay(intervalMs);
|
|
343
|
+
}
|
|
344
|
+
throw new Error("Host offer not found");
|
|
345
|
+
}
|
|
346
|
+
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
347
|
+
var PendingCandidates = class {
|
|
348
|
+
constructor(pc) {
|
|
349
|
+
this.pc = pc;
|
|
350
|
+
}
|
|
351
|
+
pc;
|
|
352
|
+
queue = [];
|
|
353
|
+
ready = false;
|
|
354
|
+
add(c) {
|
|
355
|
+
if (this.ready) this.apply(c);
|
|
356
|
+
else this.queue.push(c);
|
|
357
|
+
}
|
|
358
|
+
flush() {
|
|
359
|
+
this.ready = true;
|
|
360
|
+
for (const c of this.queue.splice(0)) this.apply(c);
|
|
361
|
+
}
|
|
362
|
+
apply(c) {
|
|
363
|
+
try {
|
|
364
|
+
this.pc.addRemoteCandidate(c.candidate, c.sdpMid ?? "0");
|
|
365
|
+
} catch {
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
function finishOnOpen(pc, dc, opts, resolve, reject) {
|
|
370
|
+
const handle = {
|
|
371
|
+
pc,
|
|
372
|
+
dc,
|
|
373
|
+
close() {
|
|
374
|
+
try {
|
|
375
|
+
dc.close();
|
|
376
|
+
} catch {
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
pc.close();
|
|
380
|
+
} catch {
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
let settled = false;
|
|
385
|
+
const timer = opts.timeoutMs === 0 ? void 0 : setTimeout(() => {
|
|
386
|
+
if (!settled) {
|
|
387
|
+
settled = true;
|
|
388
|
+
reject(new Error("Timed out waiting for data channel to open"));
|
|
389
|
+
}
|
|
390
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT);
|
|
391
|
+
timer?.unref?.();
|
|
392
|
+
if (dc.isOpen()) {
|
|
393
|
+
settled = true;
|
|
394
|
+
if (timer) clearTimeout(timer);
|
|
395
|
+
resolve(handle);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
dc.onOpen(() => {
|
|
399
|
+
if (settled) return;
|
|
400
|
+
settled = true;
|
|
401
|
+
if (timer) clearTimeout(timer);
|
|
402
|
+
resolve(handle);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/proxy.ts
|
|
407
|
+
import http from "http";
|
|
408
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
409
|
+
"connection",
|
|
410
|
+
"keep-alive",
|
|
411
|
+
"proxy-authenticate",
|
|
412
|
+
"proxy-authorization",
|
|
413
|
+
"te",
|
|
414
|
+
"trailer",
|
|
415
|
+
"transfer-encoding",
|
|
416
|
+
"upgrade"
|
|
417
|
+
]);
|
|
418
|
+
function proxyRequest(port, req, cookieHeader) {
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
const outHeaders = {};
|
|
421
|
+
for (const [k, v] of req.headers) {
|
|
422
|
+
const lk = k.toLowerCase();
|
|
423
|
+
if (HOP_BY_HOP.has(lk)) continue;
|
|
424
|
+
if (lk === "host") continue;
|
|
425
|
+
if (lk === "accept-encoding") continue;
|
|
426
|
+
if (lk === "cookie") continue;
|
|
427
|
+
const existing = outHeaders[lk];
|
|
428
|
+
if (existing === void 0) outHeaders[lk] = v;
|
|
429
|
+
else if (Array.isArray(existing)) existing.push(v);
|
|
430
|
+
else outHeaders[lk] = [existing, v];
|
|
431
|
+
}
|
|
432
|
+
outHeaders["host"] = `localhost:${port}`;
|
|
433
|
+
if (cookieHeader) outHeaders["cookie"] = cookieHeader;
|
|
434
|
+
const attempt = (host, fallback) => {
|
|
435
|
+
const request = http.request(
|
|
436
|
+
{
|
|
437
|
+
host,
|
|
438
|
+
port,
|
|
439
|
+
method: req.method,
|
|
440
|
+
path: req.url,
|
|
441
|
+
headers: outHeaders
|
|
442
|
+
},
|
|
443
|
+
(res) => {
|
|
444
|
+
const chunks = [];
|
|
445
|
+
res.on("data", (c) => chunks.push(c));
|
|
446
|
+
res.on("end", () => {
|
|
447
|
+
const { headers, setCookies } = splitResponseHeaders(
|
|
448
|
+
res.rawHeaders
|
|
449
|
+
);
|
|
450
|
+
resolve({
|
|
451
|
+
status: res.statusCode ?? 502,
|
|
452
|
+
statusText: res.statusMessage ?? "",
|
|
453
|
+
headers,
|
|
454
|
+
body: new Uint8Array(Buffer.concat(chunks)),
|
|
455
|
+
setCookies
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
);
|
|
460
|
+
request.on("error", (err) => {
|
|
461
|
+
if (fallback && err.code === "ECONNREFUSED") attempt(fallback);
|
|
462
|
+
else reject(err);
|
|
463
|
+
});
|
|
464
|
+
if (req.body.length > 0) request.write(Buffer.from(req.body));
|
|
465
|
+
request.end();
|
|
466
|
+
};
|
|
467
|
+
attempt("127.0.0.1", "::1");
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
function splitResponseHeaders(rawHeaders) {
|
|
471
|
+
const headers = [];
|
|
472
|
+
const setCookies = [];
|
|
473
|
+
for (let i = 0; i < rawHeaders.length; i += 2) {
|
|
474
|
+
const name = rawHeaders[i];
|
|
475
|
+
const value = rawHeaders[i + 1] ?? "";
|
|
476
|
+
const lower = name.toLowerCase();
|
|
477
|
+
if (lower === "set-cookie") {
|
|
478
|
+
setCookies.push(value);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (HOP_BY_HOP.has(lower)) continue;
|
|
482
|
+
if (lower === "content-length") continue;
|
|
483
|
+
headers.push([name, value]);
|
|
484
|
+
}
|
|
485
|
+
return { headers, setCookies };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/tunnel.ts
|
|
489
|
+
var BUFFER_HIGH_WATER = 1024 * 1024;
|
|
490
|
+
function serveTunnel(dc, port, jar) {
|
|
491
|
+
const stats = { requestCount: 0 };
|
|
492
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
493
|
+
const lowSignal = new BufferedAmountSignal(dc);
|
|
494
|
+
dc.onMessage((raw) => {
|
|
495
|
+
const text = typeof raw === "string" ? raw : bufferToString(raw);
|
|
496
|
+
let msg;
|
|
497
|
+
try {
|
|
498
|
+
msg = decode(text);
|
|
499
|
+
} catch {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
handleMessage(msg);
|
|
503
|
+
});
|
|
504
|
+
function handleMessage(msg) {
|
|
505
|
+
switch (msg.t) {
|
|
506
|
+
case "req_head":
|
|
507
|
+
inflight.set(msg.id, {
|
|
508
|
+
method: msg.method,
|
|
509
|
+
url: msg.url,
|
|
510
|
+
headers: msg.headers,
|
|
511
|
+
body: new ChunkAssembler()
|
|
512
|
+
});
|
|
513
|
+
break;
|
|
514
|
+
case "req_chunk": {
|
|
515
|
+
inflight.get(msg.id)?.body.push(msg.seq, msg.dataB64);
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
case "req_end": {
|
|
519
|
+
const req = inflight.get(msg.id);
|
|
520
|
+
if (!req) return;
|
|
521
|
+
inflight.delete(msg.id);
|
|
522
|
+
void complete(msg.id, req);
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
default:
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function complete(id, req) {
|
|
530
|
+
try {
|
|
531
|
+
const cookieHeader = jar.header(req.url);
|
|
532
|
+
const res = await proxyRequest(
|
|
533
|
+
port,
|
|
534
|
+
{
|
|
535
|
+
method: req.method,
|
|
536
|
+
url: req.url,
|
|
537
|
+
headers: req.headers,
|
|
538
|
+
body: req.body.concat()
|
|
539
|
+
},
|
|
540
|
+
cookieHeader
|
|
541
|
+
);
|
|
542
|
+
jar.store(res.setCookies);
|
|
543
|
+
stats.requestCount++;
|
|
544
|
+
await send({
|
|
545
|
+
t: "res_head",
|
|
546
|
+
id,
|
|
547
|
+
status: res.status,
|
|
548
|
+
statusText: res.statusText,
|
|
549
|
+
headers: res.headers
|
|
550
|
+
});
|
|
551
|
+
for (const chunk of chunkBody(id, "res", res.body)) {
|
|
552
|
+
await send(chunk);
|
|
553
|
+
}
|
|
554
|
+
await send({ t: "res_end", id });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
await send({
|
|
557
|
+
t: "res_err",
|
|
558
|
+
id,
|
|
559
|
+
message: err instanceof Error ? err.message : String(err)
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function send(msg) {
|
|
564
|
+
await lowSignal.awaitDrain();
|
|
565
|
+
if (dc.isOpen()) dc.sendMessage(encode(msg));
|
|
566
|
+
}
|
|
567
|
+
return stats;
|
|
568
|
+
}
|
|
569
|
+
var BufferedAmountSignal = class {
|
|
570
|
+
constructor(dc) {
|
|
571
|
+
this.dc = dc;
|
|
572
|
+
if (dc.setBufferedAmountLowThreshold && dc.onBufferedAmountLow) {
|
|
573
|
+
dc.setBufferedAmountLowThreshold(BUFFER_HIGH_WATER / 2);
|
|
574
|
+
dc.onBufferedAmountLow(() => {
|
|
575
|
+
for (const w of this.waiters.splice(0)) w();
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
dc;
|
|
580
|
+
waiters = [];
|
|
581
|
+
awaitDrain() {
|
|
582
|
+
const buffered = this.dc.bufferedAmount?.() ?? 0;
|
|
583
|
+
if (buffered < BUFFER_HIGH_WATER) return Promise.resolve();
|
|
584
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
function bufferToString(buf) {
|
|
588
|
+
if (buf instanceof ArrayBuffer) return Buffer.from(buf).toString("utf-8");
|
|
589
|
+
return buf.toString("utf-8");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// src/index.ts
|
|
593
|
+
var DEFAULT_API_URL = "https://expose-api.anishroy.com";
|
|
594
|
+
var DEFAULT_FIREBASE_CONFIG = {
|
|
595
|
+
apiKey: "AIzaSyA2YgT7R1gNlJhtp3mW-Sx4BOMEJPDYJgA",
|
|
596
|
+
authDomain: "exposeme-230a6.firebaseapp.com",
|
|
597
|
+
databaseURL: "https://exposeme-230a6-default-rtdb.firebaseio.com",
|
|
598
|
+
projectId: "exposeme-230a6",
|
|
599
|
+
appId: "1:242575918594:web:8a62140d71bc63d49d891b"
|
|
600
|
+
};
|
|
601
|
+
function readCliConfig(env) {
|
|
602
|
+
return { apiUrl: env.EXPOSEME_API_URL ?? DEFAULT_API_URL };
|
|
603
|
+
}
|
|
604
|
+
function parseArgs(argv) {
|
|
605
|
+
const positional = argv.filter((a) => !a.startsWith("-"));
|
|
606
|
+
const portStr = positional[0];
|
|
607
|
+
if (!portStr) throw new Error("Usage: npx exposeme <port>");
|
|
608
|
+
const port = Number(portStr);
|
|
609
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
610
|
+
throw new Error(`Invalid port: ${portStr}`);
|
|
611
|
+
}
|
|
612
|
+
return { port };
|
|
613
|
+
}
|
|
614
|
+
var label = {
|
|
615
|
+
waiting: kleur.yellow("\u25CF waiting"),
|
|
616
|
+
connected: kleur.green("\u25CF connected"),
|
|
617
|
+
disconnected: kleur.red("\u25CF disconnected")
|
|
618
|
+
};
|
|
619
|
+
async function run(opts) {
|
|
620
|
+
const env = opts.env ?? process.env;
|
|
621
|
+
loadDotenv();
|
|
622
|
+
const { apiUrl } = readCliConfig(env);
|
|
623
|
+
const firebasePrefix = env.FIREBASE_API_KEY ? "FIREBASE_" : env.VITE_FIREBASE_API_KEY ? "VITE_FIREBASE_" : null;
|
|
624
|
+
const firebaseConfig = firebasePrefix ? firebaseConfigFromEnv(env, firebasePrefix) : DEFAULT_FIREBASE_CONFIG;
|
|
625
|
+
const api = new WorkerApi(apiUrl);
|
|
626
|
+
const jar = new CookieJar();
|
|
627
|
+
console.log(kleur.bold("\n ExposeMe") + kleur.dim(` \u2192 tunneling localhost:${opts.port}
|
|
628
|
+
`));
|
|
629
|
+
const session = await api.createSession();
|
|
630
|
+
const client = createSignalingClient(firebaseConfig);
|
|
631
|
+
const signaling = client.session(session.code);
|
|
632
|
+
printCode(session.code, session.viewerUrl);
|
|
633
|
+
console.log(` status ${label.waiting}
|
|
634
|
+
`);
|
|
635
|
+
let cleanedUp = false;
|
|
636
|
+
const cleanup = async () => {
|
|
637
|
+
if (cleanedUp) return;
|
|
638
|
+
cleanedUp = true;
|
|
639
|
+
try {
|
|
640
|
+
handle?.close();
|
|
641
|
+
} catch {
|
|
642
|
+
}
|
|
643
|
+
await signaling.cleanup().catch(() => {
|
|
644
|
+
});
|
|
645
|
+
await api.releaseSession(session.code, session.sessionId);
|
|
646
|
+
await client.destroy().catch(() => {
|
|
647
|
+
});
|
|
648
|
+
nodeDataChannel2.cleanup();
|
|
649
|
+
};
|
|
650
|
+
process.on("SIGINT", () => {
|
|
651
|
+
console.log(kleur.dim("\n releasing code and cleaning up\u2026"));
|
|
652
|
+
void cleanup().then(() => process.exit(0));
|
|
653
|
+
});
|
|
654
|
+
let handle;
|
|
655
|
+
handle = await connectHost(signaling);
|
|
656
|
+
console.log(` status ${label.connected}
|
|
657
|
+
`);
|
|
658
|
+
serveTunnel(handle.dc, opts.port, jar);
|
|
659
|
+
handle.dc.onClosed(() => {
|
|
660
|
+
console.log(` status ${label.disconnected}
|
|
661
|
+
`);
|
|
662
|
+
});
|
|
663
|
+
await new Promise(() => {
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
function printCode(code, viewerUrl) {
|
|
667
|
+
console.log(` code ${kleur.bold().cyan(code)}`);
|
|
668
|
+
console.log(` share ${kleur.underline(viewerUrl)}
|
|
669
|
+
`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export {
|
|
673
|
+
WorkerApi,
|
|
674
|
+
CookieJar,
|
|
675
|
+
parseSetCookie,
|
|
676
|
+
connectHost,
|
|
677
|
+
connectViewer,
|
|
678
|
+
proxyRequest,
|
|
679
|
+
serveTunnel,
|
|
680
|
+
parseArgs,
|
|
681
|
+
run
|
|
682
|
+
};
|
|
683
|
+
//# sourceMappingURL=chunk-VZEPY6PL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../../signaling/src/firebase.ts","../../signaling/src/config.ts","../../shared/src/constants.ts","../../shared/src/paths.ts","../../shared/src/protocol.ts","../../shared/src/code.ts","../src/api.ts","../src/cookies.ts","../src/peer.ts","../src/proxy.ts","../src/tunnel.ts"],"sourcesContent":["import nodeDataChannel from \"node-datachannel\";\nimport { config as loadDotenv } from \"dotenv\";\nimport kleur from \"kleur\";\nimport {\n createSignalingClient,\n firebaseConfigFromEnv,\n type FirebaseConfig,\n type SignalingSession,\n} from \"@exposeme/signaling\";\nimport { WorkerApi } from \"./api.js\";\nimport { CookieJar } from \"./cookies.js\";\nimport { connectHost } from \"./peer.js\";\nimport { serveTunnel } from \"./tunnel.js\";\n\nexport { WorkerApi } from \"./api.js\";\nexport { CookieJar, parseSetCookie } from \"./cookies.js\";\nexport { connectHost, connectViewer } from \"./peer.js\";\nexport { serveTunnel, type DataChannelLike } from \"./tunnel.js\";\nexport { proxyRequest } from \"./proxy.js\";\n\nexport interface RunOptions {\n port: number;\n env?: NodeJS.ProcessEnv;\n}\n\ninterface CliConfig {\n apiUrl: string;\n}\n\n// Production defaults so the published CLI is zero-config; env vars override.\n// The Firebase web config is not secret — RTDB access is gated by security\n// rules + the 6-digit code, not by hiding these values.\nconst DEFAULT_API_URL = \"https://expose-api.anishroy.com\";\nconst DEFAULT_FIREBASE_CONFIG: FirebaseConfig = {\n apiKey: \"AIzaSyA2YgT7R1gNlJhtp3mW-Sx4BOMEJPDYJgA\",\n authDomain: \"exposeme-230a6.firebaseapp.com\",\n databaseURL: \"https://exposeme-230a6-default-rtdb.firebaseio.com\",\n projectId: \"exposeme-230a6\",\n appId: \"1:242575918594:web:8a62140d71bc63d49d891b\",\n};\n\nfunction readCliConfig(env: NodeJS.ProcessEnv): CliConfig {\n return { apiUrl: env.EXPOSEME_API_URL ?? DEFAULT_API_URL };\n}\n\n/** Parse `exposeme <port>` argv into a port number. */\nexport function parseArgs(argv: string[]): { port: number } {\n const positional = argv.filter((a) => !a.startsWith(\"-\"));\n const portStr = positional[0];\n if (!portStr) throw new Error(\"Usage: npx exposeme <port>\");\n const port = Number(portStr);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n throw new Error(`Invalid port: ${portStr}`);\n }\n return { port };\n}\n\nconst label = {\n waiting: kleur.yellow(\"● waiting\"),\n connected: kleur.green(\"● connected\"),\n disconnected: kleur.red(\"● disconnected\"),\n};\n\n/**\n * Run the tunnel host: mint a code, publish signaling, accept a viewer, and\n * proxy its requests to localhost:<port>. Resolves when the process is asked to\n * stop; wires SIGINT for graceful cleanup.\n */\nexport async function run(opts: RunOptions): Promise<void> {\n const env = opts.env ?? process.env;\n loadDotenv();\n const { apiUrl } = readCliConfig(env);\n // .env is shared with the viewer, which needs VITE_-prefixed vars; accept\n // either spelling so one file works for both, else fall back to prod config.\n const firebasePrefix = env.FIREBASE_API_KEY\n ? \"FIREBASE_\"\n : env.VITE_FIREBASE_API_KEY\n ? \"VITE_FIREBASE_\"\n : null;\n const firebaseConfig = firebasePrefix\n ? firebaseConfigFromEnv(env, firebasePrefix)\n : DEFAULT_FIREBASE_CONFIG;\n\n const api = new WorkerApi(apiUrl);\n const jar = new CookieJar();\n\n console.log(kleur.bold(\"\\n ExposeMe\") + kleur.dim(` → tunneling localhost:${opts.port}\\n`));\n\n const session = await api.createSession();\n const client = createSignalingClient(firebaseConfig);\n const signaling: SignalingSession = client.session(session.code);\n\n printCode(session.code, session.viewerUrl);\n console.log(` status ${label.waiting}\\n`);\n\n let cleanedUp = false;\n const cleanup = async (): Promise<void> => {\n if (cleanedUp) return;\n cleanedUp = true;\n try {\n handle?.close();\n } catch {\n /* noop */\n }\n await signaling.cleanup().catch(() => {});\n await api.releaseSession(session.code, session.sessionId);\n await client.destroy().catch(() => {});\n nodeDataChannel.cleanup();\n };\n\n process.on(\"SIGINT\", () => {\n console.log(kleur.dim(\"\\n releasing code and cleaning up…\"));\n void cleanup().then(() => process.exit(0));\n });\n\n let handle: Awaited<ReturnType<typeof connectHost>> | undefined;\n handle = await connectHost(signaling);\n\n console.log(` status ${label.connected}\\n`);\n serveTunnel(handle.dc, opts.port, jar);\n\n handle.dc.onClosed(() => {\n console.log(` status ${label.disconnected}\\n`);\n });\n\n // Keep the process alive until SIGINT.\n await new Promise<void>(() => {});\n}\n\nfunction printCode(code: string, viewerUrl: string): void {\n console.log(` code ${kleur.bold().cyan(code)}`);\n console.log(` share ${kleur.underline(viewerUrl)}\\n`);\n}\n","import { initializeApp, deleteApp, type FirebaseApp } from \"firebase/app\";\nimport {\n getDatabase,\n ref,\n set,\n get,\n push,\n onValue,\n onChildAdded,\n remove,\n off,\n type Database,\n} from \"firebase/database\";\nimport {\n hostCandidatesPath,\n hostOfferPath,\n sessionPath,\n viewerAnswerPath,\n viewerCandidatesPath,\n type IceCandidateInit,\n type SessionDescriptionInit,\n} from \"@exposeme/shared\";\nimport type { FirebaseConfig, SignalingSession, Unsubscribe } from \"./types.js\";\n\nlet appCounter = 0;\n\n/** Initialize a Firebase app + RTDB handle from config. */\nexport function createSignalingClient(config: FirebaseConfig): {\n db: Database;\n app: FirebaseApp;\n /** Bind a signaling session to a code. */\n session(code: string): SignalingSession;\n /** Tear down the underlying Firebase app. */\n destroy(): Promise<void>;\n} {\n // Unique name lets a single process (e.g. the loopback harness) hold more\n // than one app without collision.\n const app = initializeApp(config, `exposeme-${appCounter++}`);\n const db = getDatabase(app);\n\n return {\n db,\n app,\n session: (code) => new FirebaseSignalingSession(db, code),\n destroy: () => deleteApp(app),\n };\n}\n\nclass FirebaseSignalingSession implements SignalingSession {\n private unsubs: Unsubscribe[] = [];\n\n constructor(\n private readonly db: Database,\n public readonly code: string\n ) {}\n\n // ── Host role ────────────────────────────────────────────────────────────\n\n async writeOffer(offer: SessionDescriptionInit): Promise<void> {\n await set(ref(this.db, hostOfferPath(this.code)), offer);\n }\n\n async pushHostCandidate(candidate: IceCandidateInit): Promise<void> {\n await push(ref(this.db, hostCandidatesPath(this.code)), candidate);\n }\n\n onAnswer(cb: (answer: SessionDescriptionInit) => void): Unsubscribe {\n const r = ref(this.db, viewerAnswerPath(this.code));\n const handler = onValue(r, (snap) => {\n const val = snap.val() as SessionDescriptionInit | null;\n if (val) cb(val);\n });\n const unsub = () => off(r, \"value\", handler);\n this.unsubs.push(unsub);\n return unsub;\n }\n\n onViewerCandidate(cb: (candidate: IceCandidateInit) => void): Unsubscribe {\n return this.subscribeCandidates(viewerCandidatesPath(this.code), cb);\n }\n\n // ── Viewer role ──────────────────────────────────────────────────────────\n\n async readOffer(): Promise<SessionDescriptionInit | null> {\n const snap = await get(ref(this.db, hostOfferPath(this.code)));\n return (snap.val() as SessionDescriptionInit | null) ?? null;\n }\n\n async writeAnswer(answer: SessionDescriptionInit): Promise<void> {\n await set(ref(this.db, viewerAnswerPath(this.code)), answer);\n }\n\n async pushViewerCandidate(candidate: IceCandidateInit): Promise<void> {\n await push(ref(this.db, viewerCandidatesPath(this.code)), candidate);\n }\n\n onHostCandidate(cb: (candidate: IceCandidateInit) => void): Unsubscribe {\n return this.subscribeCandidates(hostCandidatesPath(this.code), cb);\n }\n\n // ── Shared ───────────────────────────────────────────────────────────────\n\n private subscribeCandidates(\n path: string,\n cb: (candidate: IceCandidateInit) => void\n ): Unsubscribe {\n const r = ref(this.db, path);\n const handler = onChildAdded(r, (snap) => {\n const val = snap.val() as IceCandidateInit | null;\n if (val) cb(val);\n });\n const unsub = () => off(r, \"child_added\", handler);\n this.unsubs.push(unsub);\n return unsub;\n }\n\n async cleanup(): Promise<void> {\n for (const unsub of this.unsubs.splice(0)) unsub();\n await remove(ref(this.db, sessionPath(this.code)));\n }\n}\n","import type { FirebaseConfig } from \"./types.js\";\n\n/**\n * Build a FirebaseConfig from a record of env vars. Works for both the CLI\n * (process.env, FIREBASE_* keys) and the viewer (import.meta.env, VITE_FIREBASE_*\n * keys) — pass the appropriate source and prefix.\n */\nexport function firebaseConfigFromEnv(\n env: Record<string, string | undefined>,\n prefix = \"FIREBASE_\"\n): FirebaseConfig {\n const get = (k: string): string => {\n const v = env[`${prefix}${k}`];\n if (!v) {\n throw new Error(\n `Missing Firebase config env var ${prefix}${k}. See .env.example.`\n );\n }\n return v;\n };\n return {\n apiKey: get(\"API_KEY\"),\n authDomain: get(\"AUTH_DOMAIN\"),\n databaseURL: get(\"DATABASE_URL\"),\n projectId: get(\"PROJECT_ID\"),\n appId: get(\"APP_ID\"),\n };\n}\n","/** Number of digits in an ExposeMe session code. */\nexport const CODE_LENGTH = 6;\n\n/** Regex a valid code must match (exactly CODE_LENGTH digits). */\nexport const CODE_REGEX = new RegExp(`^\\\\d{${CODE_LENGTH}}$`);\n\n/** Default lifetime of a session code before it expires (ms). 30 minutes. */\nexport const CODE_TTL_MS = 30 * 60 * 1000;\n\n/**\n * Max size (bytes) of a single raw body chunk before base64 encoding.\n * Kept well under the ~16 KiB safe SCTP message size so the base64-expanded\n * envelope still fits comfortably inside one data-channel message.\n */\nexport const CHUNK_SIZE = 12 * 1024;\n\n/** Public STUN servers used for ICE. TURN is intentionally omitted for v1. */\nexport const STUN_SERVERS = [\n \"stun:stun.l.google.com:19302\",\n \"stun:stun1.l.google.com:19302\",\n];\n\n/** Label used for the single application data channel. */\nexport const DATA_CHANNEL_LABEL = \"exposeme\";\n","/**\n * RTDB path helpers. All signaling state for a session lives under\n * `/sessions/{code}`. The host writes under `host/`, the viewer under `viewer/`.\n */\n\nexport const sessionPath = (code: string) => `sessions/${code}`;\n\nexport const hostOfferPath = (code: string) => `sessions/${code}/host/offer`;\nexport const hostCandidatesPath = (code: string) =>\n `sessions/${code}/host/candidates`;\n\nexport const viewerAnswerPath = (code: string) =>\n `sessions/${code}/viewer/answer`;\nexport const viewerCandidatesPath = (code: string) =>\n `sessions/${code}/viewer/candidates`;\n","import { CHUNK_SIZE } from \"./constants.js\";\nimport type { HeaderList } from \"./types.js\";\n\n/**\n * Data-channel wire protocol. Every message is a JSON envelope with a\n * discriminant `t`. A single HTTP exchange is a stream of messages sharing an\n * `id`: a HEAD, zero or more CHUNKs, then an END (or an ERR).\n *\n * Bodies are sent as base64 of the raw bytes so binary survives intact.\n */\n\nexport interface ReqHead {\n t: \"req_head\";\n id: string;\n method: string;\n /** Path + query relative to the tunneled origin, e.g. `/assets/logo.png`. */\n url: string;\n headers: HeaderList;\n}\n\nexport interface ReqChunk {\n t: \"req_chunk\";\n id: string;\n seq: number;\n dataB64: string;\n}\n\nexport interface ReqEnd {\n t: \"req_end\";\n id: string;\n}\n\nexport interface ResHead {\n t: \"res_head\";\n id: string;\n status: number;\n statusText: string;\n headers: HeaderList;\n}\n\nexport interface ResChunk {\n t: \"res_chunk\";\n id: string;\n seq: number;\n dataB64: string;\n}\n\nexport interface ResEnd {\n t: \"res_end\";\n id: string;\n}\n\nexport interface ResErr {\n t: \"res_err\";\n id: string;\n message: string;\n}\n\nexport type TunnelMessage =\n | ReqHead\n | ReqChunk\n | ReqEnd\n | ResHead\n | ResChunk\n | ResEnd\n | ResErr;\n\nexport const encode = (msg: TunnelMessage): string => JSON.stringify(msg);\n\nexport const decode = (raw: string): TunnelMessage =>\n JSON.parse(raw) as TunnelMessage;\n\n/** base64 helpers that work in both Node and the browser. */\nexport const toBase64 = (bytes: Uint8Array): string => {\n if (typeof Buffer !== \"undefined\") {\n return Buffer.from(bytes).toString(\"base64\");\n }\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]!);\n }\n return btoa(binary);\n};\n\nexport const fromBase64 = (b64: string): Uint8Array => {\n if (typeof Buffer !== \"undefined\") {\n return new Uint8Array(Buffer.from(b64, \"base64\"));\n }\n const binary = atob(b64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n};\n\n/**\n * Split a body into ordered chunk messages of at most CHUNK_SIZE raw bytes each.\n * An empty body yields no chunk messages.\n */\nexport function chunkBody(\n id: string,\n kind: \"req\" | \"res\",\n body: Uint8Array\n): Array<ReqChunk | ResChunk> {\n const t = kind === \"req\" ? \"req_chunk\" : \"res_chunk\";\n const chunks: Array<ReqChunk | ResChunk> = [];\n let seq = 0;\n for (let offset = 0; offset < body.length; offset += CHUNK_SIZE) {\n const slice = body.subarray(offset, offset + CHUNK_SIZE);\n chunks.push({ t, id, seq: seq++, dataB64: toBase64(slice) } as\n | ReqChunk\n | ResChunk);\n }\n return chunks;\n}\n\n/**\n * Accumulates ordered body chunks for a single id and concatenates them.\n * Reassembles by `seq` so out-of-order delivery is tolerated (data channels are\n * ordered by default, but this keeps the reassembler robust and testable).\n */\nexport class ChunkAssembler {\n private parts = new Map<number, Uint8Array>();\n\n push(seq: number, dataB64: string): void {\n this.parts.set(seq, fromBase64(dataB64));\n }\n\n concat(): Uint8Array {\n const ordered = [...this.parts.entries()]\n .sort((a, b) => a[0] - b[0])\n .map(([, v]) => v);\n const total = ordered.reduce((n, p) => n + p.length, 0);\n const out = new Uint8Array(total);\n let offset = 0;\n for (const part of ordered) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n }\n}\n","import { CODE_LENGTH, CODE_REGEX } from \"./constants.js\";\n\n/**\n * Generate a random N-digit code as a zero-padded string.\n * `rand` returns a float in [0, 1); defaults to Math.random. The Worker passes\n * a crypto-backed source.\n */\nexport function generateCode(rand: () => number = Math.random): string {\n const max = 10 ** CODE_LENGTH;\n const n = Math.floor(rand() * max);\n return String(n).padStart(CODE_LENGTH, \"0\");\n}\n\nexport const isValidCode = (code: string): boolean => CODE_REGEX.test(code);\n","import type { CreateSessionResponse } from \"@exposeme/shared\";\n\n/** Thin client for the ExposeMe Worker API. */\nexport class WorkerApi {\n constructor(private readonly baseUrl: string) {\n this.baseUrl = baseUrl.replace(/\\/$/, \"\");\n }\n\n /** POST /api/sessions — mint a code + session. */\n async createSession(): Promise<CreateSessionResponse> {\n const res = await fetch(`${this.baseUrl}/api/sessions`, { method: \"POST\" });\n if (!res.ok) {\n throw new Error(\n `Failed to create session (${res.status}): ${await res.text()}`\n );\n }\n return (await res.json()) as CreateSessionResponse;\n }\n\n /** DELETE /api/sessions/:code — release the code early. Best-effort. */\n async releaseSession(code: string, sessionId: string): Promise<void> {\n const url = `${this.baseUrl}/api/sessions/${code}?sessionId=${encodeURIComponent(\n sessionId\n )}`;\n await fetch(url, { method: \"DELETE\" }).catch(() => {\n /* best-effort cleanup; TTL will expire it anyway */\n });\n }\n}\n","import type { HeaderList } from \"@exposeme/shared\";\n\ninterface StoredCookie {\n value: string;\n path: string;\n expires?: number; // epoch ms\n}\n\n/**\n * A deliberately simple host-side cookie jar. Because the viewer's Service\n * Worker cannot participate in normal cross-origin cookie handling, the host\n * owns cookies for the tunneled origin: it captures `Set-Cookie` from responses\n * and replays a `Cookie` header on matching outbound requests.\n *\n * v1 scope: single tunneled origin, name-keyed, honoring `Path` and `Expires`/\n * `Max-Age`. Domain and `Secure`/`SameSite` are ignored (localhost, one origin).\n */\nexport class CookieJar {\n private cookies = new Map<string, StoredCookie>();\n\n /** Record cookies from a response's `Set-Cookie` header values. */\n store(setCookieValues: string[], now = Date.now()): void {\n for (const raw of setCookieValues) {\n const parsed = parseSetCookie(raw);\n if (!parsed) continue;\n if (parsed.expires !== undefined && parsed.expires <= now) {\n this.cookies.delete(parsed.name); // deletion cookie\n continue;\n }\n this.cookies.set(parsed.name, {\n value: parsed.value,\n path: parsed.path,\n expires: parsed.expires,\n });\n }\n }\n\n /** Build a `Cookie` header value for a request path, or null if none apply. */\n header(requestPath: string, now = Date.now()): string | null {\n const pairs: string[] = [];\n for (const [name, c] of this.cookies) {\n if (c.expires !== undefined && c.expires <= now) {\n this.cookies.delete(name);\n continue;\n }\n if (pathMatches(requestPath, c.path)) pairs.push(`${name}=${c.value}`);\n }\n return pairs.length ? pairs.join(\"; \") : null;\n }\n\n /** Number of live cookies (for tests/introspection). */\n get size(): number {\n return this.cookies.size;\n }\n}\n\nfunction pathMatches(requestPath: string, cookiePath: string): boolean {\n if (cookiePath === \"/\" || cookiePath === \"\") return true;\n const reqPath = requestPath.split(\"?\")[0] ?? \"/\";\n if (reqPath === cookiePath) return true;\n return (\n reqPath.startsWith(cookiePath) &&\n (cookiePath.endsWith(\"/\") || reqPath[cookiePath.length] === \"/\")\n );\n}\n\ninterface ParsedCookie {\n name: string;\n value: string;\n path: string;\n expires?: number;\n}\n\n/** Parse one Set-Cookie header value. Returns null if malformed. */\nexport function parseSetCookie(raw: string): ParsedCookie | null {\n const parts = raw.split(\";\");\n const first = parts[0]?.trim() ?? \"\";\n const eq = first.indexOf(\"=\");\n if (eq <= 0) return null;\n\n const name = first.slice(0, eq).trim();\n const value = first.slice(eq + 1).trim();\n let path = \"/\";\n let expires: number | undefined;\n let maxAge: number | undefined;\n\n for (let i = 1; i < parts.length; i++) {\n const attr = parts[i]!.trim();\n const aEq = attr.indexOf(\"=\");\n const key = (aEq === -1 ? attr : attr.slice(0, aEq)).toLowerCase();\n const val = aEq === -1 ? \"\" : attr.slice(aEq + 1).trim();\n if (key === \"path\") path = val || \"/\";\n else if (key === \"expires\") {\n const t = Date.parse(val);\n if (!Number.isNaN(t)) expires = t;\n } else if (key === \"max-age\") {\n const n = Number(val);\n if (!Number.isNaN(n)) maxAge = n;\n }\n }\n\n // Max-Age takes precedence over Expires per RFC 6265, but we can't call\n // Date.now() deterministically here — encode as relative and resolve later.\n if (maxAge !== undefined) {\n expires = Date.now() + maxAge * 1000;\n }\n\n return { name, value, path, expires };\n}\n","import nodeDataChannel from \"node-datachannel\";\nimport {\n DATA_CHANNEL_LABEL,\n STUN_SERVERS,\n type IceCandidateInit,\n type SessionDescriptionInit,\n} from \"@exposeme/shared\";\nimport type { SignalingSession } from \"@exposeme/signaling\";\n\nconst { PeerConnection } = nodeDataChannel;\ntype PeerConnection = InstanceType<typeof PeerConnection>;\ntype DataChannel = ReturnType<PeerConnection[\"createDataChannel\"]>;\n\nexport interface PeerConnectionHandle {\n pc: PeerConnection;\n dc: DataChannel;\n close(): void;\n}\n\nexport interface ConnectOptions {\n iceServers?: string[];\n /** Milliseconds to wait for the data channel to open before rejecting. */\n timeoutMs?: number;\n}\n\nconst DEFAULT_TIMEOUT = 30_000;\n\n/**\n * Host role: create the peer + data channel, publish the offer/ICE via\n * signaling, apply the viewer's answer/ICE, resolve once the channel opens.\n */\nexport function connectHost(\n signaling: SignalingSession,\n opts: ConnectOptions = {}\n): Promise<PeerConnectionHandle> {\n return new Promise((resolve, reject) => {\n const pc = new PeerConnection(\"exposeme-host\", {\n iceServers: opts.iceServers ?? STUN_SERVERS,\n });\n const pending = new PendingCandidates(pc);\n\n pc.onLocalDescription((sdp, type) => {\n void signaling.writeOffer({ type: type as \"offer\", sdp });\n });\n pc.onLocalCandidate((candidate, mid) => {\n void signaling.pushHostCandidate({ candidate, sdpMid: mid });\n });\n\n // onValue re-delivers the answer on viewer reloads / Firebase re-syncs;\n // applying it twice throws inside the Firebase callback and kills the\n // process, so accept only the first answer and swallow later ones.\n let answerApplied = false;\n signaling.onAnswer((answer) => {\n if (answerApplied) return;\n answerApplied = true;\n try {\n pc.setRemoteDescription(answer.sdp, answer.type);\n pending.flush();\n } catch (err) {\n reject(err instanceof Error ? err : new Error(String(err)));\n }\n });\n signaling.onViewerCandidate((c) => pending.add(c));\n\n const dc = pc.createDataChannel(DATA_CHANNEL_LABEL);\n finishOnOpen(pc, dc, opts, resolve, reject);\n });\n}\n\n/**\n * Viewer role (also used by the Node loopback test): wait for the host's\n * offer, answer it, exchange ICE, resolve with the inbound data channel.\n */\nexport function connectViewer(\n signaling: SignalingSession,\n opts: ConnectOptions = {}\n): Promise<PeerConnectionHandle> {\n return new Promise((resolve, reject) => {\n const pc = new PeerConnection(\"exposeme-viewer\", {\n iceServers: opts.iceServers ?? STUN_SERVERS,\n });\n const pending = new PendingCandidates(pc);\n let dc: DataChannel | undefined;\n\n pc.onLocalDescription((sdp, type) => {\n void signaling.writeAnswer({ type: type as \"answer\", sdp });\n });\n pc.onLocalCandidate((candidate, mid) => {\n void signaling.pushViewerCandidate({ candidate, sdpMid: mid });\n });\n\n pc.onDataChannel((incoming) => {\n dc = incoming;\n finishOnOpen(pc, incoming, opts, resolve, reject);\n });\n\n signaling.onHostCandidate((c) => pending.add(c));\n\n // Fetch the offer (may need a brief poll if the host writes it slightly\n // after the viewer starts listening).\n void pollForOffer(signaling)\n .then((offer) => {\n pc.setRemoteDescription(offer.sdp, offer.type); // triggers answer\n pending.flush();\n })\n .catch(reject);\n\n // Guard: if onDataChannel never fires we still time out via finishOnOpen\n // only after dc exists, so add a fallback timer here too.\n if (opts.timeoutMs !== 0) {\n const t = setTimeout(() => {\n if (!dc) reject(new Error(\"Timed out waiting for data channel\"));\n }, opts.timeoutMs ?? DEFAULT_TIMEOUT);\n t.unref?.();\n }\n });\n}\n\nasync function pollForOffer(\n signaling: SignalingSession,\n attempts = 40,\n intervalMs = 250\n): Promise<SessionDescriptionInit> {\n for (let i = 0; i < attempts; i++) {\n const offer = await signaling.readOffer();\n if (offer) return offer;\n await delay(intervalMs);\n }\n throw new Error(\"Host offer not found\");\n}\n\nconst delay = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\n/** Buffers remote ICE candidates until the remote description is set. */\nclass PendingCandidates {\n private queue: IceCandidateInit[] = [];\n private ready = false;\n\n constructor(private readonly pc: PeerConnection) {}\n\n add(c: IceCandidateInit): void {\n if (this.ready) this.apply(c);\n else this.queue.push(c);\n }\n\n flush(): void {\n this.ready = true;\n for (const c of this.queue.splice(0)) this.apply(c);\n }\n\n private apply(c: IceCandidateInit): void {\n try {\n this.pc.addRemoteCandidate(c.candidate, c.sdpMid ?? \"0\");\n } catch {\n /* ignore malformed/duplicate candidates */\n }\n }\n}\n\nfunction finishOnOpen(\n pc: PeerConnection,\n dc: DataChannel,\n opts: ConnectOptions,\n resolve: (h: PeerConnectionHandle) => void,\n reject: (e: Error) => void\n): void {\n const handle: PeerConnectionHandle = {\n pc,\n dc,\n close() {\n try {\n dc.close();\n } catch {\n /* noop */\n }\n try {\n pc.close();\n } catch {\n /* noop */\n }\n },\n };\n\n let settled = false;\n const timer =\n opts.timeoutMs === 0\n ? undefined\n : setTimeout(() => {\n if (!settled) {\n settled = true;\n reject(new Error(\"Timed out waiting for data channel to open\"));\n }\n }, opts.timeoutMs ?? DEFAULT_TIMEOUT);\n timer?.unref?.();\n\n if (dc.isOpen()) {\n settled = true;\n if (timer) clearTimeout(timer);\n resolve(handle);\n return;\n }\n dc.onOpen(() => {\n if (settled) return;\n settled = true;\n if (timer) clearTimeout(timer);\n resolve(handle);\n });\n}\n","import http from \"node:http\";\nimport type { HeaderList } from \"@exposeme/shared\";\n\nexport interface ProxyRequest {\n method: string;\n /** Path + query relative to the tunneled origin. */\n url: string;\n headers: HeaderList;\n body: Uint8Array;\n}\n\nexport interface ProxyResponse {\n status: number;\n statusText: string;\n headers: HeaderList;\n body: Uint8Array;\n /** Set-Cookie values pulled out for the host cookie jar. */\n setCookies: string[];\n}\n\n/** Hop-by-hop headers that must not be forwarded in either direction. */\nconst HOP_BY_HOP = new Set([\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n]);\n\n/**\n * Forward one request to `localhost:<port>` and collect the full response.\n * `cookieHeader`, if provided, overrides the request's Cookie header (the host\n * owns cookies). `accept-encoding` is stripped so the local server returns an\n * identity body the browser can consume from a Service-Worker-built Response.\n */\nexport function proxyRequest(\n port: number,\n req: ProxyRequest,\n cookieHeader: string | null\n): Promise<ProxyResponse> {\n return new Promise((resolve, reject) => {\n const outHeaders: Record<string, string | string[]> = {};\n for (const [k, v] of req.headers) {\n const lk = k.toLowerCase();\n if (HOP_BY_HOP.has(lk)) continue;\n if (lk === \"host\") continue; // rewritten below\n if (lk === \"accept-encoding\") continue; // force identity\n if (lk === \"cookie\") continue; // host jar owns cookies\n // Merge duplicate headers into an array.\n const existing = outHeaders[lk];\n if (existing === undefined) outHeaders[lk] = v;\n else if (Array.isArray(existing)) existing.push(v);\n else outHeaders[lk] = [existing, v];\n }\n outHeaders[\"host\"] = `localhost:${port}`;\n if (cookieHeader) outHeaders[\"cookie\"] = cookieHeader;\n\n // Dev servers may bind only one of IPv4/IPv6 localhost (e.g. Vite on ::1),\n // so try 127.0.0.1 first and fall back to ::1 on connection refusal.\n const attempt = (host: string, fallback?: string): void => {\n const request = http.request(\n {\n host,\n port,\n method: req.method,\n path: req.url,\n headers: outHeaders,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on(\"data\", (c: Buffer) => chunks.push(c));\n res.on(\"end\", () => {\n const { headers, setCookies } = splitResponseHeaders(\n res.rawHeaders\n );\n resolve({\n status: res.statusCode ?? 502,\n statusText: res.statusMessage ?? \"\",\n headers,\n body: new Uint8Array(Buffer.concat(chunks)),\n setCookies,\n });\n });\n }\n );\n\n request.on(\"error\", (err: NodeJS.ErrnoException) => {\n if (fallback && err.code === \"ECONNREFUSED\") attempt(fallback);\n else reject(err);\n });\n if (req.body.length > 0) request.write(Buffer.from(req.body));\n request.end();\n };\n\n attempt(\"127.0.0.1\", \"::1\");\n });\n}\n\n/**\n * Convert Node's flat rawHeaders array into a HeaderList, pulling out Set-Cookie\n * (host-managed) and dropping hop-by-hop headers the browser shouldn't see.\n */\nfunction splitResponseHeaders(rawHeaders: string[]): {\n headers: HeaderList;\n setCookies: string[];\n} {\n const headers: HeaderList = [];\n const setCookies: string[] = [];\n for (let i = 0; i < rawHeaders.length; i += 2) {\n const name = rawHeaders[i]!;\n const value = rawHeaders[i + 1] ?? \"\";\n const lower = name.toLowerCase();\n if (lower === \"set-cookie\") {\n setCookies.push(value);\n continue; // not forwarded to the viewer\n }\n if (HOP_BY_HOP.has(lower)) continue;\n if (lower === \"content-length\") continue; // body length may change; SW recomputes\n headers.push([name, value]);\n }\n return { headers, setCookies };\n}\n","import {\n ChunkAssembler,\n chunkBody,\n decode,\n encode,\n type HeaderList,\n type TunnelMessage,\n} from \"@exposeme/shared\";\nimport { proxyRequest } from \"./proxy.js\";\nimport type { CookieJar } from \"./cookies.js\";\n\n/**\n * Minimal data-channel surface the tunnel needs. Matches node-datachannel's\n * DataChannel (and is trivially mockable in tests).\n */\nexport interface DataChannelLike {\n sendMessage(msg: string): boolean;\n onMessage(cb: (msg: string | Buffer | ArrayBuffer) => void): void;\n isOpen(): boolean;\n bufferedAmount?(): number;\n maxMessageSize?(): number;\n setBufferedAmountLowThreshold?(n: number): void;\n onBufferedAmountLow?(cb: () => void): void;\n}\n\n/** Pause sending when the channel's buffer grows beyond this many bytes. */\nconst BUFFER_HIGH_WATER = 1024 * 1024; // 1 MiB\n\ninterface InFlight {\n method: string;\n url: string;\n headers: HeaderList;\n body: ChunkAssembler;\n}\n\n/**\n * Attach the host-side request handler to a data channel: reassemble incoming\n * requests, proxy them to `localhost:<port>`, and stream responses back.\n * Returns a stats object updated as requests complete.\n */\nexport function serveTunnel(\n dc: DataChannelLike,\n port: number,\n jar: CookieJar\n): { requestCount: number } {\n const stats = { requestCount: 0 };\n const inflight = new Map<string, InFlight>();\n const lowSignal = new BufferedAmountSignal(dc);\n\n dc.onMessage((raw) => {\n const text = typeof raw === \"string\" ? raw : bufferToString(raw);\n let msg: TunnelMessage;\n try {\n msg = decode(text);\n } catch {\n return; // ignore non-protocol frames\n }\n handleMessage(msg);\n });\n\n function handleMessage(msg: TunnelMessage): void {\n switch (msg.t) {\n case \"req_head\":\n inflight.set(msg.id, {\n method: msg.method,\n url: msg.url,\n headers: msg.headers,\n body: new ChunkAssembler(),\n });\n break;\n case \"req_chunk\": {\n inflight.get(msg.id)?.body.push(msg.seq, msg.dataB64);\n break;\n }\n case \"req_end\": {\n const req = inflight.get(msg.id);\n if (!req) return;\n inflight.delete(msg.id);\n void complete(msg.id, req);\n break;\n }\n default:\n break; // responses flow the other direction; ignore\n }\n }\n\n async function complete(id: string, req: InFlight): Promise<void> {\n try {\n const cookieHeader = jar.header(req.url);\n const res = await proxyRequest(\n port,\n {\n method: req.method,\n url: req.url,\n headers: req.headers,\n body: req.body.concat(),\n },\n cookieHeader\n );\n jar.store(res.setCookies);\n stats.requestCount++;\n\n await send({\n t: \"res_head\",\n id,\n status: res.status,\n statusText: res.statusText,\n headers: res.headers,\n });\n for (const chunk of chunkBody(id, \"res\", res.body)) {\n await send(chunk);\n }\n await send({ t: \"res_end\", id });\n } catch (err) {\n await send({\n t: \"res_err\",\n id,\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n async function send(msg: TunnelMessage): Promise<void> {\n await lowSignal.awaitDrain();\n if (dc.isOpen()) dc.sendMessage(encode(msg));\n }\n\n return stats;\n}\n\n/** Simple backpressure helper around a channel's buffered-amount signal. */\nclass BufferedAmountSignal {\n private waiters: Array<() => void> = [];\n\n constructor(private readonly dc: DataChannelLike) {\n if (dc.setBufferedAmountLowThreshold && dc.onBufferedAmountLow) {\n dc.setBufferedAmountLowThreshold(BUFFER_HIGH_WATER / 2);\n dc.onBufferedAmountLow(() => {\n for (const w of this.waiters.splice(0)) w();\n });\n }\n }\n\n awaitDrain(): Promise<void> {\n const buffered = this.dc.bufferedAmount?.() ?? 0;\n if (buffered < BUFFER_HIGH_WATER) return Promise.resolve();\n return new Promise((resolve) => this.waiters.push(resolve));\n }\n}\n\nfunction bufferToString(buf: Buffer | ArrayBuffer): string {\n if (buf instanceof ArrayBuffer) return Buffer.from(buf).toString(\"utf-8\");\n return buf.toString(\"utf-8\");\n}\n"],"mappings":"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","names":["nodeDataChannel","get","nodeDataChannel"]}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
parseArgs,
|
|
4
|
+
run
|
|
5
|
+
} from "./chunk-VZEPY6PL.js";
|
|
6
|
+
|
|
7
|
+
// src/cli.ts
|
|
8
|
+
async function main() {
|
|
9
|
+
let port;
|
|
10
|
+
try {
|
|
11
|
+
({ port } = parseArgs(process.argv.slice(2)));
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error(err.message);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
await run({ port });
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error(`
|
|
20
|
+
\u2716 ${err.message}
|
|
21
|
+
`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
void main();
|
|
26
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts"],"sourcesContent":["import { parseArgs, run } from \"./index.js\";\n\nasync function main(): Promise<void> {\n let port: number;\n try {\n ({ port } = parseArgs(process.argv.slice(2)));\n } catch (err) {\n console.error((err as Error).message);\n process.exit(1);\n }\n\n try {\n await run({ port });\n } catch (err) {\n console.error(`\\n ✖ ${(err as Error).message}\\n`);\n process.exit(1);\n }\n}\n\nvoid main();\n"],"mappings":";;;;;;;AAEA,eAAe,OAAsB;AACnC,MAAI;AACJ,MAAI;AACF,KAAC,EAAE,KAAK,IAAI,UAAU,QAAQ,KAAK,MAAM,CAAC,CAAC;AAAA,EAC7C,SAAS,KAAK;AACZ,YAAQ,MAAO,IAAc,OAAO;AACpC,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,MAAI;AACF,UAAM,IAAI,EAAE,KAAK,CAAC;AAAA,EACpB,SAAS,KAAK;AACZ,YAAQ,MAAM;AAAA,WAAU,IAAc,OAAO;AAAA,CAAI;AACjD,YAAQ,KAAK,CAAC;AAAA,EAChB;AACF;AAEA,KAAK,KAAK;","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { CreateSessionResponse, HeaderList } from '@exposeme/shared';
|
|
2
|
+
import * as node_datachannel from 'node-datachannel';
|
|
3
|
+
import { SignalingSession } from '@exposeme/signaling';
|
|
4
|
+
|
|
5
|
+
/** Thin client for the ExposeMe Worker API. */
|
|
6
|
+
declare class WorkerApi {
|
|
7
|
+
private readonly baseUrl;
|
|
8
|
+
constructor(baseUrl: string);
|
|
9
|
+
/** POST /api/sessions — mint a code + session. */
|
|
10
|
+
createSession(): Promise<CreateSessionResponse>;
|
|
11
|
+
/** DELETE /api/sessions/:code — release the code early. Best-effort. */
|
|
12
|
+
releaseSession(code: string, sessionId: string): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A deliberately simple host-side cookie jar. Because the viewer's Service
|
|
17
|
+
* Worker cannot participate in normal cross-origin cookie handling, the host
|
|
18
|
+
* owns cookies for the tunneled origin: it captures `Set-Cookie` from responses
|
|
19
|
+
* and replays a `Cookie` header on matching outbound requests.
|
|
20
|
+
*
|
|
21
|
+
* v1 scope: single tunneled origin, name-keyed, honoring `Path` and `Expires`/
|
|
22
|
+
* `Max-Age`. Domain and `Secure`/`SameSite` are ignored (localhost, one origin).
|
|
23
|
+
*/
|
|
24
|
+
declare class CookieJar {
|
|
25
|
+
private cookies;
|
|
26
|
+
/** Record cookies from a response's `Set-Cookie` header values. */
|
|
27
|
+
store(setCookieValues: string[], now?: number): void;
|
|
28
|
+
/** Build a `Cookie` header value for a request path, or null if none apply. */
|
|
29
|
+
header(requestPath: string, now?: number): string | null;
|
|
30
|
+
/** Number of live cookies (for tests/introspection). */
|
|
31
|
+
get size(): number;
|
|
32
|
+
}
|
|
33
|
+
interface ParsedCookie {
|
|
34
|
+
name: string;
|
|
35
|
+
value: string;
|
|
36
|
+
path: string;
|
|
37
|
+
expires?: number;
|
|
38
|
+
}
|
|
39
|
+
/** Parse one Set-Cookie header value. Returns null if malformed. */
|
|
40
|
+
declare function parseSetCookie(raw: string): ParsedCookie | null;
|
|
41
|
+
|
|
42
|
+
declare const PeerConnection: new (peerName: string, config: node_datachannel.RtcConfig) => node_datachannel.PeerConnection;
|
|
43
|
+
type PeerConnection = InstanceType<typeof PeerConnection>;
|
|
44
|
+
type DataChannel = ReturnType<PeerConnection["createDataChannel"]>;
|
|
45
|
+
interface PeerConnectionHandle {
|
|
46
|
+
pc: PeerConnection;
|
|
47
|
+
dc: DataChannel;
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
interface ConnectOptions {
|
|
51
|
+
iceServers?: string[];
|
|
52
|
+
/** Milliseconds to wait for the data channel to open before rejecting. */
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Host role: create the peer + data channel, publish the offer/ICE via
|
|
57
|
+
* signaling, apply the viewer's answer/ICE, resolve once the channel opens.
|
|
58
|
+
*/
|
|
59
|
+
declare function connectHost(signaling: SignalingSession, opts?: ConnectOptions): Promise<PeerConnectionHandle>;
|
|
60
|
+
/**
|
|
61
|
+
* Viewer role (also used by the Node loopback test): wait for the host's
|
|
62
|
+
* offer, answer it, exchange ICE, resolve with the inbound data channel.
|
|
63
|
+
*/
|
|
64
|
+
declare function connectViewer(signaling: SignalingSession, opts?: ConnectOptions): Promise<PeerConnectionHandle>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Minimal data-channel surface the tunnel needs. Matches node-datachannel's
|
|
68
|
+
* DataChannel (and is trivially mockable in tests).
|
|
69
|
+
*/
|
|
70
|
+
interface DataChannelLike {
|
|
71
|
+
sendMessage(msg: string): boolean;
|
|
72
|
+
onMessage(cb: (msg: string | Buffer | ArrayBuffer) => void): void;
|
|
73
|
+
isOpen(): boolean;
|
|
74
|
+
bufferedAmount?(): number;
|
|
75
|
+
maxMessageSize?(): number;
|
|
76
|
+
setBufferedAmountLowThreshold?(n: number): void;
|
|
77
|
+
onBufferedAmountLow?(cb: () => void): void;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Attach the host-side request handler to a data channel: reassemble incoming
|
|
81
|
+
* requests, proxy them to `localhost:<port>`, and stream responses back.
|
|
82
|
+
* Returns a stats object updated as requests complete.
|
|
83
|
+
*/
|
|
84
|
+
declare function serveTunnel(dc: DataChannelLike, port: number, jar: CookieJar): {
|
|
85
|
+
requestCount: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
interface ProxyRequest {
|
|
89
|
+
method: string;
|
|
90
|
+
/** Path + query relative to the tunneled origin. */
|
|
91
|
+
url: string;
|
|
92
|
+
headers: HeaderList;
|
|
93
|
+
body: Uint8Array;
|
|
94
|
+
}
|
|
95
|
+
interface ProxyResponse {
|
|
96
|
+
status: number;
|
|
97
|
+
statusText: string;
|
|
98
|
+
headers: HeaderList;
|
|
99
|
+
body: Uint8Array;
|
|
100
|
+
/** Set-Cookie values pulled out for the host cookie jar. */
|
|
101
|
+
setCookies: string[];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Forward one request to `localhost:<port>` and collect the full response.
|
|
105
|
+
* `cookieHeader`, if provided, overrides the request's Cookie header (the host
|
|
106
|
+
* owns cookies). `accept-encoding` is stripped so the local server returns an
|
|
107
|
+
* identity body the browser can consume from a Service-Worker-built Response.
|
|
108
|
+
*/
|
|
109
|
+
declare function proxyRequest(port: number, req: ProxyRequest, cookieHeader: string | null): Promise<ProxyResponse>;
|
|
110
|
+
|
|
111
|
+
interface RunOptions {
|
|
112
|
+
port: number;
|
|
113
|
+
env?: NodeJS.ProcessEnv;
|
|
114
|
+
}
|
|
115
|
+
/** Parse `exposeme <port>` argv into a port number. */
|
|
116
|
+
declare function parseArgs(argv: string[]): {
|
|
117
|
+
port: number;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Run the tunnel host: mint a code, publish signaling, accept a viewer, and
|
|
121
|
+
* proxy its requests to localhost:<port>. Resolves when the process is asked to
|
|
122
|
+
* stop; wires SIGINT for graceful cleanup.
|
|
123
|
+
*/
|
|
124
|
+
declare function run(opts: RunOptions): Promise<void>;
|
|
125
|
+
|
|
126
|
+
export { CookieJar, type DataChannelLike, type RunOptions, WorkerApi, connectHost, connectViewer, parseArgs, parseSetCookie, proxyRequest, run, serveTunnel };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
CookieJar,
|
|
4
|
+
WorkerApi,
|
|
5
|
+
connectHost,
|
|
6
|
+
connectViewer,
|
|
7
|
+
parseArgs,
|
|
8
|
+
parseSetCookie,
|
|
9
|
+
proxyRequest,
|
|
10
|
+
run,
|
|
11
|
+
serveTunnel
|
|
12
|
+
} from "./chunk-VZEPY6PL.js";
|
|
13
|
+
export {
|
|
14
|
+
CookieJar,
|
|
15
|
+
WorkerApi,
|
|
16
|
+
connectHost,
|
|
17
|
+
connectViewer,
|
|
18
|
+
parseArgs,
|
|
19
|
+
parseSetCookie,
|
|
20
|
+
proxyRequest,
|
|
21
|
+
run,
|
|
22
|
+
serveTunnel
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "exposeme",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-config, P2P localhost tunnel with a 6-digit code UX.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/iamanishroy/exposeme.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/iamanishroy/exposeme#readme",
|
|
12
|
+
"keywords": ["tunnel", "localhost", "webrtc", "p2p", "ngrok", "share"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"exposeme": "./dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": ["dist"],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup",
|
|
23
|
+
"dev": "tsup --watch",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"clean": "rm -rf dist .turbo"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"dotenv": "^16.4.5",
|
|
30
|
+
"firebase": "^10.14.1",
|
|
31
|
+
"kleur": "^4.1.5",
|
|
32
|
+
"node-datachannel": "^0.32.3"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@exposeme/shared": "workspace:*",
|
|
36
|
+
"@exposeme/signaling": "workspace:*",
|
|
37
|
+
"@types/node": "^22.10.1",
|
|
38
|
+
"tsup": "^8.3.0",
|
|
39
|
+
"typescript": "^5.6.3",
|
|
40
|
+
"vitest": "^2.1.8"
|
|
41
|
+
}
|
|
42
|
+
}
|