Quazim0t0 commited on
Commit
4bb3f7c
Β·
verified Β·
1 Parent(s): 004517d

Web demo: room-first lobby + group training settings sliders

Browse files
Files changed (3) hide show
  1. web/public/app.js +110 -21
  2. web/public/index.html +30 -0
  3. web/server.js +6 -0
web/public/app.js CHANGED
@@ -2,8 +2,8 @@
2
  // train a shared model together β€” averaging gradients over the data channels.
3
  "use strict";
4
 
5
- const DIN = 16, H = 16, DOUT = 4, NPER = 128, LR = 0.03, DEFAULT_STEPS = 300;
6
- const D = { n: NPER, din: DIN, h: H, dout: DOUT };
7
  const STUN = [{ urls: "stun:stun.l.google.com:19302" }];
8
 
9
  const ui = {
@@ -21,6 +21,12 @@ const ui = {
21
  load: document.getElementById("load"),
22
  loadBtn: document.getElementById("loadBtn"),
23
  requests: document.getElementById("requests"),
 
 
 
 
 
 
24
  };
25
  function log(m) { ui.log.textContent = `${new Date().toLocaleTimeString()} ${m}\n` + ui.log.textContent; }
26
  function setStatus(s) { ui.status.textContent = s; }
@@ -156,7 +162,7 @@ const CKPT_SENTINEL = -2; // wire: [int32 -2][checkpoint by
156
  function packCheckpoint() {
157
  const buf = new ArrayBuffer(8 + 16 + (W1.length + W2.length) * 4);
158
  new Uint8Array(buf, 0, 8).set([...CKPT_MAGIC].map(c => c.charCodeAt(0)));
159
- new Int32Array(buf, 8, 4).set([DIN, H, DOUT, trainedSteps]);
160
  new Float32Array(buf, 24, W1.length).set(W1);
161
  new Float32Array(buf, 24 + W1.length * 4, W2.length).set(W2);
162
  return buf;
@@ -165,13 +171,14 @@ function parseCheckpoint(buf) {
165
  const magic = String.fromCharCode(...new Uint8Array(buf, 0, 8));
166
  if (magic !== CKPT_MAGIC) throw new Error("not a DaisyChain checkpoint");
167
  const [din, h, dout, steps] = new Int32Array(buf, 8, 4);
168
- if (din !== DIN || h !== H || dout !== DOUT)
169
- throw new Error(`shape mismatch: file is ${din}Γ—${h}Γ—${dout}, this build is ${DIN}Γ—${H}Γ—${DOUT}`);
170
  if (buf.byteLength !== 24 + (din * h + h * dout) * 4) throw new Error("truncated checkpoint");
171
- return { steps, w1: new Float32Array(buf.slice(24, 24 + din * h * 4)),
172
  w2: new Float32Array(buf.slice(24 + din * h * 4)) };
173
  }
174
  function applyCheckpoint(ck, from) {
 
175
  W1.set(ck.w1); W2.set(ck.w2); trainedSteps = ck.steps;
176
  ui.save.disabled = false;
177
  ui.step.textContent = `${ck.steps} baked in`;
@@ -190,11 +197,41 @@ function saveCheckpoint() {
190
  const blob = new Blob([packCheckpoint()], { type: "application/octet-stream" });
191
  const a = document.createElement("a");
192
  a.href = URL.createObjectURL(blob);
193
- a.download = `daisychain-${DIN}x${H}x${DOUT}-step${trainedSteps}.pt`;
194
  a.click();
195
  URL.revokeObjectURL(a.href);
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  // ---- gradient wire format: [int32 step][float32 grad...] -----------------
199
  function packGrad(step, grad) {
200
  const buf = new ArrayBuffer(4 + grad.byteLength);
@@ -206,6 +243,7 @@ const waiters = new Set(); // pending waitForGrads checker
206
  function wake() { for (const w of waiters) w(); }
207
  function onGrad(peerId, buf) {
208
  const step = new Int32Array(buf, 0, 1)[0];
 
209
  if (step === CKPT_SENTINEL) { // a peer pushed a checkpoint
210
  if (training) { log(`ignored checkpoint from ${nmeOf(peerId)} (training in progress)`); return; }
211
  try { applyCheckpoint(parseCheckpoint(buf.slice(4)), nmeOf(peerId)); }
@@ -250,12 +288,13 @@ async function localStep() {
250
  }
251
 
252
  // ---- the training loop -----------------------------------------------------
253
- async function train() {
254
  if (training) return; training = true; ui.start.disabled = true;
 
255
  const cohort = [...chans.keys()]; // lock the cohort (departed peers are pruned per-step)
256
- const steps = DEFAULT_STEPS;
257
- const opt = TrainCore.makeAdam(W1.length + W2.length, { lr: 0.2 }); // swept: best on the verified-unit STE grads
258
- log(`training started β€” cohort ${cohort.length} peer(s), world ${cohort.length + 1}, optimizer ${opt.name}`);
259
  for (let s = 0; s < steps; s++) {
260
  const { loss, grad } = await localStep();
261
  broadcastGrad(s, grad);
@@ -278,18 +317,55 @@ async function train() {
278
  training = false;
279
  ui.save.disabled = false;
280
  ui.start.disabled = false;
 
281
  }
282
 
283
  // 2-layer float target (matches the model shape) for a learnable task
284
- function target(X) {
285
- const Wt1 = randn(DIN * H, mulberry32(42)), Wt2 = randn(H * DOUT, mulberry32(43));
286
- const hpre = TrainCore.matmul(X, Wt1, NPER, DIN, H);
287
  for (let i = 0; i < hpre.length; i++) hpre[i] = Math.max(0, hpre[i]);
288
- return TrainCore.matmul(hpre, Wt2, NPER, H, DOUT);
 
 
 
 
 
 
 
 
 
 
 
289
  }
290
 
291
  // ---- boot ------------------------------------------------------------------
292
  (async function () {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  // Neural Units are mandatory: no LUTs -> no training, period. There is no
294
  // float fallback path anywhere in this app; both backends (WebGPU shader and
295
  // CPU JS) compute every product through the verified mul8 LUT.
@@ -310,15 +386,28 @@ function target(X) {
310
  return; // no signaling, no training
311
  }
312
  ui.backend.textContent = `${compute.backend.toUpperCase()} β€” ${compute.label} Β· through verified INT8 units`;
313
- // deterministic shared init (no weight broadcast); per-peer random data shard
314
- W1 = randn(DIN * H, mulberry32(7));
315
- W2 = randn(H * DOUT, mulberry32(8));
316
- Xdata = randn(NPER * DIN);
317
- Ydata = target(Xdata);
318
  ui.me.textContent = deviceName;
 
 
 
 
 
 
 
 
 
319
  updatePeers();
320
  connectSignaling();
321
- ui.start.onclick = train;
 
 
 
 
 
322
  ui.save.onclick = saveCheckpoint;
323
  ui.loadBtn.onclick = () => { if (training) { log("can't load a checkpoint mid-training"); return; } ui.load.click(); };
324
  ui.load.onchange = async () => {
 
2
  // train a shared model together β€” averaging gradients over the data channels.
3
  "use strict";
4
 
5
+ const DIN = 16, DOUT = 4, NPER = 128;
6
+ let D = { n: NPER, din: DIN, h: 16, dout: DOUT }; // h is set by the training-settings slider
7
  const STUN = [{ urls: "stun:stun.l.google.com:19302" }];
8
 
9
  const ui = {
 
21
  load: document.getElementById("load"),
22
  loadBtn: document.getElementById("loadBtn"),
23
  requests: document.getElementById("requests"),
24
+ roomInfo: document.getElementById("roomInfo"),
25
+ roomCode: document.getElementById("roomCode"),
26
+ copyLink: document.getElementById("copyLink"),
27
+ cfgH: document.getElementById("cfgH"),
28
+ cfgSteps: document.getElementById("cfgSteps"),
29
+ cfgLr: document.getElementById("cfgLr"),
30
  };
31
  function log(m) { ui.log.textContent = `${new Date().toLocaleTimeString()} ${m}\n` + ui.log.textContent; }
32
  function setStatus(s) { ui.status.textContent = s; }
 
162
  function packCheckpoint() {
163
  const buf = new ArrayBuffer(8 + 16 + (W1.length + W2.length) * 4);
164
  new Uint8Array(buf, 0, 8).set([...CKPT_MAGIC].map(c => c.charCodeAt(0)));
165
+ new Int32Array(buf, 8, 4).set([DIN, D.h, DOUT, trainedSteps]);
166
  new Float32Array(buf, 24, W1.length).set(W1);
167
  new Float32Array(buf, 24 + W1.length * 4, W2.length).set(W2);
168
  return buf;
 
171
  const magic = String.fromCharCode(...new Uint8Array(buf, 0, 8));
172
  if (magic !== CKPT_MAGIC) throw new Error("not a DaisyChain checkpoint");
173
  const [din, h, dout, steps] = new Int32Array(buf, 8, 4);
174
+ if (din !== DIN || dout !== DOUT || h < 8 || h > 64)
175
+ throw new Error(`shape mismatch: file is ${din}Γ—${h}Γ—${dout}, this build is ${DIN}Γ—(8–64)Γ—${DOUT}`);
176
  if (buf.byteLength !== 24 + (din * h + h * dout) * 4) throw new Error("truncated checkpoint");
177
+ return { h, steps, w1: new Float32Array(buf.slice(24, 24 + din * h * 4)),
178
  w2: new Float32Array(buf.slice(24 + din * h * 4)) };
179
  }
180
  function applyCheckpoint(ck, from) {
181
+ if (ck.h !== D.h) { buildModel(ck.h); showCfgInUI({ h: ck.h, steps: +ui.cfgSteps.value, lr: +ui.cfgLr.value / 100 }); }
182
  W1.set(ck.w1); W2.set(ck.w2); trainedSteps = ck.steps;
183
  ui.save.disabled = false;
184
  ui.step.textContent = `${ck.steps} baked in`;
 
197
  const blob = new Blob([packCheckpoint()], { type: "application/octet-stream" });
198
  const a = document.createElement("a");
199
  a.href = URL.createObjectURL(blob);
200
+ a.download = `daisychain-${DIN}x${D.h}x${DOUT}-step${trainedSteps}.pt`;
201
  a.click();
202
  URL.revokeObjectURL(a.href);
203
  }
204
 
205
+ // ---- training config: whoever presses Start sets it for the whole group ----
206
+ const CFG_SENTINEL = -3; // wire: [int32 -3][int32 h,steps][f32 lr]
207
+ function readCfgFromUI() {
208
+ return { h: +ui.cfgH.value, steps: +ui.cfgSteps.value, lr: +ui.cfgLr.value / 100 };
209
+ }
210
+ function showCfgInUI(cfg) {
211
+ ui.cfgH.value = cfg.h; document.getElementById("vcfgH").textContent = cfg.h;
212
+ ui.cfgSteps.value = cfg.steps; document.getElementById("vcfgSteps").textContent = cfg.steps;
213
+ ui.cfgLr.value = Math.round(cfg.lr * 100); document.getElementById("vcfgLr").textContent = Math.round(cfg.lr * 100);
214
+ }
215
+ function broadcastConfig(cfg) {
216
+ const buf = new ArrayBuffer(16);
217
+ new Int32Array(buf, 0, 3).set([CFG_SENTINEL, cfg.h, cfg.steps]);
218
+ new Float32Array(buf, 12, 1)[0] = cfg.lr;
219
+ for (const dc of chans.values()) if (dc.readyState === "open") dc.send(buf);
220
+ }
221
+ function onConfig(peerId, buf) {
222
+ if (training) { log(`ignored settings from ${nmeOf(peerId)} (already training)`); return; }
223
+ const [, h, steps] = new Int32Array(buf, 0, 3);
224
+ const lr = new Float32Array(buf, 12, 1)[0];
225
+ if (!(h >= 8 && h <= 64 && steps >= 1 && steps <= 10000 && lr > 0 && lr <= 1)) {
226
+ log(`rejected bad settings from ${nmeOf(peerId)}`); return;
227
+ }
228
+ const cfg = { h, steps, lr };
229
+ showCfgInUI(cfg);
230
+ log(`${nmeOf(peerId)} started the group: hidden=${h}, ${steps} steps, lr=${lr}`);
231
+ buildModel(cfg.h);
232
+ train(cfg); // follow automatically
233
+ }
234
+
235
  // ---- gradient wire format: [int32 step][float32 grad...] -----------------
236
  function packGrad(step, grad) {
237
  const buf = new ArrayBuffer(4 + grad.byteLength);
 
243
  function wake() { for (const w of waiters) w(); }
244
  function onGrad(peerId, buf) {
245
  const step = new Int32Array(buf, 0, 1)[0];
246
+ if (step === CFG_SENTINEL) { onConfig(peerId, buf); return; }
247
  if (step === CKPT_SENTINEL) { // a peer pushed a checkpoint
248
  if (training) { log(`ignored checkpoint from ${nmeOf(peerId)} (training in progress)`); return; }
249
  try { applyCheckpoint(parseCheckpoint(buf.slice(4)), nmeOf(peerId)); }
 
288
  }
289
 
290
  // ---- the training loop -----------------------------------------------------
291
+ async function train(cfg) {
292
  if (training) return; training = true; ui.start.disabled = true;
293
+ [ui.cfgH, ui.cfgSteps, ui.cfgLr].forEach(el => el.disabled = true);
294
  const cohort = [...chans.keys()]; // lock the cohort (departed peers are pruned per-step)
295
+ const steps = cfg.steps;
296
+ const opt = TrainCore.makeAdam(W1.length + W2.length, { lr: cfg.lr });
297
+ log(`training started β€” cohort ${cohort.length} peer(s), world ${cohort.length + 1}, hidden=${cfg.h}, optimizer ${opt.name}`);
298
  for (let s = 0; s < steps; s++) {
299
  const { loss, grad } = await localStep();
300
  broadcastGrad(s, grad);
 
317
  training = false;
318
  ui.save.disabled = false;
319
  ui.start.disabled = false;
320
+ [ui.cfgH, ui.cfgSteps, ui.cfgLr].forEach(el => el.disabled = false);
321
  }
322
 
323
  // 2-layer float target (matches the model shape) for a learnable task
324
+ function target(X, h) {
325
+ const Wt1 = randn(DIN * h, mulberry32(42)), Wt2 = randn(h * DOUT, mulberry32(43));
326
+ const hpre = TrainCore.matmul(X, Wt1, NPER, DIN, h);
327
  for (let i = 0; i < hpre.length; i++) hpre[i] = Math.max(0, hpre[i]);
328
+ return TrainCore.matmul(hpre, Wt2, NPER, h, DOUT);
329
+ }
330
+
331
+ // (re)build the model for a hidden size β€” deterministic shared init (same seeds
332
+ // on every peer), fresh per-peer data shard
333
+ function buildModel(h) {
334
+ D = { n: NPER, din: DIN, h, dout: DOUT };
335
+ W1 = randn(DIN * h, mulberry32(7));
336
+ W2 = randn(h * DOUT, mulberry32(8));
337
+ Xdata = randn(NPER * DIN);
338
+ Ydata = target(Xdata, h);
339
+ trainedSteps = 0;
340
  }
341
 
342
  // ---- boot ------------------------------------------------------------------
343
  (async function () {
344
+ // Room-first deployments (the HF Space sets DAISY_FORCE_ROOMS): there is no
345
+ // LAN auto-grouping between strangers β€” visitors without a room code choose
346
+ // to create their own room (they become its host) or join one by code.
347
+ try {
348
+ const mode = await (await fetch("mode")).json();
349
+ if (mode.forceRooms && !room()) {
350
+ const lobby = document.getElementById("lobby");
351
+ for (const c of document.querySelectorAll(".card")) if (c !== lobby) c.style.display = "none";
352
+ lobby.style.display = "";
353
+ document.getElementById("createRoom").onclick = () => {
354
+ const code = (ADJ[Math.floor(Math.random() * ADJ.length)] + "-" +
355
+ NOUN[Math.floor(Math.random() * NOUN.length)] + "-" +
356
+ (100 + Math.floor(Math.random() * 900))).toLowerCase();
357
+ location.href = "?room=" + code;
358
+ };
359
+ const join = () => {
360
+ const code = document.getElementById("joinCode").value.trim().toLowerCase();
361
+ if (code) location.href = "?room=" + encodeURIComponent(code);
362
+ };
363
+ document.getElementById("joinRoom").onclick = join;
364
+ document.getElementById("joinCode").onkeydown = (e) => { if (e.key === "Enter") join(); };
365
+ return; // wait for the choice
366
+ }
367
+ } catch (e) {} // no /mode: local default
368
+
369
  // Neural Units are mandatory: no LUTs -> no training, period. There is no
370
  // float fallback path anywhere in this app; both backends (WebGPU shader and
371
  // CPU JS) compute every product through the verified mul8 LUT.
 
386
  return; // no signaling, no training
387
  }
388
  ui.backend.textContent = `${compute.backend.toUpperCase()} β€” ${compute.label} Β· through verified INT8 units`;
389
+ // slider readouts
390
+ for (const [el, v] of [[ui.cfgH, "vcfgH"], [ui.cfgSteps, "vcfgSteps"], [ui.cfgLr, "vcfgLr"]])
391
+ el.oninput = () => document.getElementById(v).textContent = el.value;
392
+ buildModel(+ui.cfgH.value);
 
393
  ui.me.textContent = deviceName;
394
+ if (room()) {
395
+ ui.roomInfo.style.display = "";
396
+ ui.roomCode.textContent = room();
397
+ ui.copyLink.onclick = async () => {
398
+ try { await navigator.clipboard.writeText(location.href); ui.copyLink.textContent = "Copied!"; }
399
+ catch { ui.copyLink.textContent = location.href; }
400
+ setTimeout(() => ui.copyLink.textContent = "Copy invite link", 2000);
401
+ };
402
+ }
403
  updatePeers();
404
  connectSignaling();
405
+ ui.start.onclick = () => {
406
+ const cfg = readCfgFromUI();
407
+ buildModel(cfg.h);
408
+ broadcastConfig(cfg); // everyone follows these settings
409
+ train(cfg);
410
+ };
411
  ui.save.onclick = saveCheckpoint;
412
  ui.loadBtn.onclick = () => { if (training) { log("can't load a checkpoint mid-training"); return; } ui.load.click(); };
413
  ui.load.onchange = async () => {
web/public/index.html CHANGED
@@ -62,6 +62,9 @@
62
  font-family: 'Courier New', monospace; }
63
  @media (prefers-color-scheme: dark) { pre { background: rgba(0,0,0,0.25); } }
64
  .note { color: var(--text-soft); font-size: .82rem; }
 
 
 
65
  .note b { color: var(--warn); }
66
  .diff { color: var(--accent-deep); font-weight: 600; font-size: .88rem; }
67
  @media (prefers-color-scheme: dark) { .diff { color: var(--accent); } }
@@ -71,6 +74,17 @@
71
  <h1>🌼 DaisyChain-Web</h1>
72
  <p class="sub">Open this on your other devices <b>on the same network</b> and they train a shared model together β€” peer-to-peer, right in the browser, through the emulated GPU logic. Only devices on your network are grouped (like Snapdrop). To invite people across networks, everyone opens <code>?room=YOUR-CODE</code> β€” the person who created the room approves each device before it can join.</p>
73
 
 
 
 
 
 
 
 
 
 
 
 
74
  <div class="card">
75
  <div class="lbl">🌲 This device</div>
76
  <div class="device" id="me">β€”</div>
@@ -81,9 +95,25 @@
81
  <div class="card">
82
  <div class="lbl">πŸ„ Devices in your group</div>
83
  <div class="row"><span class="v" id="peers" style="text-align:left">(none yet)</span></div>
 
 
 
 
 
84
  <div id="requests"></div>
85
  </div>
86
 
 
 
 
 
 
 
 
 
 
 
 
87
  <div class="card" style="text-align:center">
88
  <button id="start" disabled>Start training</button>
89
  <p class="note" style="margin:.6rem 0 0">Enabled once another device joins. (Or open a second tab to try it.)</p>
 
62
  font-family: 'Courier New', monospace; }
63
  @media (prefers-color-scheme: dark) { pre { background: rgba(0,0,0,0.25); } }
64
  .note { color: var(--text-soft); font-size: .82rem; }
65
+ .slbl { display: flex; justify-content: space-between; font-size: .92rem; margin: 10px 0 4px; font-weight: 600; }
66
+ .sval { font-family: 'Courier New', monospace; color: var(--accent); font-weight: 700; }
67
+ input[type=range] { width: 100%; accent-color: var(--accent); }
68
  .note b { color: var(--warn); }
69
  .diff { color: var(--accent-deep); font-weight: 600; font-size: .88rem; }
70
  @media (prefers-color-scheme: dark) { .diff { color: var(--accent); } }
 
74
  <h1>🌼 DaisyChain-Web</h1>
75
  <p class="sub">Open this on your other devices <b>on the same network</b> and they train a shared model together β€” peer-to-peer, right in the browser, through the emulated GPU logic. Only devices on your network are grouped (like Snapdrop). To invite people across networks, everyone opens <code>?room=YOUR-CODE</code> β€” the person who created the room approves each device before it can join.</p>
76
 
77
+ <div class="card" id="lobby" style="display:none;text-align:center">
78
+ <div class="lbl">🏑 Get started</div>
79
+ <p class="note" style="margin:0 0 12px">Create a room and invite your devices, or join a room someone shared with you.</p>
80
+ <button id="createRoom">Create a room</button>
81
+ <div style="display:flex;gap:8px;justify-content:center;margin-top:12px;flex-wrap:wrap">
82
+ <input type="text" id="joinCode" placeholder="room code" spellcheck="false"
83
+ style="padding:10px 12px;border-radius:8px;border:1px solid var(--card-border);background:transparent;color:inherit;font-family:'Courier New',monospace">
84
+ <button id="joinRoom" style="padding:10px 18px">Join</button>
85
+ </div>
86
+ </div>
87
+
88
  <div class="card">
89
  <div class="lbl">🌲 This device</div>
90
  <div class="device" id="me">β€”</div>
 
95
  <div class="card">
96
  <div class="lbl">πŸ„ Devices in your group</div>
97
  <div class="row"><span class="v" id="peers" style="text-align:left">(none yet)</span></div>
98
+ <div id="roomInfo" style="display:none;margin-top:8px">
99
+ <div class="row"><span class="k">Room</span><span class="v" id="roomCode"></span></div>
100
+ <div style="text-align:center;margin-top:6px"><button id="copyLink" style="padding:6px 14px;font-size:.85rem">Copy invite link</button></div>
101
+ <p class="note" style="margin:.5rem 0 0;text-align:center">Open the link on your other devices β€” you approve each one before it joins.</p>
102
+ </div>
103
  <div id="requests"></div>
104
  </div>
105
 
106
+ <div class="card">
107
+ <div class="lbl">πŸŽ› Training settings</div>
108
+ <label class="slbl">Hidden units <span class="sval" id="vcfgH">16</span></label>
109
+ <input type="range" id="cfgH" min="8" max="64" step="8" value="16">
110
+ <label class="slbl">Steps <span class="sval" id="vcfgSteps">300</span></label>
111
+ <input type="range" id="cfgSteps" min="100" max="1000" step="100" value="300">
112
+ <label class="slbl">Learning rate Γ—100 <span class="sval" id="vcfgLr">20</span></label>
113
+ <input type="range" id="cfgLr" min="5" max="50" step="5" value="20">
114
+ <p class="note" style="margin:.5rem 0 0">Whoever presses Start sets the settings for the whole group β€” every device follows automatically.</p>
115
+ </div>
116
+
117
  <div class="card" style="text-align:center">
118
  <button id="start" disabled>Start training</button>
119
  <p class="note" style="margin:.6rem 0 0">Enabled once another device joins. (Or open a second tab to try it.)</p>
web/server.js CHANGED
@@ -32,6 +32,12 @@ const TYPES = { ".html": "text/html", ".js": "text/javascript",
32
 
33
  const server = http.createServer((req, res) => {
34
  let p = decodeURIComponent(req.url.split("?")[0]);
 
 
 
 
 
 
35
  if (p === "/") p = "/index.html";
36
  const file = path.join(PUB, path.normalize(p));
37
  if (!file.startsWith(PUB)) { res.writeHead(403); return res.end(); }
 
32
 
33
  const server = http.createServer((req, res) => {
34
  let p = decodeURIComponent(req.url.split("?")[0]);
35
+ if (p === "/mode") { // deployment flags for the client
36
+ res.writeHead(200, { "Content-Type": "application/json" });
37
+ // DAISY_FORCE_ROOMS=1 (set on the HF Space): no LAN auto-grouping β€” every
38
+ // visitor creates their own private room and invites devices by link
39
+ return res.end(JSON.stringify({ forceRooms: !!process.env.DAISY_FORCE_ROOMS }));
40
+ }
41
  if (p === "/") p = "/index.html";
42
  const file = path.join(PUB, path.normalize(p));
43
  if (!file.startsWith(PUB)) { res.writeHead(403); return res.end(); }