vibeshare-live 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1018 @@
1
+ // src/consent.ts
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { createConsentLedger } from "@pooriaarab/vibe-core";
6
+ function vibeHome() {
7
+ return process.env["VIBESHARE_HOME"] ?? join(homedir(), ".vibeshare");
8
+ }
9
+ function ensureDir(dir) {
10
+ mkdirSync(dir, { recursive: true, mode: 448 });
11
+ }
12
+ var FileConsentStore = class {
13
+ file;
14
+ constructor(file = join(vibeHome(), "consent.json")) {
15
+ this.file = file;
16
+ }
17
+ load() {
18
+ try {
19
+ if (!existsSync(this.file)) return [];
20
+ const parsed = JSON.parse(readFileSync(this.file, "utf8"));
21
+ if (!Array.isArray(parsed)) return [];
22
+ return parsed.filter(
23
+ (g) => typeof g === "object" && g !== null && typeof g.scope === "string" && typeof g.grantedAt === "string"
24
+ );
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+ save(grants) {
30
+ ensureDir(join(this.file, ".."));
31
+ writeFileSync(this.file, JSON.stringify(grants, null, 2), { mode: 384 });
32
+ try {
33
+ chmodSync(this.file, 384);
34
+ } catch {
35
+ }
36
+ }
37
+ };
38
+ function loadLedger(store) {
39
+ return createConsentLedger(store ?? new FileConsentStore());
40
+ }
41
+ function sharesDir() {
42
+ return join(vibeHome(), "shares");
43
+ }
44
+ function writeActiveShare(rec) {
45
+ const dir = sharesDir();
46
+ ensureDir(dir);
47
+ const file = join(dir, `${rec.id}.json`);
48
+ writeFileSync(file, JSON.stringify(rec, null, 2), { mode: 384 });
49
+ try {
50
+ chmodSync(file, 384);
51
+ } catch {
52
+ }
53
+ }
54
+ function readActiveShare(id) {
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(join(sharesDir(), `${id}.json`), "utf8"));
57
+ return isActiveShareRecord(parsed) ? parsed : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ function listActiveShares() {
63
+ try {
64
+ const dir = sharesDir();
65
+ if (!existsSync(dir)) return [];
66
+ const records = [];
67
+ for (const name of readdirSync(dir)) {
68
+ if (!name.endsWith(".json")) continue;
69
+ try {
70
+ const parsed = JSON.parse(readFileSync(join(dir, name), "utf8"));
71
+ if (isActiveShareRecord(parsed)) records.push(parsed);
72
+ } catch {
73
+ }
74
+ }
75
+ return records.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+ function clearActiveShare(id) {
81
+ try {
82
+ rmSync(join(sharesDir(), `${id}.json`), { force: true });
83
+ } catch {
84
+ }
85
+ }
86
+ function isActiveShareRecord(v) {
87
+ if (typeof v !== "object" || v === null) return false;
88
+ const r = v;
89
+ return typeof r["id"] === "string" && typeof r["url"] === "string" && typeof r["port"] === "number" && typeof r["hostToken"] === "string" && typeof r["pid"] === "number" && typeof r["startedAt"] === "string";
90
+ }
91
+
92
+ // src/spectatorPage.ts
93
+ function spectatorPage(share) {
94
+ const config = JSON.stringify({
95
+ id: share.id,
96
+ name: share.name,
97
+ access: share.access
98
+ }).replace(/</g, "\\u003c");
99
+ return `<!DOCTYPE html>
100
+ <html lang="en">
101
+ <head>
102
+ <meta charset="UTF-8">
103
+ <meta name="viewport" content="width=device-width, initial-scale=1">
104
+ <title>vibeshare \xB7 ${escapeHtml(share.name)}</title>
105
+ <style>
106
+ :root{ --bg:#0a0b0f; --panel:#12141a; --panel-2:#171a22; --panel-3:#1d2129;
107
+ --border:rgba(255,255,255,.08); --border-2:rgba(255,255,255,.14);
108
+ --text:#edeef3; --dim:#9aa0b2; --faint:#666c7c; --cyan:#67e8f9;
109
+ --violet:#c4b5fd; --green:#7ee787; --red:#ff8b85;
110
+ --mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;
111
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; }
112
+ *{ box-sizing:border-box; }
113
+ body{ margin:0; background:var(--bg); color:var(--text); font-family:var(--sans);
114
+ -webkit-font-smoothing:antialiased; min-height:100vh; }
115
+ .app{ max-width:860px; margin:0 auto; padding:26px 22px 48px; }
116
+ .topbar{ display:flex; align-items:center; justify-content:space-between; gap:14px;
117
+ padding-bottom:18px; margin-bottom:20px; border-bottom:1px solid var(--border); flex-wrap:wrap; }
118
+ .brand{ font-size:17px; font-weight:650; letter-spacing:-.01em; }
119
+ .brand span{ color:var(--faint); font-size:12.5px; font-weight:450; margin-left:9px; }
120
+ .p2p{ font-family:var(--mono); font-size:12px; color:var(--dim); background:var(--panel);
121
+ border:1px solid var(--border-2); border-radius:999px; padding:6px 13px; white-space:nowrap; }
122
+ .p2p b{ color:var(--green); font-weight:600; }
123
+ .meta{ display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:14px; }
124
+ .badge{ display:inline-flex; align-items:center; gap:6px; font-size:11.5px; font-weight:700;
125
+ letter-spacing:.03em; color:var(--cyan); background:rgba(103,232,249,.1);
126
+ border:1px solid rgba(103,232,249,.3); border-radius:999px; padding:5px 11px; }
127
+ .badge.collab{ color:var(--violet); background:rgba(196,181,253,.12); border-color:rgba(196,181,253,.35); }
128
+ .badge.ended{ color:var(--red); background:rgba(255,139,133,.1); border-color:rgba(255,139,133,.35); }
129
+ .badge .d{ width:6px; height:6px; border-radius:50%; background:currentColor; }
130
+ .count{ font-family:var(--mono); font-size:12.5px; color:var(--dim); }
131
+ .count b{ color:var(--text); }
132
+ .term{ background:#0d0f14; border:1px solid var(--border); border-radius:12px; overflow:hidden; }
133
+ .chrome{ display:flex; align-items:center; gap:6px; padding:10px 12px;
134
+ background:var(--panel-2); border-bottom:1px solid var(--border); }
135
+ .chrome span{ width:9px; height:9px; border-radius:50%; background:#3a3f4b; }
136
+ .chrome .path{ margin-left:8px; font-family:var(--mono); font-size:11.5px; color:var(--faint);
137
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
138
+ .body{ font-family:var(--mono); font-size:13px; line-height:1.7; padding:14px 16px;
139
+ min-height:280px; max-height:56vh; overflow-y:auto; }
140
+ .line{ white-space:pre-wrap; word-break:break-word; }
141
+ .line.stderr{ color:var(--dim); }
142
+ .line.milestone{ color:var(--violet); }
143
+ .line.system{ color:var(--faint); font-style:italic; }
144
+ .panel{ background:var(--panel); border:1px solid var(--border); border-radius:12px;
145
+ padding:18px; margin-bottom:16px; }
146
+ .panel h1{ font-size:15px; margin:0 0 4px; }
147
+ .panel p{ font-size:13px; color:var(--dim); margin:0 0 14px; }
148
+ .row{ display:flex; gap:10px; flex-wrap:wrap; }
149
+ input{ flex:1; min-width:160px; font-family:var(--mono); font-size:13px; color:var(--text);
150
+ background:var(--panel-2); border:1px solid var(--border); border-radius:8px; padding:10px 12px; }
151
+ input:focus{ outline:none; border-color:var(--cyan); }
152
+ .err{ color:var(--red); font-size:12.5px; margin-top:10px; display:none; }
153
+ button{ font-family:inherit; font-size:13px; font-weight:600; color:var(--text);
154
+ background:var(--panel-3); border:1px solid var(--border-2); border-radius:8px;
155
+ padding:10px 16px; cursor:pointer; }
156
+ button:hover{ background:#242933; }
157
+ button:disabled{ opacity:.6; cursor:default; }
158
+ .join-btn{ margin-top:14px; background:#3a3160; border-color:rgba(196,181,253,.5); color:#f2eeff; }
159
+ .join-btn:hover{ background:#453a78; }
160
+ .join-btn.pending{ background:var(--panel-3); border-color:var(--border-2); color:var(--dim); }
161
+ .join-btn.joined{ background:rgba(126,231,135,.12); border-color:rgba(126,231,135,.4); color:var(--green); }
162
+ .hidden{ display:none !important; }
163
+ </style>
164
+ </head>
165
+ <body>
166
+ <div class="app">
167
+ <header class="topbar">
168
+ <div class="brand">vibeshare<span id="sessionName"></span></div>
169
+ <div class="p2p"><b>\u25CF</b> p2p \xB7 nothing stored on a server</div>
170
+ </header>
171
+
172
+ <div class="panel" id="joinPanel">
173
+ <h1>Watch this session live</h1>
174
+ <p>Read-only. The stream comes straight from the host machine.</p>
175
+ <div class="row">
176
+ <input id="nameInput" placeholder="your name (optional)" maxlength="32">
177
+ <input id="passInput" placeholder="passphrase" type="password" class="hidden">
178
+ <button id="watchBtn">Watch</button>
179
+ </div>
180
+ <div class="err" id="joinErr"></div>
181
+ </div>
182
+
183
+ <div class="meta">
184
+ <span class="badge" id="badge"><span class="d"></span> CONNECTING</span>
185
+ <span class="count">\u{1F441} <b id="watching">0</b> watching</span>
186
+ </div>
187
+
188
+ <div class="term">
189
+ <div class="chrome"><span></span><span></span><span></span><span class="path" id="chromePath"></span></div>
190
+ <div class="body" id="termBody"></div>
191
+ </div>
192
+
193
+ <button class="join-btn hidden" id="reqBtn">Request to join</button>
194
+ </div>
195
+
196
+ <script>
197
+ (function(){
198
+ "use strict";
199
+ var CFG = ${config};
200
+ var base = location.origin + "/s/" + CFG.id;
201
+ var viewer = null, source = null;
202
+
203
+ var joinPanel = document.getElementById("joinPanel");
204
+ var joinErr = document.getElementById("joinErr");
205
+ var passInput = document.getElementById("passInput");
206
+ var badge = document.getElementById("badge");
207
+ var watchingEl = document.getElementById("watching");
208
+ var termBody = document.getElementById("termBody");
209
+ var reqBtn = document.getElementById("reqBtn");
210
+
211
+ document.getElementById("sessionName").textContent = " \xB7 " + CFG.name;
212
+ document.getElementById("chromePath").textContent = CFG.name + " \u2014 live";
213
+
214
+ function setBadge(cls, text){ badge.className = "badge" + (cls ? " " + cls : ""); badge.innerHTML = '<span class="d"></span> ' + text; }
215
+ function showErr(msg){ joinErr.textContent = msg; joinErr.style.display = "block"; }
216
+
217
+ function line(cls, text){
218
+ var d = document.createElement("div");
219
+ d.className = "line " + cls;
220
+ d.textContent = text;
221
+ termBody.appendChild(d);
222
+ while(termBody.childNodes.length > 800) termBody.removeChild(termBody.firstChild);
223
+ termBody.scrollTop = termBody.scrollHeight;
224
+ }
225
+
226
+ fetch(base + "/meta").then(function(r){ return r.json(); }).then(function(meta){
227
+ if(meta.state !== "live"){ ended(meta.state); joinPanel.classList.add("hidden"); return; }
228
+ if(meta.requiresPassphrase) passInput.classList.remove("hidden");
229
+ watchingEl.textContent = meta.watching;
230
+ }).catch(function(){ setBadge("ended", "UNREACHABLE"); });
231
+
232
+ document.getElementById("watchBtn").addEventListener("click", function(){
233
+ joinErr.style.display = "none";
234
+ fetch(base + "/join", {
235
+ method: "POST",
236
+ headers: { "content-type": "application/json" },
237
+ body: JSON.stringify({ name: document.getElementById("nameInput").value, pass: passInput.value || undefined })
238
+ }).then(function(r){
239
+ if(r.status === 403){ passInput.classList.remove("hidden"); showErr("passphrase required or wrong \u2014 try again"); return null; }
240
+ if(!r.ok){ showErr("could not join (" + r.status + ")"); return null; }
241
+ return r.json();
242
+ }).then(function(res){
243
+ if(!res) return;
244
+ viewer = res;
245
+ joinPanel.classList.add("hidden");
246
+ setBadge("", "SPECTATING \xB7 read-only");
247
+ if(CFG.access === "invite") reqBtn.classList.remove("hidden");
248
+ openStream();
249
+ }).catch(function(){ showErr("could not reach the host"); });
250
+ });
251
+
252
+ function openStream(){
253
+ source = new EventSource(base + "/stream?token=" + encodeURIComponent(viewer.token));
254
+ source.addEventListener("entry", function(ev){
255
+ var e = JSON.parse(ev.data);
256
+ var cls = e.type === "milestone" ? "milestone" : e.type === "system" ? "system" : (e.stream === "stderr" ? "stderr" : "");
257
+ line(cls, e.text);
258
+ });
259
+ source.addEventListener("viewers", function(ev){ watchingEl.textContent = JSON.parse(ev.data).watching; });
260
+ source.addEventListener("join-approved", function(){
261
+ setBadge("collab", "COLLABORATING \xB7 live");
262
+ reqBtn.className = "join-btn joined"; reqBtn.disabled = true; reqBtn.textContent = "You\u2019re in \u2014 live";
263
+ line("system", "\u2192 handed off: the host approved you as a collaborator.");
264
+ });
265
+ source.addEventListener("join-denied", function(){
266
+ reqBtn.className = "join-btn"; reqBtn.disabled = false; reqBtn.textContent = "Request denied \u2014 try again";
267
+ });
268
+ source.addEventListener("kicked", function(){ source.close(); ended("kicked"); });
269
+ source.addEventListener("ended", function(ev){ source.close(); ended(JSON.parse(ev.data).state); });
270
+ source.onerror = function(){ if(badge.className.indexOf("ended") === -1) setBadge("", "RECONNECTING\u2026"); };
271
+ source.onopen = function(){ if(!viewer || reqBtn.className.indexOf("joined") === -1) setBadge("", "SPECTATING \xB7 read-only"); };
272
+ }
273
+
274
+ reqBtn.addEventListener("click", function(){
275
+ reqBtn.disabled = true; reqBtn.className = "join-btn pending"; reqBtn.textContent = "Waiting for host approval\u2026";
276
+ fetch(base + "/request-join", {
277
+ method: "POST",
278
+ headers: { "content-type": "application/json" },
279
+ body: JSON.stringify({ token: viewer.token })
280
+ }).then(function(r){
281
+ if(!r.ok){ reqBtn.className = "join-btn"; reqBtn.disabled = false; reqBtn.textContent = "Request to join"; }
282
+ }).catch(function(){ reqBtn.className = "join-btn"; reqBtn.disabled = false; });
283
+ });
284
+
285
+ function ended(state){
286
+ setBadge("ended", state === "kicked" ? "REMOVED BY HOST" : "SHARE ENDED");
287
+ reqBtn.classList.add("hidden");
288
+ line("system", state === "kicked" ? "\u2014 the host removed you from this share \u2014" : "\u2014 the host ended this share \u2014");
289
+ }
290
+
291
+ window.addEventListener("beforeunload", function(){
292
+ if(viewer && navigator.sendBeacon) navigator.sendBeacon(base + "/leave?token=" + encodeURIComponent(viewer.token), "");
293
+ });
294
+ })();
295
+ </script>
296
+ </body>
297
+ </html>`;
298
+ }
299
+ function escapeHtml(s) {
300
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
301
+ }
302
+
303
+ // src/types.ts
304
+ var ShareError = class extends Error {
305
+ code;
306
+ constructor(code, message) {
307
+ super(message);
308
+ this.name = "ShareError";
309
+ this.code = code;
310
+ }
311
+ };
312
+
313
+ // src/utils.ts
314
+ import { randomBytes, scryptSync, timingSafeEqual } from "crypto";
315
+ function newShareId() {
316
+ return randomBytes(9).toString("base64url");
317
+ }
318
+ function newToken(bytes = 16) {
319
+ return randomBytes(bytes).toString("hex");
320
+ }
321
+ var EXPIRY_RE = /^(\d+)(m|h|d)$/;
322
+ function parseExpiry(spec) {
323
+ const s = spec.trim().toLowerCase();
324
+ if (s === "stop" || s === "never" || s === "until-stop") return null;
325
+ const m = EXPIRY_RE.exec(s);
326
+ if (!m) {
327
+ throw new Error(`invalid expiry "${spec}" \u2014 use 1h, 24h, 7d, \u2026 or "stop"`);
328
+ }
329
+ const n = Number(m[1]);
330
+ if (!Number.isSafeInteger(n) || n <= 0) {
331
+ throw new Error(`invalid expiry "${spec}" \u2014 duration must be positive`);
332
+ }
333
+ const unit = m[2];
334
+ const mult = unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5;
335
+ return n * mult;
336
+ }
337
+ function hashPassphrase(passphrase) {
338
+ const salt = randomBytes(16);
339
+ const hash = scryptSync(passphrase, salt, 32);
340
+ return `scrypt$${salt.toString("hex")}$${hash.toString("hex")}`;
341
+ }
342
+ function verifyPassphrase(passphrase, stored) {
343
+ const parts = stored.split("$");
344
+ if (parts.length !== 3 || parts[0] !== "scrypt") return false;
345
+ const salt = Buffer.from(parts[1], "hex");
346
+ const expected = Buffer.from(parts[2], "hex");
347
+ const actual = scryptSync(passphrase, salt, expected.length);
348
+ return timingSafeEqual(actual, expected);
349
+ }
350
+
351
+ // src/localHttp.ts
352
+ import { createServer } from "http";
353
+ import { networkInterfaces } from "os";
354
+ import { timingSafeEqual as timingSafeEqual2 } from "crypto";
355
+ var MAX_BODY = 64 * 1024;
356
+ var LocalHttpTransport = class {
357
+ kind = "local-http";
358
+ hostToken;
359
+ #host;
360
+ #port;
361
+ #baseUrl;
362
+ #onStop;
363
+ #shares = /* @__PURE__ */ new Map();
364
+ #gone = /* @__PURE__ */ new Set();
365
+ #sockets = /* @__PURE__ */ new Set();
366
+ #server = null;
367
+ #boundPort = 0;
368
+ constructor(opts = {}) {
369
+ this.#host = opts.host ?? "127.0.0.1";
370
+ this.#port = opts.port ?? 0;
371
+ this.#baseUrl = opts.baseUrl;
372
+ this.hostToken = opts.hostToken ?? newToken(24);
373
+ this.#onStop = opts.onStopRequested;
374
+ }
375
+ /** Bind the listener. Must be called before serve(). Idempotent. */
376
+ async listen() {
377
+ if (this.#server) return;
378
+ const server = createServer((req, res) => {
379
+ void this.#route(req, res).catch((err) => this.#handleError(res, err));
380
+ });
381
+ server.on("connection", (socket) => {
382
+ this.#sockets.add(socket);
383
+ socket.on("close", () => this.#sockets.delete(socket));
384
+ });
385
+ await new Promise((resolve, reject) => {
386
+ server.once("error", reject);
387
+ server.listen(this.#port, this.#host, () => resolve());
388
+ });
389
+ const addr = server.address();
390
+ this.#boundPort = typeof addr === "object" && addr !== null ? addr.port : this.#port;
391
+ this.#server = server;
392
+ }
393
+ /** The port actually bound (useful with port 0). */
394
+ get port() {
395
+ return this.#boundPort;
396
+ }
397
+ async serve(share, feed, viewers) {
398
+ if (!this.#server) await this.listen();
399
+ const ctx = { share, feed, viewers, streams: /* @__PURE__ */ new Map() };
400
+ this.#shares.set(share.id, ctx);
401
+ this.#gone.delete(share.id);
402
+ this.#wire(ctx);
403
+ return `${this.#publicBase()}/s/${share.id}`;
404
+ }
405
+ async unserve(shareId) {
406
+ const ctx = this.#shares.get(shareId);
407
+ if (!ctx) return;
408
+ this.#shares.delete(shareId);
409
+ this.#gone.add(shareId);
410
+ this.#endAllStreams(ctx, ctx.share.state);
411
+ }
412
+ async close() {
413
+ for (const ctx of this.#shares.values()) this.#endAllStreams(ctx, ctx.share.state);
414
+ this.#shares.clear();
415
+ const server = this.#server;
416
+ this.#server = null;
417
+ if (!server) return;
418
+ for (const socket of this.#sockets) socket.destroy();
419
+ await new Promise((resolve) => server.close(() => resolve()));
420
+ }
421
+ /** Number of shares currently served (diagnostics/tests). */
422
+ get shareCount() {
423
+ return this.#shares.size;
424
+ }
425
+ // ------------------------------------------------------------- wiring
426
+ #wire(ctx) {
427
+ ctx.feed.on("close", () => {
428
+ if (this.#shares.has(ctx.share.id)) {
429
+ this.#shares.delete(ctx.share.id);
430
+ this.#gone.add(ctx.share.id);
431
+ }
432
+ this.#endAllStreams(ctx, ctx.share.state);
433
+ });
434
+ const broadcastCount = () => this.#broadcastViewers(ctx);
435
+ ctx.viewers.on("join", broadcastCount);
436
+ ctx.viewers.on("leave", (v) => {
437
+ this.#closeViewerStreams(ctx, v, null);
438
+ broadcastCount();
439
+ });
440
+ ctx.viewers.on("kick", (v) => {
441
+ this.#closeViewerStreams(ctx, v, "kicked");
442
+ broadcastCount();
443
+ });
444
+ ctx.viewers.on("approve", (v) => this.#sendToViewer(ctx, v, "join-approved", {}));
445
+ ctx.viewers.on("deny", (v) => this.#sendToViewer(ctx, v, "join-denied", {}));
446
+ }
447
+ // ------------------------------------------------------------- routing
448
+ async #route(req, res) {
449
+ setSecurityHeaders(res);
450
+ const url = new URL(req.url ?? "/", "http://localhost");
451
+ const path = url.pathname;
452
+ if (path.startsWith("/control/")) {
453
+ this.#routeControl(req, res, path, url);
454
+ return;
455
+ }
456
+ const m = /^\/s\/([A-Za-z0-9_-]+)(?:\/(meta|stream|join|request-join|leave))?\/?$/.exec(path);
457
+ if (!m) {
458
+ sendJson(res, 404, { error: "not found" });
459
+ return;
460
+ }
461
+ const shareId = m[1];
462
+ const action = m[2] ?? "page";
463
+ const ctx = this.#shares.get(shareId);
464
+ if (!ctx) {
465
+ const status = this.#gone.has(shareId) ? 410 : 404;
466
+ if (action === "page") {
467
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
468
+ res.end('<!DOCTYPE html><title>vibeshare</title><body style="background:#0a0b0f;color:#edeef3;font-family:sans-serif;display:grid;place-items:center;min-height:100vh;margin:0"><p>This share has ended.</p></body>');
469
+ } else {
470
+ sendJson(res, status, { error: status === 410 ? "share ended" : "not found" });
471
+ }
472
+ return;
473
+ }
474
+ switch (action) {
475
+ case "page": {
476
+ if (req.method !== "GET") return void sendJson(res, 405, { error: "method not allowed" });
477
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
478
+ res.end(spectatorPage(ctx.share));
479
+ return;
480
+ }
481
+ case "meta": {
482
+ sendJson(res, 200, {
483
+ id: ctx.share.id,
484
+ name: ctx.share.name,
485
+ access: ctx.share.access,
486
+ state: ctx.share.state,
487
+ requiresPassphrase: ctx.share.passphraseHash !== null,
488
+ watching: streamCount(ctx)
489
+ });
490
+ return;
491
+ }
492
+ case "join": {
493
+ if (req.method !== "POST") return void sendJson(res, 405, { error: "method not allowed" });
494
+ if (ctx.share.state !== "live") throw new ShareError("not-live", "share has ended");
495
+ const body = await readJsonBody(req);
496
+ if (ctx.share.passphraseHash !== null) {
497
+ const pass = typeof body["pass"] === "string" ? body["pass"] : "";
498
+ if (pass.length === 0) throw new ShareError("passphrase-required", "passphrase required");
499
+ if (!verifyPassphrase(pass, ctx.share.passphraseHash)) {
500
+ throw new ShareError("passphrase-invalid", "wrong passphrase");
501
+ }
502
+ }
503
+ const viewer = ctx.viewers.add(typeof body["name"] === "string" ? body["name"] : void 0);
504
+ sendJson(res, 200, { viewerId: viewer.id, token: viewer.token, role: viewer.role });
505
+ return;
506
+ }
507
+ case "stream": {
508
+ if (req.method !== "GET") return void sendJson(res, 405, { error: "method not allowed" });
509
+ const viewer = ctx.viewers.getByToken(url.searchParams.get("token") ?? "");
510
+ if (!viewer) return void sendJson(res, 401, { error: "unknown viewer token" });
511
+ this.#openStream(ctx, viewer, res);
512
+ return;
513
+ }
514
+ case "request-join": {
515
+ if (req.method !== "POST") return void sendJson(res, 405, { error: "method not allowed" });
516
+ const body = await readJsonBody(req);
517
+ const viewer = ctx.viewers.getByToken(typeof body["token"] === "string" ? body["token"] : "");
518
+ if (!viewer) return void sendJson(res, 401, { error: "unknown viewer token" });
519
+ ctx.viewers.requestJoin(viewer.id);
520
+ sendJson(res, 202, { status: "pending" });
521
+ return;
522
+ }
523
+ case "leave": {
524
+ if (req.method !== "POST") return void sendJson(res, 405, { error: "method not allowed" });
525
+ let token = url.searchParams.get("token") ?? "";
526
+ if (token.length === 0) {
527
+ const body = await readJsonBody(req);
528
+ if (typeof body["token"] === "string") token = body["token"];
529
+ }
530
+ const viewer = ctx.viewers.getByToken(token);
531
+ if (viewer) ctx.viewers.leave(viewer.id);
532
+ sendJson(res, 200, { ok: true });
533
+ return;
534
+ }
535
+ }
536
+ }
537
+ #routeControl(req, res, path, url) {
538
+ if (!isLoopback(req.socket.remoteAddress) || !tokenMatches(req.headers.authorization, this.hostToken)) {
539
+ sendJson(res, 401, { error: "host-only control API" });
540
+ return;
541
+ }
542
+ const respond = (fn) => {
543
+ try {
544
+ sendJson(res, 200, fn());
545
+ } catch (err) {
546
+ this.#handleError(res, err);
547
+ }
548
+ };
549
+ void (async () => {
550
+ switch (path) {
551
+ case "/control/shares":
552
+ respond(() => ({
553
+ shares: [...this.#shares.values()].map((c) => ({
554
+ id: c.share.id,
555
+ name: c.share.name,
556
+ access: c.share.access,
557
+ state: c.share.state,
558
+ watching: streamCount(c)
559
+ }))
560
+ }));
561
+ return;
562
+ case "/control/viewers": {
563
+ const ctx = this.#mustCtx(url.searchParams.get("share") ?? "");
564
+ respond(() => ({ viewers: ctx.viewers.list().map(publicViewer), watching: streamCount(ctx) }));
565
+ return;
566
+ }
567
+ case "/control/approve":
568
+ case "/control/deny":
569
+ case "/control/kick": {
570
+ if (req.method !== "POST") return void sendJson(res, 405, { error: "method not allowed" });
571
+ const body = await readJsonBody(req);
572
+ const ctx = this.#mustCtx(typeof body["share"] === "string" ? body["share"] : "");
573
+ const viewerId = typeof body["viewer"] === "string" ? body["viewer"] : "";
574
+ const action = path.slice("/control/".length);
575
+ respond(() => ({ viewer: publicViewer(ctx.viewers[action](viewerId)) }));
576
+ return;
577
+ }
578
+ case "/control/stop": {
579
+ if (req.method !== "POST") return void sendJson(res, 405, { error: "method not allowed" });
580
+ const body = await readJsonBody(req);
581
+ const shareId = typeof body["share"] === "string" ? body["share"] : "";
582
+ this.#mustCtx(shareId);
583
+ sendJson(res, 200, { stopped: shareId });
584
+ if (this.#onStop) this.#onStop(shareId);
585
+ else {
586
+ const ctx = this.#shares.get(shareId);
587
+ if (ctx) {
588
+ await this.unserve(shareId);
589
+ ctx.feed.close();
590
+ }
591
+ }
592
+ return;
593
+ }
594
+ default:
595
+ sendJson(res, 404, { error: "not found" });
596
+ }
597
+ })().catch((err) => this.#handleError(res, err));
598
+ }
599
+ #mustCtx(shareId) {
600
+ const ctx = this.#shares.get(shareId);
601
+ if (!ctx) throw new ShareError("not-found", `no live share ${shareId}`);
602
+ return ctx;
603
+ }
604
+ #handleError(res, err) {
605
+ if (res.writableEnded) return;
606
+ if (err instanceof ShareError) {
607
+ const status = err.code === "not-found" ? 404 : err.code === "not-live" ? 410 : err.code === "passphrase-required" || err.code === "passphrase-invalid" || err.code === "invite-disabled" ? 403 : err.code === "already-pending" || err.code === "not-pending" ? 409 : 400;
608
+ sendJson(res, status, { error: err.message, code: err.code });
609
+ } else {
610
+ sendJson(res, 500, { error: "internal" });
611
+ console.error("[vibeshare]", err);
612
+ }
613
+ }
614
+ // ------------------------------------------------------------- SSE
615
+ #openStream(ctx, viewer, res) {
616
+ res.writeHead(200, {
617
+ "content-type": "text/event-stream; charset=utf-8",
618
+ "cache-control": "no-store",
619
+ connection: "keep-alive"
620
+ });
621
+ for (const entry of ctx.feed.backlog()) sseSend(res, "entry", entry, entry.seq);
622
+ const unsubscribe = ctx.feed.subscribe((entry) => sseSend(res, "entry", entry, entry.seq));
623
+ let set = ctx.streams.get(viewer.id);
624
+ if (!set) {
625
+ set = /* @__PURE__ */ new Set();
626
+ ctx.streams.set(viewer.id, set);
627
+ }
628
+ set.add(res);
629
+ this.#broadcastViewers(ctx);
630
+ res.on("close", () => {
631
+ unsubscribe();
632
+ set.delete(res);
633
+ if (set.size === 0) ctx.streams.delete(viewer.id);
634
+ this.#broadcastViewers(ctx);
635
+ });
636
+ }
637
+ #sendToViewer(ctx, viewer, event, data) {
638
+ for (const res of ctx.streams.get(viewer.id) ?? []) sseSend(res, event, data);
639
+ }
640
+ #closeViewerStreams(ctx, viewer, event) {
641
+ const set = ctx.streams.get(viewer.id);
642
+ if (!set) return;
643
+ for (const res of set) {
644
+ if (event !== null) sseSend(res, event, {});
645
+ res.end();
646
+ }
647
+ ctx.streams.delete(viewer.id);
648
+ }
649
+ #broadcastViewers(ctx) {
650
+ const watching = streamCount(ctx);
651
+ for (const set of ctx.streams.values()) {
652
+ for (const res of set) sseSend(res, "viewers", { watching });
653
+ }
654
+ }
655
+ #endAllStreams(ctx, state) {
656
+ for (const set of ctx.streams.values()) {
657
+ for (const res of set) {
658
+ sseSend(res, "ended", { state });
659
+ res.end();
660
+ }
661
+ }
662
+ ctx.streams.clear();
663
+ }
664
+ // -------------------------------------------------------------
665
+ #publicBase() {
666
+ if (this.#baseUrl) return this.#baseUrl.replace(/\/$/, "");
667
+ const host = this.#host === "0.0.0.0" || this.#host === "::" ? lanAddress() ?? "127.0.0.1" : this.#host;
668
+ return `http://${host}:${this.#boundPort}`;
669
+ }
670
+ };
671
+ function streamCount(ctx) {
672
+ let n = 0;
673
+ for (const set of ctx.streams.values()) n += set.size;
674
+ return n;
675
+ }
676
+ function publicViewer(v) {
677
+ return { id: v.id, name: v.name, role: v.role, joinRequest: v.joinRequest, joinedAt: v.joinedAt };
678
+ }
679
+ function sseSend(res, event, data, id) {
680
+ if (res.writableEnded) return;
681
+ const idLine = id === void 0 ? "" : `id: ${id}
682
+ `;
683
+ res.write(`${idLine}event: ${event}
684
+ data: ${JSON.stringify(data)}
685
+
686
+ `);
687
+ }
688
+ function sendJson(res, status, body) {
689
+ if (res.writableEnded) return;
690
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
691
+ res.end(JSON.stringify(body));
692
+ }
693
+ function setSecurityHeaders(res) {
694
+ res.setHeader("x-content-type-options", "nosniff");
695
+ res.setHeader("referrer-policy", "no-referrer");
696
+ res.setHeader("cache-control", "no-store");
697
+ }
698
+ async function readJsonBody(req) {
699
+ const chunks = [];
700
+ let size = 0;
701
+ for await (const chunk of req) {
702
+ const buf = chunk;
703
+ size += buf.length;
704
+ if (size > MAX_BODY) throw new ShareError("bad-request", "body too large");
705
+ chunks.push(buf);
706
+ }
707
+ if (chunks.length === 0) return {};
708
+ try {
709
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
710
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
711
+ } catch {
712
+ return {};
713
+ }
714
+ }
715
+ function isLoopback(addr) {
716
+ return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
717
+ }
718
+ function tokenMatches(header, expected) {
719
+ if (!header?.startsWith("Bearer ")) return false;
720
+ const got = Buffer.from(header.slice(7));
721
+ const want = Buffer.from(expected);
722
+ return got.length === want.length && timingSafeEqual2(got, want);
723
+ }
724
+ function lanAddress() {
725
+ for (const infos of Object.values(networkInterfaces())) {
726
+ for (const info of infos ?? []) {
727
+ if (info.family === "IPv4" && !info.internal) return info.address;
728
+ }
729
+ }
730
+ return null;
731
+ }
732
+
733
+ // src/feed.ts
734
+ import { EventEmitter } from "events";
735
+ var SessionFeed = class extends EventEmitter {
736
+ #capacity;
737
+ #seq = 0;
738
+ #log = [];
739
+ #closed = false;
740
+ constructor(capacity = 1e3) {
741
+ super();
742
+ this.#capacity = Math.max(1, capacity);
743
+ }
744
+ get closed() {
745
+ return this.#closed;
746
+ }
747
+ /** Append a line to the log and fan it out to subscribers. */
748
+ publish(text, opts = {}) {
749
+ if (this.#closed) throw new Error("feed is closed");
750
+ const entry = {
751
+ seq: ++this.#seq,
752
+ ts: Date.now(),
753
+ type: opts.type ?? "output",
754
+ ...opts.stream !== void 0 ? { stream: opts.stream } : {},
755
+ text
756
+ };
757
+ this.#log.push(entry);
758
+ if (this.#log.length > this.#capacity) {
759
+ this.#log.splice(0, this.#log.length - this.#capacity);
760
+ }
761
+ this.emit("entry", entry);
762
+ return entry;
763
+ }
764
+ /** Publish a normalized vibe-core milestone event as a feed line. */
765
+ publishEvent(e) {
766
+ const detail = typeof e.payload?.["detail"] === "string" ? ` \u2014 ${e.payload["detail"]}` : "";
767
+ return this.publish(`\u25C6 ${e.kind} \xB7 ${e.agent}${detail}`, { type: "milestone" });
768
+ }
769
+ /** Publish a host-side status line (share opened, viewer joined, …). */
770
+ system(text) {
771
+ return this.publish(text, { type: "system" });
772
+ }
773
+ /** Recent entries for late-joiner replay, oldest first. */
774
+ backlog() {
775
+ return this.#log;
776
+ }
777
+ /** Subscribe to live entries. Returns an unsubscribe function. */
778
+ subscribe(listener) {
779
+ this.on("entry", listener);
780
+ return () => {
781
+ this.off("entry", listener);
782
+ };
783
+ }
784
+ /** End the feed: notifies subscribers, refuses further publishes. */
785
+ close() {
786
+ if (this.#closed) return;
787
+ this.#closed = true;
788
+ this.emit("close");
789
+ this.removeAllListeners();
790
+ }
791
+ };
792
+
793
+ // src/registry.ts
794
+ import { EventEmitter as EventEmitter2 } from "events";
795
+ var ViewerRegistry = class extends EventEmitter2 {
796
+ #getAccess;
797
+ #viewers = /* @__PURE__ */ new Map();
798
+ constructor(getAccess) {
799
+ super();
800
+ this.#getAccess = getAccess;
801
+ }
802
+ /** Register a new spectator. Everyone enters read-only — no exceptions. */
803
+ add(name) {
804
+ const viewer = {
805
+ id: newShareId(),
806
+ name: sanitizeName(name),
807
+ role: "spectator",
808
+ token: newToken(),
809
+ joinedAt: (/* @__PURE__ */ new Date()).toISOString(),
810
+ joinRequest: "none"
811
+ };
812
+ this.#viewers.set(viewer.id, viewer);
813
+ this.emit("join", viewer);
814
+ return viewer;
815
+ }
816
+ get(id) {
817
+ return this.#viewers.get(id);
818
+ }
819
+ getByToken(token) {
820
+ for (const v of this.#viewers.values()) {
821
+ if (v.token === token) return v;
822
+ }
823
+ return void 0;
824
+ }
825
+ list() {
826
+ return [...this.#viewers.values()];
827
+ }
828
+ count() {
829
+ return this.#viewers.size;
830
+ }
831
+ /**
832
+ * A spectator asks to be promoted to collaborator. Only possible on an
833
+ * `invite`-access share; the host still has to approve.
834
+ */
835
+ requestJoin(id) {
836
+ const v = this.#mustGet(id);
837
+ if (this.#getAccess() !== "invite") {
838
+ throw new ShareError("invite-disabled", "this share is spectate-only");
839
+ }
840
+ if (v.joinRequest === "pending") {
841
+ throw new ShareError("already-pending", "join request already pending");
842
+ }
843
+ if (v.role === "collaborator") return v;
844
+ v.joinRequest = "pending";
845
+ this.emit("request", v);
846
+ return v;
847
+ }
848
+ /** Host approves a pending request → the viewer becomes a collaborator. */
849
+ approve(id) {
850
+ const v = this.#mustGet(id);
851
+ if (v.joinRequest !== "pending") {
852
+ throw new ShareError("not-pending", "no pending join request from this viewer");
853
+ }
854
+ v.joinRequest = "approved";
855
+ v.role = "collaborator";
856
+ this.emit("approve", v);
857
+ return v;
858
+ }
859
+ /** Host denies a pending request; the viewer stays a spectator. */
860
+ deny(id) {
861
+ const v = this.#mustGet(id);
862
+ if (v.joinRequest !== "pending") {
863
+ throw new ShareError("not-pending", "no pending join request from this viewer");
864
+ }
865
+ v.joinRequest = "denied";
866
+ this.emit("deny", v);
867
+ return v;
868
+ }
869
+ /** Remove a viewer entirely (their streams are closed by the transport). */
870
+ kick(id) {
871
+ const v = this.#mustGet(id);
872
+ this.#viewers.delete(id);
873
+ this.emit("kick", v);
874
+ return v;
875
+ }
876
+ /** A viewer leaves on their own. */
877
+ leave(id) {
878
+ const v = this.#viewers.get(id);
879
+ if (!v) return;
880
+ this.#viewers.delete(id);
881
+ this.emit("leave", v);
882
+ }
883
+ /**
884
+ * The write-arbitration gate. The host writes by definition; a remote
885
+ * participant writes only as an approved collaborator. Any future input
886
+ * channel (vibelive cursors, shared terminal input) MUST consult this.
887
+ */
888
+ canWrite(viewerId) {
889
+ return this.#viewers.get(viewerId)?.role === "collaborator";
890
+ }
891
+ #mustGet(id) {
892
+ const v = this.#viewers.get(id);
893
+ if (!v) throw new ShareError("not-found", `no viewer ${id}`);
894
+ return v;
895
+ }
896
+ };
897
+ function sanitizeName(name) {
898
+ const cleaned = (name ?? "").trim().slice(0, 32);
899
+ return cleaned.length > 0 ? cleaned : `anon-${newToken(2)}`;
900
+ }
901
+
902
+ // src/manager.ts
903
+ var SHARE_SCOPE = "share:session";
904
+ var ConsentRequiredError = class extends ShareError {
905
+ constructor() {
906
+ super(
907
+ "consent-required",
908
+ `sharing requires a "${SHARE_SCOPE}" consent grant \u2014 run \`vibeshare\` interactively to grant it, or call consent.grant("${SHARE_SCOPE}")`
909
+ );
910
+ this.name = "ConsentRequiredError";
911
+ }
912
+ };
913
+ var ShareManager = class {
914
+ #consent;
915
+ #transport;
916
+ #live = /* @__PURE__ */ new Map();
917
+ #timers = /* @__PURE__ */ new Map();
918
+ constructor(deps) {
919
+ this.#consent = deps.consent;
920
+ this.#transport = deps.transport;
921
+ }
922
+ /**
923
+ * Create a share: `createShare({session, access, expiry, passphrase})`
924
+ * → `{share, url, feed, viewers, revoke}`.
925
+ *
926
+ * @throws ConsentRequiredError when the ledger has no `share:session` grant.
927
+ */
928
+ async createShare(opts = {}) {
929
+ if (!this.#consent.allows(SHARE_SCOPE)) throw new ConsentRequiredError();
930
+ const access = opts.access ?? "spectate";
931
+ const expiryMs = opts.expiryMs ?? parseExpiry(opts.expiry ?? "stop");
932
+ const now = Date.now();
933
+ const share = {
934
+ id: newShareId(),
935
+ name: opts.name ?? opts.session ?? "agent session",
936
+ access,
937
+ createdAt: new Date(now).toISOString(),
938
+ expiresAt: expiryMs === null ? null : new Date(now + expiryMs).toISOString(),
939
+ state: "live",
940
+ passphraseHash: opts.passphrase !== void 0 && opts.passphrase.length > 0 ? hashPassphrase(opts.passphrase) : null
941
+ };
942
+ const feed = new SessionFeed();
943
+ const viewers = new ViewerRegistry(() => share.access);
944
+ const url = await this.#transport.serve(share, feed, viewers);
945
+ const created = {
946
+ share,
947
+ url,
948
+ feed,
949
+ viewers,
950
+ revoke: () => this.revokeShare(share.id)
951
+ };
952
+ this.#live.set(share.id, created);
953
+ if (expiryMs !== null) {
954
+ const timer = setTimeout(() => {
955
+ void this.revokeShare(share.id, "expired");
956
+ }, expiryMs);
957
+ timer.unref?.();
958
+ this.#timers.set(share.id, timer);
959
+ }
960
+ feed.system(`share opened \xB7 access=${access} \xB7 ${share.expiresAt ?? "until stopped"}`);
961
+ return created;
962
+ }
963
+ /** End a share: disconnect viewers, 410 the URL, close the feed. */
964
+ async revokeShare(id, reason = "revoked") {
965
+ const created = this.#live.get(id);
966
+ if (!created) return;
967
+ created.share.state = reason;
968
+ const timer = this.#timers.get(id);
969
+ if (timer) clearTimeout(timer);
970
+ this.#timers.delete(id);
971
+ this.#live.delete(id);
972
+ await this.#transport.unserve(id);
973
+ if (!created.feed.closed) {
974
+ created.feed.system(`share ${reason}`);
975
+ created.feed.close();
976
+ }
977
+ }
978
+ get(id) {
979
+ return this.#live.get(id);
980
+ }
981
+ list() {
982
+ return [...this.#live.values()];
983
+ }
984
+ /** Revoke every live share and close the transport. */
985
+ async stopAll() {
986
+ for (const id of [...this.#live.keys()]) {
987
+ await this.revokeShare(id);
988
+ }
989
+ await this.#transport.close();
990
+ }
991
+ };
992
+
993
+ // src/version.ts
994
+ var VERSION = "0.1.0";
995
+
996
+ export {
997
+ vibeHome,
998
+ FileConsentStore,
999
+ loadLedger,
1000
+ writeActiveShare,
1001
+ readActiveShare,
1002
+ listActiveShares,
1003
+ clearActiveShare,
1004
+ spectatorPage,
1005
+ ShareError,
1006
+ newShareId,
1007
+ newToken,
1008
+ parseExpiry,
1009
+ hashPassphrase,
1010
+ verifyPassphrase,
1011
+ LocalHttpTransport,
1012
+ SessionFeed,
1013
+ ViewerRegistry,
1014
+ SHARE_SCOPE,
1015
+ ConsentRequiredError,
1016
+ ShareManager,
1017
+ VERSION
1018
+ };