truecanvas 0.4.1 → 0.6.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.
@@ -40,6 +40,10 @@ header { position: fixed; top: 12px; left: 12px; right: 12px; display: flex; ali
40
40
  background: var(--panel); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border: 1px solid var(--line); box-shadow: 0 1px 2px rgba(0,0,0,.05); }
41
41
  .brand img { height: 20px; width: auto; display: block; }
42
42
  .brand span { font-weight: 600; letter-spacing: -.01em; }
43
+ .brand .who { font-weight: 450; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
44
+ .brand form { display: contents; }
45
+ .brand .who-btn, .brand a.who-btn { height: 28px; padding: 0 8px; border-radius: 8px; font-size: 13px; color: var(--text); text-decoration: none; display: inline-flex; align-items: center; }
46
+ .brand .who-btn:hover { background: var(--line); }
43
47
  .title { font-weight: 550; }
44
48
  .meta { color: var(--muted); }
45
49
  .vselect { gap: 4px; color: var(--muted); padding: 0 6px 0 8px; margin-right: -6px; max-width: 46vw; }
@@ -120,23 +124,56 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
120
124
  @media (max-width: 640px) {
121
125
  .meta, .sep, .made, .vdate { display: none; } header { top: 8px; left: 8px; right: 8px; gap: 8px; }
122
126
  .brand:has(img) span { display: none; }
127
+ .brand .who { display: none; }
123
128
  header .pill { min-width: 0; padding: 0 10px; }
124
129
  header .pill:last-child { flex: 1 1 auto; justify-content: flex-end; }
125
130
  .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
126
131
  #pop { left: 8px !important; right: 8px; top: auto !important; bottom: 8px; width: auto; max-height: 60dvh; }
127
132
  #panel { left: 8px; right: 8px; top: 56px; bottom: 68px; width: auto; }
128
133
  }
129
- @media (prefers-reduced-motion: reduce) { .frame-box iframe { transition: none; } }
134
+ /* live site */
135
+ .live-btn { height: 22px; min-width: 0; margin-left: 8px; padding: 0 8px 0 6px; gap: 4px; border-radius: 6px; vertical-align: middle;
136
+ background: var(--accent-soft); color: var(--accent); font-size: 11.5px; font-weight: 600; }
137
+ .live-btn:hover { background: var(--accent); color: #fff; }
138
+ #live { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 16px;
139
+ background: rgba(12,12,11,.6); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: fade-in .16s var(--ease-out); }
140
+ @keyframes fade-in { from { opacity: 0; } }
141
+ .live-bar { display: flex; align-items: center; gap: 10px; max-width: 100%; height: 40px; padding: 0 6px 0 14px; border-radius: 12px;
142
+ background: var(--panel-solid); border: 1px solid var(--line); box-shadow: 0 8px 28px -10px rgba(0,0,0,.35); }
143
+ .live-bar b { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
144
+ .live-bar a { color: var(--text); white-space: nowrap; text-decoration: none; padding: 0 8px; height: 30px; display: inline-flex; align-items: center; border-radius: 8px; }
145
+ .live-bar a:hover { background: var(--line); }
146
+ .live-device { position: relative; overflow: hidden; border-radius: 10px; background: #fff; box-shadow: 0 30px 80px -20px rgba(0,0,0,.6); animation: pop-in .2s var(--ease-out); }
147
+ .live-device iframe { position: absolute; left: 0; top: 0; border: 0; transform-origin: 0 0; background: #fff; }
148
+ @media (max-width: 640px) { .live-bar .meta { display: none; } }
149
+ /* the room: who's here, their cursors, a live session */
150
+ #room { gap: 8px; padding: 0 10px 0 12px; }
151
+ .live-badge { display: inline-flex; align-items: center; gap: 6px; font-weight: 650; font-size: 12px; color: #dc2626; letter-spacing: .01em; }
152
+ .live-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,.18); }
153
+ .faces { display: flex; }
154
+ .face { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; color: #fff; font-size: 10.5px; font-weight: 650;
155
+ border: 2px solid var(--panel-solid); margin-left: -6px; }
156
+ .face:first-child { margin-left: 0; }
157
+ .rcursor { position: absolute; width: 0; height: 0; transform-origin: 0 0; z-index: 3; pointer-events: none; transition: left .09s linear, top .09s linear; }
158
+ .rcursor svg { position: absolute; left: -2px; top: -2px; display: block; }
159
+ .rcursor span { position: absolute; left: 13px; top: 15px; white-space: nowrap; padding: 2px 7px; border-radius: 7px; color: #fff; font-size: 11.5px; font-weight: 600;
160
+ box-shadow: 0 2px 6px rgba(0,0,0,.18); }
161
+ @media (prefers-reduced-motion: reduce) { .frame-box iframe { transition: none; } #live, .live-device { animation: none; } .rcursor { transition: none; } }
130
162
  </style>
131
163
  </head>
132
164
  <body>
133
165
  <div id="stage" aria-label="Design canvas"><div id="world"></div></div>
134
166
  <header>
135
167
  <div class="pill brand" id="brand"><span>Design review</span></div>
168
+ <div class="pill" id="room" hidden aria-live="polite"></div>
136
169
  <div class="pill"><span class="title" id="title"></span><span class="sep"></span><span class="meta" id="meta"></span><button id="list" hidden aria-label="Comments" title="Comments"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v11H9l-5 4z"/></svg><span class="count" id="count">0</span></button></div>
137
170
  </header>
138
171
  <aside id="panel" hidden aria-label="Comments"><div class="panel-head"><span>Comments</span><button id="panel-close" aria-label="Close">\u2715</button></div><div class="panel-list" id="panel-list"></div></aside>
139
172
  <div id="pop" hidden role="dialog" aria-label="Comment"></div>
173
+ <div id="live" hidden role="dialog" aria-modal="true" aria-label="Live site">
174
+ <div class="live-bar"><b id="live-name"></b><span class="meta" id="live-size"></span><a id="live-tab" target="_blank" rel="noopener">Open in a new tab</a><button id="live-close" aria-label="Close live site" title="Close (Esc)">\u2715</button></div>
175
+ <div class="live-device" id="live-device"></div>
176
+ </div>
140
177
  <footer><div class="pill">
141
178
  <button id="out" aria-label="Zoom out" title="Zoom out (-)">\u2212</button>
142
179
  <button id="zoom" aria-label="Zoom to fit" title="Zoom to fit (1)">100%</button>
@@ -248,6 +285,14 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
248
285
  if (manifest.brand) {
249
286
  document.getElementById("brand").innerHTML = (manifest.brand.logo ? '<img alt="" src="' + esc(manifest.brand.logo) + '">' : "") + (manifest.brand.name ? "<span>" + esc(manifest.brand.name) + "</span>" : "");
250
287
  }
288
+ // signed in: who's commenting, and sign out. On review sites with sign-in, a way in for the studio and invitees.
289
+ if (manifest.viewer || manifest.signIn) {
290
+ const brandEl = document.getElementById("brand");
291
+ const here = location.pathname.replace(/\\/+$/, "");
292
+ brandEl.insertAdjacentHTML("beforeend", '<span class="sep"></span>' + (manifest.viewer
293
+ ? '<span class="who" title="' + esc(manifest.viewer.email) + '">' + esc(manifest.viewer.name) + '</span><form method="post" action="/signout"><input type="hidden" name="next" value="' + esc(here) + '"><button class="who-btn" type="submit">Sign out</button></form>'
294
+ : '<a class="who-btn" href="/signin?next=' + encodeURIComponent(here) + '">Sign in</a>'));
295
+ }
251
296
 
252
297
  // layout: frames at their canvas positions
253
298
  const frames = manifest.frames;
@@ -258,10 +303,12 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
258
303
  const el = document.createElement("div");
259
304
  el.className = "frame";
260
305
  el.style.cssText = "left:" + (f.x - minX) + "px;top:" + (f.y - minY) + "px;width:" + f.width + "px;height:" + f.height + "px";
261
- el.innerHTML = '<div class="frame-label"><b>' + esc(f.name) + "</b>" + f.width + " \xD7 " + Math.round(f.height) + "</div>" +
306
+ el.innerHTML = '<div class="frame-label"><b>' + esc(f.name) + "</b>" + f.width + " \xD7 " + Math.round(f.height) + (manifest.live ? '<button class="live-btn" title="Open the live site at this width"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4.5v15l13-7.5z"/></svg>View live</button>' : "") + "</div>" +
262
307
  '<div class="frame-box" style="width:' + f.width + "px;height:" + f.height + 'px"><img alt="" src="' + esc(f.image) + '"><iframe sandbox title="' + esc(f.name) + '" scrolling="no" loading="lazy" src="' + esc(f.html) + '"></iframe></div>';
263
308
  const iframe = el.querySelector("iframe");
264
309
  iframe.addEventListener("load", () => iframe.classList.add("ready"));
310
+ const liveBtn = el.querySelector(".live-btn");
311
+ if (liveBtn) liveBtn.onclick = (e) => { e.stopPropagation(); openLive(f); };
265
312
  el.dataset.name = f.name;
266
313
  world.appendChild(el);
267
314
  labels.push(el.querySelector(".frame-label"));
@@ -276,7 +323,7 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
276
323
  zoomLabel.textContent = Math.round(z * 100) + "%";
277
324
  // labels keep a readable size at any zoom
278
325
  for (const l of labels) l.style.transform = "scale(" + 1 / z + ")";
279
- for (const p of world.querySelectorAll(".pin")) p.style.transform = "scale(" + 1 / z + ")";
326
+ for (const p of world.querySelectorAll(".pin, .rcursor")) p.style.transform = "scale(" + 1 / z + ")";
280
327
  if (typeof placePop === "function") placePop();
281
328
  };
282
329
  const zoomAt = (nz, cx, cy) => {
@@ -319,8 +366,8 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
319
366
  let pinch = null;
320
367
  let moved = false, downAt = null;
321
368
  stage.addEventListener("pointerdown", (e) => {
322
- // pins are buttons: they take their own clicks
323
- if (e.target.closest && e.target.closest(".pin")) return;
369
+ // pins and View live are buttons: they take their own clicks
370
+ if (e.target.closest && e.target.closest(".pin, .live-btn")) return;
324
371
  stage.setPointerCapture(e.pointerId);
325
372
  moved = false;
326
373
  downAt = { x: e.clientX, y: e.clientY };
@@ -378,6 +425,7 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
378
425
  document.getElementById("out").onclick = () => zoomAt(z / 1.25, ...center());
379
426
  zoomLabel.onclick = () => (tall() ? fitWidth() : fit());
380
427
  addEventListener("keydown", (e) => {
428
+ if (liveOpen()) { if (e.key === "Escape") closeLive(); return; }
381
429
  if (e.target.closest && e.target.closest("input, textarea, select")) return;
382
430
  if (e.key === "Escape") { closePop(); setCommenting(false); return; }
383
431
  if ((e.key === "c" || e.key === "C") && canComment && !e.metaKey && !e.ctrlKey) { setCommenting(!commenting); return; }
@@ -389,6 +437,53 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
389
437
  });
390
438
  addEventListener("resize", apply);
391
439
 
440
+ // ---------- live site: the real thing, at the frame's width ----------
441
+ const liveBox = document.getElementById("live");
442
+ const liveDevice = document.getElementById("live-device");
443
+ let liveFrame = null, liveReturn = null;
444
+ const liveOpen = () => !liveBox.hidden;
445
+ // a realistic screen for the width: desktop, tablet or phone
446
+ const viewportHeight = (w) => (w >= 1024 ? 900 : w > 500 ? 1024 : 844);
447
+ function sizeLive() {
448
+ if (!liveFrame) return;
449
+ const w = liveFrame.width, h = viewportHeight(w);
450
+ const s = Math.min(1, (innerWidth - 32) / w, (innerHeight - 32 - 50) / h);
451
+ liveDevice.style.width = Math.round(w * s) + "px";
452
+ liveDevice.style.height = Math.round(h * s) + "px";
453
+ const iframe = liveDevice.querySelector("iframe");
454
+ iframe.style.width = w + "px";
455
+ iframe.style.height = h + "px";
456
+ iframe.style.transform = s < 1 ? "scale(" + s + ")" : "";
457
+ document.getElementById("live-size").textContent = w + " \xD7 " + h + (s < 1 ? " \xB7 " + Math.round(s * 100) + "%" : "");
458
+ }
459
+ function openLive(f) {
460
+ liveFrame = f;
461
+ liveReturn = document.activeElement;
462
+ liveDevice.textContent = "";
463
+ // its own origin: scripts run there, never on the review site
464
+ const iframe = document.createElement("iframe");
465
+ iframe.title = "Live site: " + f.name;
466
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
467
+ iframe.setAttribute("referrerpolicy", "no-referrer");
468
+ iframe.src = manifest.live.url;
469
+ liveDevice.appendChild(iframe);
470
+ document.getElementById("live-name").textContent = f.name;
471
+ document.getElementById("live-tab").href = manifest.live.url;
472
+ liveBox.hidden = false;
473
+ sizeLive();
474
+ document.getElementById("live-close").focus();
475
+ }
476
+ function closeLive() {
477
+ liveBox.hidden = true;
478
+ liveDevice.textContent = "";
479
+ liveFrame = null;
480
+ if (liveReturn && liveReturn.focus) liveReturn.focus();
481
+ }
482
+ document.getElementById("live-close").onclick = closeLive;
483
+ // a click on the backdrop closes it too
484
+ liveBox.addEventListener("click", (e) => { if (e.target === liveBox) closeLive(); });
485
+ addEventListener("resize", sizeLive);
486
+
392
487
  // ---------- comments (on a review site: the manifest says so) ----------
393
488
  var canComment = !!manifest.comments;
394
489
  var commenting = false;
@@ -398,7 +493,8 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
398
493
  var popAnchor = null;
399
494
  var pop = document.getElementById("pop");
400
495
  const studioName = (manifest.brand && manifest.brand.name) || "Studio";
401
- const storedName = () => { try { return localStorage.getItem("tc-review-name") || ""; } catch { return ""; } };
496
+ // signed in: comments carry the account's name, nothing to type
497
+ const storedName = () => { if (manifest.viewer) return manifest.viewer.name; try { return localStorage.getItem("tc-review-name") || ""; } catch { return ""; } };
402
498
  const saveName = (n) => { try { localStorage.setItem("tc-review-name", n); } catch {} };
403
499
  const ago = (t) => {
404
500
  const s = Math.round((Date.now() - t) / 1000);
@@ -647,6 +743,156 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
647
743
  setInterval(() => document.visibilityState === "visible" && refresh(), 15000);
648
744
  addEventListener("visibilitychange", () => document.visibilityState === "visible" && refresh());
649
745
  }
746
+
747
+ // ---------- the link's room: who's here, their cursors, comments as they come, live sessions ----------
748
+ if (manifest.room && "WebSocket" in window) {
749
+ const people = new Map();
750
+ const roomPill = document.getElementById("room");
751
+ let me = null, live = false, route = null, sock = null, retry = 1000, ping = 0;
752
+ // a session shows the app as it is now: only on the latest version
753
+ const canLive = !!manifest.session && (!manifest.versions || !manifest.versions.length || manifest.versions[0].id === manifest.id);
754
+ const initials = (n) => n.split(" ").filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join("") || "?";
755
+
756
+ function renderRoom() {
757
+ const others = [...people.values()];
758
+ roomPill.hidden = !live && !others.length;
759
+ roomPill.textContent = "";
760
+ if (live) roomPill.appendChild(el("span", "live-badge", "Live"));
761
+ const faces = el("span", "faces");
762
+ for (const p of others.slice(0, 5)) {
763
+ const f = el("span", "face", initials(p.name));
764
+ f.style.background = p.color;
765
+ f.title = p.name + (p.kind === "studio" ? " (" + studioName + ")" : "");
766
+ faces.appendChild(f);
767
+ }
768
+ if (others.length > 5) faces.appendChild(el("span", "face", "+" + (others.length - 5))).style.background = "#8b8a85";
769
+ if (others.length) roomPill.appendChild(faces);
770
+ roomPill.setAttribute("aria-label", (live ? "Live session. " : "") + (others.length ? others.map((p) => p.name).join(", ") + " here" : ""));
771
+ }
772
+
773
+ function cursorOf(p) {
774
+ if (p.el) return p.el;
775
+ p.el = el("div", "rcursor");
776
+ p.el.innerHTML = '<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M2.2 1.6 15.1 8.3c.7.4.6 1.4-.2 1.6l-5.3 1.3-2.6 4.8c-.4.7-1.4.6-1.6-.2L1.1 2.7c-.2-.8.5-1.4 1.1-1.1Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>';
777
+ p.el.querySelector("path").setAttribute("fill", p.color);
778
+ const name = el("span", null, p.name);
779
+ name.style.background = p.color;
780
+ p.el.appendChild(name);
781
+ p.el.style.transform = "scale(" + 1 / z + ")";
782
+ world.appendChild(p.el);
783
+ return p.el;
784
+ }
785
+ function moveCursor(p, cx, cy) {
786
+ if (cx == null || cy == null) { if (p.el) p.el.hidden = true; return; }
787
+ const c = cursorOf(p);
788
+ c.hidden = false;
789
+ c.style.left = cx - minX + "px";
790
+ c.style.top = cy - minY + "px";
791
+ }
792
+ const add = (person) => { if (person.id !== me) people.set(person.id, Object.assign(people.get(person.id) || {}, person)); };
793
+ const remove = (id) => { const p = people.get(id); if (p && p.el) p.el.remove(); people.delete(id); };
794
+
795
+ // live frames: the studio's app, served from the link's session host, at each frame's size
796
+ function sessionFrame(f) {
797
+ const home = new URL(manifest.session.url);
798
+ const url = new URL((route || "/truecanvas/") + encodeURIComponent(manifest.canvas), home);
799
+ url.searchParams.set("frame", f.name);
800
+ if (f.theme) url.searchParams.set("theme", f.theme);
801
+ const token = home.searchParams.get("tc_access");
802
+ if (token) url.searchParams.set("tc_access", token);
803
+ return url.href;
804
+ }
805
+ function setLive(on) {
806
+ on = !!on && canLive;
807
+ if (on === live) return renderRoom();
808
+ live = on;
809
+ for (const frameEl of world.querySelectorAll(".frame")) {
810
+ const f = frameOf(frameEl.dataset.name);
811
+ const box = frameEl.querySelector(".frame-box");
812
+ const old = box.querySelector("iframe");
813
+ const img = box.querySelector("img");
814
+ const next = document.createElement("iframe");
815
+ next.title = f.name;
816
+ next.setAttribute("scrolling", "no");
817
+ if (on) {
818
+ // its own origin: the app's scripts run there, never on the review site
819
+ next.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
820
+ next.setAttribute("referrerpolicy", "no-referrer");
821
+ next.src = sessionFrame(f);
822
+ } else {
823
+ next.setAttribute("sandbox", "");
824
+ next.loading = "lazy";
825
+ next.src = f.html;
826
+ if (img) img.style.visibility = "";
827
+ }
828
+ next.addEventListener("load", () => { next.classList.add("ready"); if (on && img) img.style.visibility = "hidden"; });
829
+ old.replaceWith(next);
830
+ }
831
+ renderRoom();
832
+ toast(on ? "Live: you're seeing the design as the studio works on it" : "The live session ended: back to the published version");
833
+ }
834
+
835
+ function onEvent(e) {
836
+ if (e.t === "hello") {
837
+ me = e.you.id;
838
+ for (const id of [...people.keys()]) remove(id);
839
+ for (const p of e.people) add(p);
840
+ route = e.route;
841
+ setLive(e.live);
842
+ } else if (e.t === "join") { add(e.person); renderRoom(); }
843
+ else if (e.t === "leave") { remove(e.id); renderRoom(); }
844
+ else if (e.t === "cursor") { const p = people.get(e.id); if (p) moveCursor(p, e.x, e.y); }
845
+ else if (e.t === "state") { route = e.route || route; setLive(e.live); }
846
+ else if (e.t === "comments" && canComment) refresh();
847
+ }
848
+
849
+ function connect() {
850
+ const url = new URL("room", document.baseURI);
851
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
852
+ const typed = storedName();
853
+ if (!manifest.viewer && typed) url.searchParams.set("name", typed);
854
+ const s = new WebSocket(url.href);
855
+ sock = s;
856
+ s.onopen = () => {
857
+ retry = 1000;
858
+ clearInterval(ping);
859
+ // idle sockets are dropped after about 100 s: the room answers "ping" without waking up
860
+ ping = setInterval(() => s.readyState === 1 && s.send("ping"), 30000);
861
+ };
862
+ s.onmessage = (m) => {
863
+ if (m.data === "pong") return;
864
+ let e;
865
+ try { e = JSON.parse(m.data); } catch { return; }
866
+ onEvent(e);
867
+ };
868
+ s.onclose = () => {
869
+ clearInterval(ping);
870
+ if (sock !== s) return;
871
+ sock = null;
872
+ for (const id of [...people.keys()]) remove(id);
873
+ renderRoom();
874
+ setTimeout(connect, retry);
875
+ retry = Math.min(retry * 2, 15000);
876
+ };
877
+ }
878
+
879
+ // my cursor, in canvas coordinates, at most 20 times a second
880
+ let next = null, timer = 0, last = 0;
881
+ const flush = () => {
882
+ timer = 0;
883
+ if (!next || !sock || sock.readyState !== 1) return;
884
+ last = Date.now();
885
+ sock.send(JSON.stringify({ t: "cursor", x: next.x, y: next.y }));
886
+ next = null;
887
+ };
888
+ const sendCursor = (cx, cy) => {
889
+ next = { x: cx, y: cy };
890
+ if (!timer) timer = setTimeout(flush, Math.max(0, 50 - (Date.now() - last)));
891
+ };
892
+ stage.addEventListener("pointermove", (e) => sendCursor((e.clientX - x) / z + minX, (e.clientY - y) / z + minY));
893
+ stage.addEventListener("pointerleave", () => sendCursor(null, null));
894
+ connect();
895
+ }
650
896
  })();
651
897
  </script>
652
898
  </body>
@@ -654,6 +900,86 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
654
900
  `;
655
901
  }
656
902
 
903
+ // src/share/wire.ts
904
+ var CHUNK = 256 * 1024;
905
+ var enc = new TextEncoder();
906
+ var dec = new TextDecoder();
907
+ function encodeFrame(header, payload) {
908
+ const head = enc.encode(JSON.stringify(header));
909
+ const out = new Uint8Array(4 + head.length + (payload?.length ?? 0));
910
+ new DataView(out.buffer).setUint32(0, head.length);
911
+ out.set(head, 4);
912
+ if (payload) out.set(payload, 4 + head.length);
913
+ return out;
914
+ }
915
+ function decodeFrame(data) {
916
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
917
+ if (bytes.length < 4) throw new Error("Short tunnel frame");
918
+ const size = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
919
+ if (size > bytes.length - 4) throw new Error("Bad tunnel frame");
920
+ const header = JSON.parse(dec.decode(bytes.subarray(4, 4 + size)));
921
+ if (!header || typeof header.t !== "string" || typeof header.id !== "string") throw new Error("Bad tunnel frame");
922
+ return { header, payload: bytes.subarray(4 + size) };
923
+ }
924
+ function safePath(path) {
925
+ if (typeof path !== "string" || path.length > 8192) return null;
926
+ if (!path.startsWith("/") || path.startsWith("//") || /[\\\u0000-\u001f\u007f]/.test(path)) return null;
927
+ if (/^\/(%2f|%5c)/i.test(path)) return null;
928
+ return path;
929
+ }
930
+ var DROP_REQUEST = /* @__PURE__ */ new Set([
931
+ "host",
932
+ "connection",
933
+ "keep-alive",
934
+ "upgrade",
935
+ "transfer-encoding",
936
+ "te",
937
+ "trailer",
938
+ "proxy-authorization",
939
+ "proxy-connection",
940
+ "content-length",
941
+ "accept-encoding",
942
+ "origin",
943
+ "referer",
944
+ "authorization",
945
+ "cdn-loop",
946
+ "x-real-ip",
947
+ "true-client-ip",
948
+ "sec-websocket-key",
949
+ "sec-websocket-version",
950
+ "sec-websocket-extensions",
951
+ "sec-websocket-protocol"
952
+ ]);
953
+ function requestHeaders(headers) {
954
+ const out = [];
955
+ for (const [k, v] of headers) {
956
+ const key = k.toLowerCase();
957
+ if (DROP_REQUEST.has(key) || key.startsWith("cf-") || key.startsWith("x-forwarded-") || key.startsWith("sec-fetch-") || key.startsWith("x-tc-")) continue;
958
+ if (key === "cookie") {
959
+ const kept = v.split(";").map((c) => c.trim()).filter((c) => c && !c.startsWith("tc_"));
960
+ if (kept.length) out.push(["cookie", kept.join("; ")]);
961
+ continue;
962
+ }
963
+ out.push([key, v]);
964
+ }
965
+ return out;
966
+ }
967
+ var DROP_RESPONSE = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", "upgrade", "trailer", "alt-svc"]);
968
+ function responseHeaders(headers) {
969
+ const out = [];
970
+ for (const [k, v] of headers) {
971
+ const key = k.toLowerCase();
972
+ if (!DROP_RESPONSE.has(key)) out.push([key, v]);
973
+ }
974
+ return out;
975
+ }
976
+
657
977
  export {
658
- viewerHtml
978
+ viewerHtml,
979
+ CHUNK,
980
+ encodeFrame,
981
+ decodeFrame,
982
+ safePath,
983
+ requestHeaders,
984
+ responseHeaders
659
985
  };
@@ -0,0 +1,45 @@
1
+ // src/core/config.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ function loadConfig(root, overrides = {}) {
5
+ let file = {};
6
+ const configPath = path.join(root, "truecanvas.config.json");
7
+ if (fs.existsSync(configPath)) file = JSON.parse(fs.readFileSync(configPath, "utf8"));
8
+ let deps = {};
9
+ try {
10
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
11
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
12
+ } catch {
13
+ deps = {};
14
+ }
15
+ const framework = file.framework ?? detectFramework(deps);
16
+ const vite = framework === "vite";
17
+ const appDir = file.appDir ?? (vite ? fs.existsSync(path.join(root, "src")) ? "src" : "." : fs.existsSync(path.join(root, "src/app")) ? "src/app" : "app");
18
+ const srcPrefix = appDir === "src" || appDir.startsWith("src/") ? "src/" : "";
19
+ const knownLibraries = ["@paper-design/shaders-react"].filter((l) => l in deps);
20
+ return {
21
+ root,
22
+ framework,
23
+ appUrl: vite ? "http://localhost:5173" : "http://localhost:3000",
24
+ port: 4800,
25
+ canvasDir: `${srcPrefix}canvas`,
26
+ appDir,
27
+ routeDir: vite ? ".truecanvas" : `${appDir}/truecanvas`,
28
+ components: [`${srcPrefix}components/**/*.tsx`],
29
+ darkMode: { strategy: "class", value: "dark" },
30
+ ...file,
31
+ libraries: [.../* @__PURE__ */ new Set([...knownLibraries, ...file.libraries ?? []])],
32
+ ...stripUndefined(overrides)
33
+ };
34
+ }
35
+ function stripUndefined(o) {
36
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
37
+ }
38
+ function detectFramework(deps) {
39
+ return !("next" in deps) && "vite" in deps ? "vite" : "next";
40
+ }
41
+
42
+ export {
43
+ loadConfig,
44
+ detectFramework
45
+ };