tina4-nodejs 3.13.50 → 3.13.52
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/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/packages/core/public/js/frond.js +138 -1
- package/packages/core/public/js/frond.min.js +2 -2
- package/packages/core/src/devAdmin.ts +87 -42
- package/packages/core/src/docsAutoDiscovery.ts +4 -3
- package/packages/core/src/mcp.ts +187 -38
- package/packages/core/src/scss.ts +97 -0
- package/packages/core/src/server.ts +24 -0
- package/packages/frond/src/engine.ts +224 -0
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/src/database.ts +4 -3
package/CLAUDE.md
CHANGED
package/package.json
CHANGED
|
@@ -558,6 +558,132 @@ var _frondModule = (() => {
|
|
|
558
558
|
}
|
|
559
559
|
});
|
|
560
560
|
}
|
|
561
|
+
function _liveKey(el) {
|
|
562
|
+
const d = el.dataset;
|
|
563
|
+
return d && d.key ? d.key : null;
|
|
564
|
+
}
|
|
565
|
+
function _liveSyncAttrs(oldNode, newNode) {
|
|
566
|
+
const na = newNode.attributes;
|
|
567
|
+
for (let i = 0; i < na.length; i++) {
|
|
568
|
+
const a = na[i];
|
|
569
|
+
if (oldNode.getAttribute(a.name) !== a.value) oldNode.setAttribute(a.name, a.value);
|
|
570
|
+
}
|
|
571
|
+
const oa = Array.prototype.slice.call(oldNode.attributes);
|
|
572
|
+
oa.forEach(function(a) {
|
|
573
|
+
if (!newNode.hasAttribute(a.name)) oldNode.removeAttribute(a.name);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
function _liveMorphNode(oldNode, newNode) {
|
|
577
|
+
const tag = newNode.tagName;
|
|
578
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
|
579
|
+
_liveSyncAttrs(oldNode, newNode);
|
|
580
|
+
if (oldNode.children.length || newNode.children.length) {
|
|
581
|
+
_liveReconcile(oldNode, newNode);
|
|
582
|
+
} else if (oldNode.innerHTML !== newNode.innerHTML) {
|
|
583
|
+
oldNode.innerHTML = newNode.innerHTML;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function _liveReconcile(parent, next) {
|
|
587
|
+
const oldKids = Array.prototype.slice.call(parent.children);
|
|
588
|
+
const newKids = Array.prototype.slice.call(next.children);
|
|
589
|
+
const oldByKey = {};
|
|
590
|
+
oldKids.forEach(function(c) {
|
|
591
|
+
const k = _liveKey(c);
|
|
592
|
+
if (k) oldByKey[k] = c;
|
|
593
|
+
});
|
|
594
|
+
const order = [];
|
|
595
|
+
for (let i = 0; i < newKids.length; i++) {
|
|
596
|
+
const nk = newKids[i];
|
|
597
|
+
const k = _liveKey(nk);
|
|
598
|
+
let match = null;
|
|
599
|
+
if (k && oldByKey[k]) {
|
|
600
|
+
match = oldByKey[k];
|
|
601
|
+
} else if (!k && oldKids[i] && !_liveKey(oldKids[i]) && oldKids[i].tagName === nk.tagName) {
|
|
602
|
+
match = oldKids[i];
|
|
603
|
+
}
|
|
604
|
+
if (match && match.tagName === nk.tagName) {
|
|
605
|
+
_liveMorphNode(match, nk);
|
|
606
|
+
reused.push(match);
|
|
607
|
+
order.push(match);
|
|
608
|
+
} else {
|
|
609
|
+
order.push(nk);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
let cursor = parent.firstElementChild;
|
|
613
|
+
for (let i = 0; i < order.length; i++) {
|
|
614
|
+
const node = order[i];
|
|
615
|
+
if (node === cursor) {
|
|
616
|
+
cursor = cursor.nextElementSibling;
|
|
617
|
+
} else {
|
|
618
|
+
parent.insertBefore(node, cursor);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
oldKids.forEach(function(c) {
|
|
622
|
+
if (order.indexOf(c) === -1 && c.parentNode === parent) parent.removeChild(c);
|
|
623
|
+
});
|
|
624
|
+
void reused;
|
|
625
|
+
}
|
|
626
|
+
function _liveSwap(container, html) {
|
|
627
|
+
const tmp = document.createElement("div");
|
|
628
|
+
tmp.innerHTML = html;
|
|
629
|
+
if (!tmp.children.length || !container.children.length) {
|
|
630
|
+
container.innerHTML = html;
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
_liveReconcile(container, tmp);
|
|
634
|
+
}
|
|
635
|
+
function _liveWsUrl(path) {
|
|
636
|
+
if (/^wss?:\/\//.test(path)) return path;
|
|
637
|
+
const proto = typeof location !== "undefined" && location.protocol === "https:" ? "wss" : "ws";
|
|
638
|
+
return proto + "://" + location.host + path;
|
|
639
|
+
}
|
|
640
|
+
function _liveExtract(msg, name) {
|
|
641
|
+
if (msg && typeof msg === "object") {
|
|
642
|
+
if (msg.type === "live") {
|
|
643
|
+
if (name && msg.name && msg.name !== name) return null;
|
|
644
|
+
return msg.html != null ? String(msg.html) : null;
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
return typeof msg === "string" ? msg : null;
|
|
649
|
+
}
|
|
650
|
+
function liveInit(root) {
|
|
651
|
+
if (typeof document === "undefined") return;
|
|
652
|
+
const scope = root || document;
|
|
653
|
+
const blocks = scope.querySelectorAll("[data-frond-live]");
|
|
654
|
+
Array.prototype.slice.call(blocks).forEach(function(el) {
|
|
655
|
+
if (el.__frondLive) return;
|
|
656
|
+
el.__frondLive = true;
|
|
657
|
+
const mode = el.getAttribute("data-mode");
|
|
658
|
+
const name = el.getAttribute("data-frond-live");
|
|
659
|
+
if (mode === "poll") {
|
|
660
|
+
const src = el.getAttribute("data-src");
|
|
661
|
+
const interval = (parseInt(el.getAttribute("data-interval"), 10) || 5) * 1e3;
|
|
662
|
+
const timer = setInterval(function() {
|
|
663
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
664
|
+
request(src, { method: "GET", onSuccess: function(data) {
|
|
665
|
+
_liveSwap(el, typeof data === "string" ? data : String(data));
|
|
666
|
+
} });
|
|
667
|
+
}, interval);
|
|
668
|
+
el.__frondLiveStop = function() {
|
|
669
|
+
clearInterval(timer);
|
|
670
|
+
};
|
|
671
|
+
} else if (mode === "ws") {
|
|
672
|
+
const sock = wsConnect(_liveWsUrl(el.getAttribute("data-ws")));
|
|
673
|
+
sock.on("message", function(msg) {
|
|
674
|
+
const h = _liveExtract(msg, name);
|
|
675
|
+
if (h !== null) _liveSwap(el, h);
|
|
676
|
+
});
|
|
677
|
+
el.__frondLiveStop = function() {
|
|
678
|
+
sock.close();
|
|
679
|
+
};
|
|
680
|
+
} else if (mode === "sse") {
|
|
681
|
+
if (typeof console !== "undefined" && console.warn) {
|
|
682
|
+
console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '" + name + "' shows first paint only. Use poll or ws.");
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
}
|
|
561
687
|
var frond = {
|
|
562
688
|
/** Core HTTP request. */
|
|
563
689
|
request,
|
|
@@ -573,6 +699,8 @@ var _frondModule = (() => {
|
|
|
573
699
|
ws: wsConnect,
|
|
574
700
|
/** Server-Sent Events with auto-reconnect. */
|
|
575
701
|
sse: sseConnect,
|
|
702
|
+
/** Wire {% live %} blocks (poll/ws) with keyed morph. Auto-runs on DOMContentLoaded. */
|
|
703
|
+
live: liveInit,
|
|
576
704
|
/** Cookie helpers: get, set, remove. */
|
|
577
705
|
cookie,
|
|
578
706
|
/** Display alert message in #message element. */
|
|
@@ -593,8 +721,17 @@ var _frondModule = (() => {
|
|
|
593
721
|
};
|
|
594
722
|
if (typeof window !== "undefined") {
|
|
595
723
|
window.frond = frond;
|
|
724
|
+
if (typeof document !== "undefined") {
|
|
725
|
+
if (document.readyState === "loading") {
|
|
726
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
727
|
+
liveInit();
|
|
728
|
+
});
|
|
729
|
+
} else {
|
|
730
|
+
liveInit();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
596
733
|
}
|
|
597
734
|
return __toCommonJS(frond_exports);
|
|
598
735
|
})();
|
|
599
|
-
/* Frond v2.
|
|
736
|
+
/* Frond v2.2.0 - tina4.com */
|
|
600
737
|
//# sourceMappingURL=frond.js.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var _frondModule=(()=>{var b=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var O=(o,s)=>{for(var e in s)b(o,e,{get:s[e],enumerable:!0})},M=(o,s,e,t)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of x(s))!C.call(o,n)&&n!==e&&b(o,n,{get:()=>s[n],enumerable:!(t=k(s,n))||t.enumerable});return o};var q=o=>M(b({},"__esModule",{value:!0}),o);var j={};O(j,{frond:()=>R});var g=null;function w(o,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let t=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(t,o,!0),g!==null&&n.setRequestHeader("Authorization","Bearer "+g),e.headers)for(let r in e.headers)Object.prototype.hasOwnProperty.call(e.headers,r)&&n.setRequestHeader(r,e.headers[r]);let i=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?i=e.body:typeof e.body=="object"?(i=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(i=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let r=n.getResponseHeader("FreshToken");r&&r!==""&&(g=r);let u=n.response;try{u=JSON.parse(u)}catch{}if(n.responseURL){let c=new URL(o,window.location.href).href;if(n.responseURL!==c){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(u,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(i)}function h(o,s){if(!o)return"";let e=new DOMParser,t=o.includes("<html>")?o:"<body>"+o+"</body></html>",i=e.parseFromString(t,"text/html").querySelector("body"),r=i.querySelectorAll("script");if(r.forEach(function(u){u.remove()}),s!==null){let u=document.getElementById(s);return u&&(i.children.length>0?u.replaceChildren.apply(u,Array.from(i.children)):u.innerHTML=i.innerHTML,r.forEach(function(c){let d=document.createElement("script");d.type="text/javascript",d.async=!0,c.src?d.src=c.src:d.textContent=c.textContent,u.appendChild(d)})),""}return r.forEach(function(u){let c=document.createElement("script");c.type="text/javascript",c.async=!0,c.textContent=u.textContent,document.body.appendChild(c)}),i.innerHTML}function H(o,s,e){let t=s||"content";w(o,{method:"GET",onSuccess:function(n,i){if(document.getElementById(t)){let r=h(n,t);e&&e(r,n)}else e&&e(n)}})}function S(o,s,e,t){let n=e||"content";w(o,{method:"POST",body:s,onSuccess:function(i){let r="";if(i&&i.message!==void 0)r=h(i.message,n);else if(document.getElementById(n))r=h(i,n);else{t&&t(i);return}t&&t(r,i)}})}var T={collect:function(o){let s=new FormData,e=document.querySelectorAll("#"+o+" select, #"+o+" input, #"+o+" textarea");for(let t=0;t<e.length;t++){let n=e[t];if(n.name==="formToken"&&g!==null&&(n.value=g),!!n.name)if(n.type==="file"){let i=n.files;if(i)for(let r=0;r<i.length;r++){let u=i[r];if(u!==void 0){let c=n.name;i.length>1&&!c.includes("[")&&(c=c+"[]"),s.append(c,u,u.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(o,s,e,t){let n=T.collect(o);S(s,n,e||"message",t)},show:function(o,s,e,t){let n=o.toUpperCase();(o==="create"||o==="edit")&&(n="GET"),o==="delete"&&(n="DELETE");let i=e||"form";w(s,{method:n,onSuccess:function(r){let u="";if(r&&r.message!==void 0)u=h(r.message,i);else if(document.getElementById(i))u=h(r,i);else{t&&t(r);return}t&&t(u)}})}};function L(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",send:function(l){if(!t||t.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");t.send(typeof l=="string"?l:JSON.stringify(l))},on:function(l,a){return c[l]||(c[l]=[]),c[l].push(a),function(){let f=c[l],m=f.indexOf(a);m>=0&&f.splice(m,1)}},close:function(l,a){n=!0,u&&(clearTimeout(u),u=null),t&&t.close(l||1e3,a||""),d.status="closed"}};function y(l){if(typeof l!="string")return l;try{return JSON.parse(l)}catch{return l}}function p(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,v()},i),i=Math.min(i*2,e.maxReconnectDelay))}function v(){d.status=r>0?"reconnecting":"connecting";try{t=new WebSocket(o,e.protocols)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let l of c.open)l()},t.onmessage=function(l){let a=y(l.data);for(let f of c.message)f(a)},t.onclose=function(l){d.status="closed",e.onClose(l.code,l.reason);for(let a of c.close)a(l.code,l.reason);n||p()},t.onerror=function(l){e.onError(l);for(let a of c.error)a(l)}}return v(),d}function D(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",on:function(a,f){return c[a]||(c[a]=[]),c[a].push(f),function(){let m=c[a],E=m.indexOf(f);E>=0&&m.splice(E,1)}},close:function(){n=!0,u&&(clearTimeout(u),u=null),t&&(t.close(),t=null),d.status="closed"}};function y(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,f){for(let m of c.message)m(a,f||void 0)}function v(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,l()},i),i=Math.min(i*2,e.maxReconnectDelay))}function l(){d.status=r>0?"reconnecting":"connecting";try{t=new EventSource(o)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let a of c.open)a(null)},t.onmessage=function(a){p(y(a.data),null)};for(let a of e.events)t.addEventListener(a,function(f){p(y(f.data),a)});t.onerror=function(a){e.onError(a);for(let f of c.error)f(a);if(t&&t.readyState===2){t=null,d.status="closed",e.onClose();for(let f of c.close)f(null);n||v()}}}return l(),d}var W={set:function(o,s,e){let t="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),t="; expires="+n.toUTCString()}document.cookie=o+"="+(s||"")+t+"; path=/"},get:function(o){let s=o+"=",e=document.cookie.split(";");for(let t=0;t<e.length;t++){let n=e[t];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(o){document.cookie=o+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function A(o,s){let e=document.getElementById("message");if(!e)return;let t=s||"info";e.innerHTML='<div class="alert alert-'+t+' alert-dismissible">'+o+'<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>'}function I(o,s,e,t){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,i=window.screenTop!==void 0?window.screenTop:window.screenY,r=window.innerWidth||document.documentElement.clientWidth||screen.width,u=window.innerHeight||document.documentElement.clientHeight||screen.height,c=r/window.screen.availWidth,d=(r-e)/2/c+n,y=(u-t)/2/c+i,p=window.open(o,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/c+",height="+t/c+",top="+y+",left="+d);return window.focus&&p&&p.focus(),p}function N(o){if(o.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(o,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function U(o,s,e,t){w(o,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){t&&t(n.data||null,n.errors||void 0)},onError:function(n){t&&t(null,[{message:"GraphQL request failed with status "+n}])}})}var R={request:w,load:H,post:S,inject:h,form:T,ws:L,sse:D,cookie:W,message:A,popup:I,report:N,graphql:U,get token(){return g},set token(o){g=o}};typeof window<"u"&&(window.frond=R);return q(j);})();
|
|
2
|
-
/* Frond v2.
|
|
1
|
+
var _frondModule=(()=>{var E=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var H=Object.prototype.hasOwnProperty;var q=(t,s)=>{for(var e in s)E(t,e,{get:s[e],enumerable:!0})},D=(t,s,e,o)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of A(s))!H.call(t,n)&&n!==e&&E(t,n,{get:()=>s[n],enumerable:!(o=O(s,n))||o.enumerable});return t};var W=t=>D(E({},"__esModule",{value:!0}),t);var z={};q(z,{frond:()=>L});var y=null;function v(t,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(o,t,!0),y!==null&&n.setRequestHeader("Authorization","Bearer "+y),e.headers)for(let u in e.headers)Object.prototype.hasOwnProperty.call(e.headers,u)&&n.setRequestHeader(u,e.headers[u]);let c=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?c=e.body:typeof e.body=="object"?(c=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(c=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let u=n.getResponseHeader("FreshToken");u&&u!==""&&(y=u);let r=n.response;try{r=JSON.parse(r)}catch{}if(n.responseURL){let i=new URL(t,window.location.href).href;if(n.responseURL!==i){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(r,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(c)}function h(t,s){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(r){r.remove()}),s!==null){let r=document.getElementById(s);return r&&(c.children.length>0?r.replaceChildren.apply(r,Array.from(c.children)):r.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,r.appendChild(l)})),""}return u.forEach(function(r){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=r.textContent,document.body.appendChild(i)}),c.innerHTML}function _(t,s,e){let o=s||"content";v(t,{method:"GET",onSuccess:function(n,c){if(document.getElementById(o)){let u=h(n,o);e&&e(u,n)}else e&&e(n)}})}function R(t,s,e,o){let n=e||"content";v(t,{method:"POST",body:s,onSuccess:function(c){let u="";if(c&&c.message!==void 0)u=h(c.message,n);else if(document.getElementById(n))u=h(c,n);else{o&&o(c);return}o&&o(u,c)}})}var x={collect:function(t){let s=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name==="formToken"&&y!==null&&(n.value=y),!!n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let r=c[u];if(r!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),s.append(i,r,r.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(t,s,e,o){let n=x.collect(t);R(s,n,e||"message",o)},show:function(t,s,e,o){let n=t.toUpperCase();(t==="create"||t==="edit")&&(n="GET"),t==="delete"&&(n="DELETE");let c=e||"form";v(s,{method:n,onSuccess:function(u){let r="";if(u&&u.message!==void 0)r=h(u.message,c);else if(document.getElementById(c))r=h(u,c);else{o&&o(u);return}o&&o(r)}})}};function M(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(f){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof f=="string"?f:JSON.stringify(f))},on:function(f,a){return i[f]||(i[f]=[]),i[f].push(a),function(){let d=i[f],g=d.indexOf(a);g>=0&&d.splice(g,1)}},close:function(f,a){n=!0,r&&(clearTimeout(r),r=null),o&&o.close(f||1e3,a||""),l.status="closed"}};function p(f){if(typeof f!="string")return f;try{return JSON.parse(f)}catch{return f}}function m(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let f of i.open)f()},o.onmessage=function(f){let a=p(f.data);for(let d of i.message)d(a)},o.onclose=function(f){l.status="closed",e.onClose(f.code,f.reason);for(let a of i.close)a(f.code,f.reason);n||m()},o.onerror=function(f){e.onError(f);for(let a of i.error)a(f)}}return w(),l}function I(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,d){return i[a]||(i[a]=[]),i[a].push(d),function(){let g=i[a],T=g.indexOf(d);T>=0&&g.splice(T,1)}},close:function(){n=!0,r&&(clearTimeout(r),r=null),o&&(o.close(),o=null),l.status="closed"}};function p(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function m(a,d){for(let g of i.message)g(a,d||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,f()},c),c=Math.min(c*2,e.maxReconnectDelay))}function f(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){m(p(a.data),null)};for(let a of e.events)o.addEventListener(a,function(d){m(p(d.data),a)});o.onerror=function(a){e.onError(a);for(let d of i.error)d(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let d of i.close)d(null);n||w()}}}return f(),l}var U={set:function(t,s,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(s||"")+o+"; path=/"},get:function(t){let s=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function j(t,s){let e=document.getElementById("message");if(!e)return;let o=s||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>'}function B(t,s,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,r=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,p=(r-o)/2/i+c,m=window.open(t,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+p+",left="+l);return window.focus&&m&&m.focus(),m}function P(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function F(t,s,e,o){v(t,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let s=t.dataset;return s&&s.key?s.key:null}function X(t,s){let e=s.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){s.hasAttribute(n.name)||t.removeAttribute(n.name)})}function G(t,s){let e=s.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(X(t,s),t.children.length||s.children.length?C(t,s):t.innerHTML!==s.innerHTML&&(t.innerHTML=s.innerHTML))}function C(t,s){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(s.children),n={};e.forEach(function(r){let i=b(r);i&&(n[i]=r)});let c=[];for(let r=0;r<o.length;r++){let i=o[r],l=b(i),p=null;l&&n[l]?p=n[l]:!l&&e[r]&&!b(e[r])&&e[r].tagName===i.tagName&&(p=e[r]),p&&p.tagName===i.tagName?(G(p,i),reused.push(p),c.push(p)):c.push(i)}let u=t.firstElementChild;for(let r=0;r<c.length;r++){let i=c[r];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(r){c.indexOf(r)===-1&&r.parentNode===t&&t.removeChild(r)}),reused}function k(t,s){let e=document.createElement("div");if(e.innerHTML=s,!e.children.length||!t.children.length){t.innerHTML=s;return}C(t,e)}function J(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function N(t,s){return t&&typeof t=="object"?t.type==="live"?s&&t.name&&t.name!==s?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),r=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||v(u,{method:"GET",onSuccess:function(l){k(o,typeof l=="string"?l:String(l))}})},r);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=M(J(o.getAttribute("data-ws")));u.on("message",function(r){let i=N(r,c);i!==null&&k(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var L={request:v,load:_,post:R,inject:h,form:x,ws:M,sse:I,live:S,cookie:U,message:j,popup:B,report:P,graphql:F,get token(){return y},set token(t){y=t}};typeof window<"u"&&(window.frond=L,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(z);})();
|
|
2
|
+
/* Frond v2.2.0 - tina4.com */
|
|
@@ -612,12 +612,16 @@ export class DevAdmin {
|
|
|
612
612
|
// MCP tool introspection over the built-in MCP server (browser dev-admin REST shim)
|
|
613
613
|
{ method: "GET", pattern: "/__dev/api/mcp/tools", handler: handleMcpTools },
|
|
614
614
|
{ method: "POST", pattern: "/__dev/api/mcp/call", handler: handleMcpCall },
|
|
615
|
-
// MCP
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
615
|
+
// MCP transport endpoints that REAL MCP clients (Claude Code/Desktop) speak.
|
|
616
|
+
// /__dev/mcp is the Streamable HTTP endpoint (current transport): POST = a
|
|
617
|
+
// JSON-RPC message (initialize issues Mcp-Session-Id), DELETE = terminate a
|
|
618
|
+
// session, GET = 405 (this server initiates no messages). /message + /sse
|
|
619
|
+
// are the legacy 2024-11-05 HTTP+SSE transport, kept for older SSE-only
|
|
620
|
+
// clients. Mirrors the Python master.
|
|
621
|
+
{ method: "POST", pattern: "/__dev/mcp", handler: handleMcpStreamable },
|
|
622
|
+
{ method: "DELETE", pattern: "/__dev/mcp", handler: handleMcpDelete },
|
|
623
|
+
{ method: "GET", pattern: "/__dev/mcp", handler: handleMcpGet405 },
|
|
624
|
+
{ method: "POST", pattern: "/__dev/mcp/message", handler: handleMcpLegacyMessage },
|
|
621
625
|
{ method: "GET", pattern: "/__dev/mcp/sse", handler: handleMcpSse },
|
|
622
626
|
];
|
|
623
627
|
for (const route of mcpRoutes) {
|
|
@@ -2241,13 +2245,31 @@ const handleMcpCall: RouteHandler = async (req, res) => {
|
|
|
2241
2245
|
/**
|
|
2242
2246
|
* JSON-RPC message endpoint for real MCP clients.
|
|
2243
2247
|
*
|
|
2244
|
-
* Mounted at POST /__dev/mcp
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
2247
|
-
*
|
|
2248
|
-
* clients connect without a token.
|
|
2248
|
+
* Mounted at POST /__dev/mcp (Streamable HTTP). Forwards the body to the default
|
|
2249
|
+
* dev MCP server via dispatchHttp(): initialize issues an Mcp-Session-Id header,
|
|
2250
|
+
* an unknown session id is 404 (client re-inits), a notification is 202, else
|
|
2251
|
+
* 200 with the JSON-RPC response as application/json. The /__dev path is always
|
|
2252
|
+
* public (auth-bypassed), so MCP clients connect without a token.
|
|
2249
2253
|
*/
|
|
2250
|
-
|
|
2254
|
+
function mcpNormalizeBody(body: unknown): string | Record<string, unknown> {
|
|
2255
|
+
if (typeof body === "object" && body !== null) return body as Record<string, unknown>;
|
|
2256
|
+
if (typeof body === "string") return body;
|
|
2257
|
+
return String(body ?? "");
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
function applyMcpOutcome(
|
|
2261
|
+
res: Parameters<RouteHandler>[1],
|
|
2262
|
+
outcome: { status: number; headers: Record<string, string>; body: string },
|
|
2263
|
+
): void {
|
|
2264
|
+
for (const [n, v] of Object.entries(outcome.headers)) res.addHeader(n, v);
|
|
2265
|
+
if (!outcome.body) {
|
|
2266
|
+
res.send("", outcome.status);
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
res.json(JSON.parse(outcome.body), outcome.status);
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
const handleMcpStreamable: RouteHandler = async (req, res) => {
|
|
2251
2273
|
if (!mcpRequestAllowed(req)) {
|
|
2252
2274
|
res.json({ error: "MCP forbidden" }, 404);
|
|
2253
2275
|
return;
|
|
@@ -2255,48 +2277,71 @@ const handleMcpMessage: RouteHandler = async (req, res) => {
|
|
|
2255
2277
|
try {
|
|
2256
2278
|
const { getDefaultDevServer } = await import("./mcp.js");
|
|
2257
2279
|
const server = getDefaultDevServer();
|
|
2258
|
-
const
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2280
|
+
const sessionId = req.header("mcp-session-id") ?? "";
|
|
2281
|
+
applyMcpOutcome(res, await server.dispatchHttp(mcpNormalizeBody(req.body), sessionId));
|
|
2282
|
+
} catch (e) {
|
|
2283
|
+
res.json({ error: (e as Error).message }, 500);
|
|
2284
|
+
}
|
|
2285
|
+
};
|
|
2286
|
+
|
|
2287
|
+
/** DELETE /__dev/mcp — terminate the session named by Mcp-Session-Id. */
|
|
2288
|
+
const handleMcpDelete: RouteHandler = async (req, res) => {
|
|
2289
|
+
if (!mcpRequestAllowed(req)) {
|
|
2290
|
+
res.json({ error: "MCP forbidden" }, 404);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
const { getDefaultDevServer } = await import("./mcp.js");
|
|
2294
|
+
getDefaultDevServer().closeSession(req.header("mcp-session-id") ?? "");
|
|
2295
|
+
res.send("", 204);
|
|
2296
|
+
};
|
|
2297
|
+
|
|
2298
|
+
/** GET /__dev/mcp — 405: this server initiates no messages (use /sse for a stream). */
|
|
2299
|
+
const handleMcpGet405: RouteHandler = (req, res) => {
|
|
2300
|
+
if (!mcpRequestAllowed(req)) {
|
|
2301
|
+
res.json({ error: "MCP forbidden" }, 404);
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
res.addHeader("Allow", "POST, DELETE");
|
|
2305
|
+
res.json({ error: "method not allowed" }, 405);
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
/**
|
|
2309
|
+
* POST /__dev/mcp/message — legacy HTTP+SSE message sink. Delivers the JSON-RPC
|
|
2310
|
+
* response on the matching open SSE stream (202 here); with no open stream it
|
|
2311
|
+
* degrades to an inline Streamable HTTP response, so the path serves a plain
|
|
2312
|
+
* POST client too.
|
|
2313
|
+
*/
|
|
2314
|
+
const handleMcpLegacyMessage: RouteHandler = async (req, res) => {
|
|
2315
|
+
if (!mcpRequestAllowed(req)) {
|
|
2316
|
+
res.json({ error: "MCP forbidden" }, 404);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
try {
|
|
2320
|
+
const { getDefaultDevServer } = await import("./mcp.js");
|
|
2321
|
+
const server = getDefaultDevServer();
|
|
2322
|
+
const sessionId = (req.query?.sessionId as string) || (req.header("mcp-session-id") ?? "");
|
|
2323
|
+
applyMcpOutcome(res, await server.dispatchSseMessage(mcpNormalizeBody(req.body), sessionId));
|
|
2272
2324
|
} catch (e) {
|
|
2273
2325
|
res.json({ error: (e as Error).message }, 500);
|
|
2274
2326
|
}
|
|
2275
2327
|
};
|
|
2276
2328
|
|
|
2277
2329
|
/**
|
|
2278
|
-
* SSE
|
|
2279
|
-
*
|
|
2280
|
-
*
|
|
2281
|
-
*
|
|
2282
|
-
* the Python v3 fix. Content-Type text/event-stream, status 200.
|
|
2330
|
+
* GET /__dev/mcp/sse — legacy HTTP+SSE stream. Opens a persistent SSE connection:
|
|
2331
|
+
* first the `endpoint` event naming the (session-tagged) POST target, then each
|
|
2332
|
+
* JSON-RPC response as it arrives. Node's single event loop lets a separate POST
|
|
2333
|
+
* to /message feed this stream via an in-process channel.
|
|
2283
2334
|
*/
|
|
2284
2335
|
const handleMcpSse: RouteHandler = async (req, res) => {
|
|
2285
2336
|
if (!mcpRequestAllowed(req)) {
|
|
2286
2337
|
res.json({ error: "MCP forbidden" }, 404);
|
|
2287
2338
|
return;
|
|
2288
2339
|
}
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
const
|
|
2292
|
-
const
|
|
2293
|
-
|
|
2294
|
-
res.raw.writeHead(200, {
|
|
2295
|
-
"Content-Type": "text/event-stream",
|
|
2296
|
-
"Cache-Control": "no-cache",
|
|
2297
|
-
Connection: "keep-alive",
|
|
2298
|
-
});
|
|
2299
|
-
res.raw.end(sseData);
|
|
2340
|
+
const { getDefaultDevServer } = await import("./mcp.js");
|
|
2341
|
+
const server = getDefaultDevServer();
|
|
2342
|
+
const sessionId = server.openSession();
|
|
2343
|
+
const base = (req.path || "/__dev/mcp/sse").replace(/\/sse$/, "");
|
|
2344
|
+
await res.stream(server.sseStream(sessionId, `${base}/message?sessionId=${sessionId}`));
|
|
2300
2345
|
};
|
|
2301
2346
|
|
|
2302
2347
|
const handleScaffoldList: RouteHandler = (_req, res) => {
|
|
@@ -10,7 +10,7 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
|
|
12
12
|
interface McpDiscovery {
|
|
13
|
-
mcpServers: Record<string, { url: string; description: string }>;
|
|
13
|
+
mcpServers: Record<string, { type?: string; url: string; description: string }>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const TINA4_DIR = ".tina4";
|
|
@@ -34,8 +34,9 @@ export function writeMcpDiscovery(projectRoot: string, port: number): boolean {
|
|
|
34
34
|
const desired: McpDiscovery = {
|
|
35
35
|
mcpServers: {
|
|
36
36
|
"tina4-live-docs": {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
type: "http",
|
|
38
|
+
url: `http://localhost:${portStr}/__dev/mcp`,
|
|
39
|
+
description: "Live API docs + dev tools for this Tina4 project (framework + user code)",
|
|
39
40
|
},
|
|
40
41
|
},
|
|
41
42
|
};
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -19,6 +19,7 @@ import * as path from "node:path";
|
|
|
19
19
|
import * as os from "node:os";
|
|
20
20
|
import { spawnSync } from "node:child_process";
|
|
21
21
|
import { createRequire } from "node:module";
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
22
23
|
|
|
23
24
|
// Synchronous CommonJS-style require that works under real ESM (where the
|
|
24
25
|
// bare `require` global is undefined). Dev-tool handlers are synchronous, so
|
|
@@ -96,6 +97,12 @@ export const METHOD_NOT_FOUND = -32601;
|
|
|
96
97
|
export const INVALID_PARAMS = -32602;
|
|
97
98
|
export const INTERNAL_ERROR = -32603;
|
|
98
99
|
|
|
100
|
+
// MCP protocol versions this server can speak, newest first. The 2025-* versions
|
|
101
|
+
// are the Streamable HTTP era; 2024-11-05 is the legacy HTTP+SSE transport we
|
|
102
|
+
// still accept for older clients (Claude Desktop et al.).
|
|
103
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"] as const;
|
|
104
|
+
export const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
105
|
+
|
|
99
106
|
export function encodeResponse(requestId: number | string | null | undefined, result: unknown): string {
|
|
100
107
|
return JSON.stringify({ jsonrpc: "2.0", id: requestId, result });
|
|
101
108
|
}
|
|
@@ -295,6 +302,15 @@ export class McpServer {
|
|
|
295
302
|
private _resources: Map<string, McpResourceDefinition> = new Map();
|
|
296
303
|
private _initialized = false;
|
|
297
304
|
|
|
305
|
+
// Streamable HTTP session ids issued at initialize time -> creation ts. A
|
|
306
|
+
// request bearing an unknown id gets a 404 so the client re-initializes.
|
|
307
|
+
private _sessions: Map<string, number> = new Map();
|
|
308
|
+
// Open legacy HTTP+SSE streams keyed by session id. GET /sse registers a
|
|
309
|
+
// channel; POST /message pushes each JSON-RPC response onto it so it streams
|
|
310
|
+
// back on the open connection (the 2024-11-05 transport). Node is single-
|
|
311
|
+
// threaded, so this in-process map is the whole coordination mechanism.
|
|
312
|
+
private _sseChannels: Map<string, { buffer: string[]; wake: (() => void) | null }> = new Map();
|
|
313
|
+
|
|
298
314
|
constructor(mcpPath: string, name = "Tina4 MCP", version = "1.0.0") {
|
|
299
315
|
this.path = mcpPath.replace(/\/+$/, "");
|
|
300
316
|
this.name = name;
|
|
@@ -302,6 +318,128 @@ export class McpServer {
|
|
|
302
318
|
McpServer._instances.push(this);
|
|
303
319
|
}
|
|
304
320
|
|
|
321
|
+
// ── Session lifecycle + protocol negotiation ──────────────────
|
|
322
|
+
|
|
323
|
+
/** Mint a new session id and remember it. Called on `initialize`. */
|
|
324
|
+
openSession(): string {
|
|
325
|
+
const sid = randomBytes(16).toString("hex");
|
|
326
|
+
this._sessions.set(sid, Date.now());
|
|
327
|
+
return sid;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** True when `sessionId` was issued by this server and is still open. */
|
|
331
|
+
isValidSession(sessionId: string | undefined | null): boolean {
|
|
332
|
+
return !!sessionId && this._sessions.has(sessionId);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Forget a session (client DELETE or SSE stream close). */
|
|
336
|
+
closeSession(sessionId: string | undefined | null): boolean {
|
|
337
|
+
return sessionId ? this._sessions.delete(sessionId) : false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Pick the protocol version to run on. Echo the client's requested version
|
|
342
|
+
* when we support it (proper negotiation), else fall back to the newest we
|
|
343
|
+
* speak so an unversioned/old client still connects.
|
|
344
|
+
*/
|
|
345
|
+
negotiateProtocolVersion(requested: string | undefined | null): string {
|
|
346
|
+
return requested && (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(requested)
|
|
347
|
+
? requested
|
|
348
|
+
: LATEST_PROTOCOL_VERSION;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private _peekMethod(raw: string | Record<string, unknown>): string | null {
|
|
352
|
+
try {
|
|
353
|
+
const obj = typeof raw === "object" && raw !== null ? raw : JSON.parse(String(raw || "{}"));
|
|
354
|
+
return obj && typeof obj === "object" ? ((obj as Record<string, unknown>).method as string) ?? null : null;
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Streamable HTTP transport (transport-agnostic, mirrors Python) ──
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Streamable HTTP POST handler. initialize mints a session id (returned in
|
|
364
|
+
* the Mcp-Session-Id response header); a non-initialize request with an
|
|
365
|
+
* unknown session id is a 404 (client re-inits); a notification is 202; else
|
|
366
|
+
* 200 with the JSON-RPC response as application/json (which the spec permits
|
|
367
|
+
* for a POST that resolves to a single response).
|
|
368
|
+
*/
|
|
369
|
+
async dispatchHttp(
|
|
370
|
+
raw: string | Record<string, unknown>,
|
|
371
|
+
sessionId = "",
|
|
372
|
+
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
|
|
373
|
+
const isInit = this._peekMethod(raw) === "initialize";
|
|
374
|
+
if (!isInit && sessionId && !this.isValidSession(sessionId)) {
|
|
375
|
+
return { status: 404, headers: {}, body: encodeError(null, INVALID_REQUEST, "session not found") };
|
|
376
|
+
}
|
|
377
|
+
const body = await this.handleMessage(raw);
|
|
378
|
+
const headers: Record<string, string> = {};
|
|
379
|
+
if (isInit) headers["Mcp-Session-Id"] = this.openSession();
|
|
380
|
+
if (!body) return { status: 202, headers, body: "" };
|
|
381
|
+
return { status: 200, headers, body };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Legacy HTTP+SSE POST /message handler. When a live SSE stream is open for
|
|
386
|
+
* `sessionId`, run the message and push the response down that stream (202
|
|
387
|
+
* here); with no open stream it degrades to an inline Streamable HTTP
|
|
388
|
+
* response, so the same path serves a legacy SSE client and a plain POST.
|
|
389
|
+
*/
|
|
390
|
+
async dispatchSseMessage(
|
|
391
|
+
raw: string | Record<string, unknown>,
|
|
392
|
+
sessionId = "",
|
|
393
|
+
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
|
|
394
|
+
const channel = sessionId ? this._sseChannels.get(sessionId) : undefined;
|
|
395
|
+
if (!channel) return this.dispatchHttp(raw, sessionId);
|
|
396
|
+
const body = await this.handleMessage(raw);
|
|
397
|
+
if (body) {
|
|
398
|
+
channel.buffer.push(body);
|
|
399
|
+
if (channel.wake) {
|
|
400
|
+
const wake = channel.wake;
|
|
401
|
+
channel.wake = null;
|
|
402
|
+
wake();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { status: 202, headers: {}, body: "" };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Async generator of SSE frames for the legacy HTTP+SSE transport. Emits the
|
|
410
|
+
* `endpoint` event first (naming the POST target), then each queued JSON-RPC
|
|
411
|
+
* response as it arrives, with periodic keep-alive comments. Registers the
|
|
412
|
+
* per-session channel up front and tears it down (plus the session) when the
|
|
413
|
+
* client disconnects and the generator is closed.
|
|
414
|
+
*/
|
|
415
|
+
async *sseStream(sessionId: string, endpointUrl: string, keepaliveMs = 15000): AsyncGenerator<string> {
|
|
416
|
+
const channel: { buffer: string[]; wake: (() => void) | null } = { buffer: [], wake: null };
|
|
417
|
+
this._sseChannels.set(sessionId, channel);
|
|
418
|
+
try {
|
|
419
|
+
yield `event: endpoint\ndata: ${endpointUrl}\n\n`;
|
|
420
|
+
for (;;) {
|
|
421
|
+
if (channel.buffer.length > 0) {
|
|
422
|
+
yield `event: message\ndata: ${channel.buffer.shift()}\n\n`;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const gotMessage = await new Promise<boolean>((resolve) => {
|
|
426
|
+
const timer = setTimeout(() => {
|
|
427
|
+
channel.wake = null;
|
|
428
|
+
resolve(false);
|
|
429
|
+
}, keepaliveMs);
|
|
430
|
+
channel.wake = () => {
|
|
431
|
+
clearTimeout(timer);
|
|
432
|
+
resolve(true);
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
if (!gotMessage) yield `: keep-alive\n\n`;
|
|
436
|
+
}
|
|
437
|
+
} finally {
|
|
438
|
+
this._sseChannels.delete(sessionId);
|
|
439
|
+
this.closeSession(sessionId);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
305
443
|
registerTool(
|
|
306
444
|
name: string,
|
|
307
445
|
handler: (args: Record<string, unknown>) => unknown,
|
|
@@ -375,10 +513,11 @@ export class McpServer {
|
|
|
375
513
|
}
|
|
376
514
|
}
|
|
377
515
|
|
|
378
|
-
private _handleInitialize(
|
|
516
|
+
private _handleInitialize(params: Record<string, unknown>): Record<string, unknown> {
|
|
379
517
|
this._initialized = true;
|
|
518
|
+
const requested = params ? (params.protocolVersion as string | undefined) : undefined;
|
|
380
519
|
return {
|
|
381
|
-
protocolVersion:
|
|
520
|
+
protocolVersion: this.negotiateProtocolVersion(requested),
|
|
382
521
|
capabilities: {
|
|
383
522
|
tools: { listChanged: false },
|
|
384
523
|
resources: { subscribe: false, listChanged: false },
|
|
@@ -485,55 +624,64 @@ export class McpServer {
|
|
|
485
624
|
};
|
|
486
625
|
}
|
|
487
626
|
|
|
627
|
+
/** Coerce a route handler's parsed body into what the dispatchers accept. */
|
|
628
|
+
private _normalizeBody(body: unknown): string | Record<string, unknown> {
|
|
629
|
+
if (typeof body === "object" && body !== null) return body as Record<string, unknown>;
|
|
630
|
+
if (typeof body === "string") return body;
|
|
631
|
+
return String(body ?? "");
|
|
632
|
+
}
|
|
633
|
+
|
|
488
634
|
/**
|
|
489
|
-
* Register HTTP routes for this MCP server on the Tina4 router.
|
|
635
|
+
* Register HTTP routes for this MCP server on the Tina4 router. Mounts both
|
|
636
|
+
* supported transports on `path`:
|
|
637
|
+
* POST {path} — Streamable HTTP (current transport)
|
|
638
|
+
* POST {path}/message — legacy HTTP+SSE message sink (+ inline fallback)
|
|
639
|
+
* GET {path}/sse — legacy HTTP+SSE stream (persistent)
|
|
490
640
|
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
641
|
+
* A Streamable HTTP client (Claude Code `--transport http`) POSTs to `{path}`
|
|
642
|
+
* and reads the JSON-RPC response inline, with an Mcp-Session-Id header on
|
|
643
|
+
* initialize. A legacy SSE client GETs `{path}/sse`, gets the endpoint event,
|
|
644
|
+
* and its responses stream back on that connection.
|
|
494
645
|
*/
|
|
495
646
|
registerRoutes(router: {
|
|
496
647
|
post: (pattern: string, handler: (req: unknown, res: unknown) => unknown) => { noAuth: () => unknown };
|
|
497
648
|
get: (pattern: string, handler: (req: unknown, res: unknown) => unknown) => { noAuth: () => unknown };
|
|
498
649
|
}): void {
|
|
499
650
|
const server = this;
|
|
500
|
-
|
|
501
|
-
|
|
651
|
+
type Resp = ((data: unknown, status?: number, contentType?: string) => unknown) & {
|
|
652
|
+
addHeader?: (name: string, value: string) => unknown;
|
|
653
|
+
stream?: (source: AsyncIterable<string>, contentType?: string) => unknown;
|
|
654
|
+
};
|
|
655
|
+
const session = (req: { headers?: Record<string, string> }): string =>
|
|
656
|
+
(req.headers?.["mcp-session-id"] as string) || "";
|
|
657
|
+
const apply = (response: Resp, outcome: { status: number; headers: Record<string, string>; body: string }) => {
|
|
658
|
+
for (const [n, v] of Object.entries(outcome.headers)) response.addHeader?.(n, v);
|
|
659
|
+
if (!outcome.body) return response("", outcome.status);
|
|
660
|
+
return response(JSON.parse(outcome.body), outcome.status);
|
|
661
|
+
};
|
|
502
662
|
|
|
503
663
|
router
|
|
504
|
-
.post(
|
|
505
|
-
const request = req as { body: unknown;
|
|
506
|
-
|
|
507
|
-
const body = request.body;
|
|
508
|
-
let raw: string | Record<string, unknown>;
|
|
509
|
-
if (typeof body === "object" && body !== null) {
|
|
510
|
-
raw = body as Record<string, unknown>;
|
|
511
|
-
} else {
|
|
512
|
-
raw = typeof body === "string" ? body : String(body);
|
|
513
|
-
}
|
|
514
|
-
const result = await server.handleMessage(raw);
|
|
515
|
-
if (!result) {
|
|
516
|
-
return response("", 204);
|
|
517
|
-
}
|
|
518
|
-
return response(JSON.parse(result));
|
|
664
|
+
.post(this.path, async (req: unknown, res: unknown) => {
|
|
665
|
+
const request = req as { body: unknown; headers?: Record<string, string> };
|
|
666
|
+
return apply(res as Resp, await server.dispatchHttp(server._normalizeBody(request.body), session(request)));
|
|
519
667
|
})
|
|
520
668
|
.noAuth();
|
|
521
669
|
|
|
522
670
|
router
|
|
523
|
-
.
|
|
524
|
-
const request = req as {
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
response
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return response.
|
|
671
|
+
.post(`${this.path}/message`, async (req: unknown, res: unknown) => {
|
|
672
|
+
const request = req as { body: unknown; headers?: Record<string, string>; query?: Record<string, string> };
|
|
673
|
+
const sessionId = (request.query?.sessionId as string) || session(request);
|
|
674
|
+
return apply(res as Resp, await server.dispatchSseMessage(server._normalizeBody(request.body), sessionId));
|
|
675
|
+
})
|
|
676
|
+
.noAuth();
|
|
677
|
+
|
|
678
|
+
router
|
|
679
|
+
.get(`${this.path}/sse`, (req: unknown, res: unknown) => {
|
|
680
|
+
const request = req as { path?: string };
|
|
681
|
+
const response = res as Resp;
|
|
682
|
+
const sessionId = server.openSession();
|
|
683
|
+
const base = (request.path || `${server.path}/sse`).replace(/\/sse$/, "");
|
|
684
|
+
return response.stream!(server.sseStream(sessionId, `${base}/message?sessionId=${sessionId}`));
|
|
537
685
|
})
|
|
538
686
|
.noAuth();
|
|
539
687
|
}
|
|
@@ -563,7 +711,8 @@ export class McpServer {
|
|
|
563
711
|
|
|
564
712
|
const serverKey = this.name.toLowerCase().replace(/ /g, "-");
|
|
565
713
|
(config.mcpServers as Record<string, unknown>)[serverKey] = {
|
|
566
|
-
|
|
714
|
+
type: "http",
|
|
715
|
+
url: `http://localhost:${port}${this.path}`,
|
|
567
716
|
};
|
|
568
717
|
|
|
569
718
|
fs.writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
@@ -131,6 +131,10 @@ function compileString(
|
|
|
131
131
|
// 7. Evaluate basic math in property values
|
|
132
132
|
scss = evalMath(scss);
|
|
133
133
|
|
|
134
|
+
// 7.5. Resolve color functions (lighten/darken/rgba/rgb/mix) — after variable
|
|
135
|
+
// substitution so a $colour arg is already a hex literal (issue #124).
|
|
136
|
+
scss = resolveColorFunctions(scss);
|
|
137
|
+
|
|
134
138
|
// 8. Flatten nested rules
|
|
135
139
|
const css = flattenNesting(scss);
|
|
136
140
|
|
|
@@ -340,6 +344,99 @@ function evalMath(scss: string): string {
|
|
|
340
344
|
});
|
|
341
345
|
}
|
|
342
346
|
|
|
347
|
+
// ── Color Functions ──────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Resolve lighten(), darken(), rgba()/rgb(), and mix() color functions.
|
|
351
|
+
*
|
|
352
|
+
* rgba(<hex>, <alpha>) is the damaging case (issue #124): the functional rgba()
|
|
353
|
+
* notation cannot take a hex, so rgba(#0f3460, 0.12) is invalid CSS and browsers
|
|
354
|
+
* drop the whole declaration. Convert the hex to its r,g,b components. Runs after
|
|
355
|
+
* variable substitution so a $colour arg is already a hex literal.
|
|
356
|
+
*/
|
|
357
|
+
function resolveColorFunctions(scss: string): string {
|
|
358
|
+
scss = scss.replace(/lighten\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g, (_m, color: string, amt: string) =>
|
|
359
|
+
adjustLightness(color.trim(), parseFloat(amt.trim().replace(/%$/, "")) / 100)
|
|
360
|
+
);
|
|
361
|
+
scss = scss.replace(/darken\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g, (_m, color: string, amt: string) =>
|
|
362
|
+
adjustLightness(color.trim(), -(parseFloat(amt.trim().replace(/%$/, "")) / 100))
|
|
363
|
+
);
|
|
364
|
+
// rgba(<hex>, <alpha>) — only the two-arg hex form; leave rgba(r,g,b,a) alone.
|
|
365
|
+
scss = scss.replace(/rgba\(\s*(#[0-9a-fA-F]{3,8})\s*,\s*([\d.]+)\s*\)/g, (whole, hex: string, alpha: string) => {
|
|
366
|
+
const rgb = hexToRgb(hex);
|
|
367
|
+
return rgb === null ? whole : `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha.trim()})`;
|
|
368
|
+
});
|
|
369
|
+
scss = scss.replace(/rgb\(\s*(#[0-9a-fA-F]{3,8})\s*\)/g, (whole, hex: string) => {
|
|
370
|
+
const rgb = hexToRgb(hex);
|
|
371
|
+
return rgb === null ? whole : `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
|
372
|
+
});
|
|
373
|
+
// mix(<c1>, <c2>[, <weight>]) — Sass weight is c1's proportion (default 50%).
|
|
374
|
+
scss = scss.replace(
|
|
375
|
+
/mix\(\s*(#[0-9a-fA-F]{3,8})\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:,\s*([\d.]+%?)\s*)?\)/g,
|
|
376
|
+
(whole, h1: string, h2: string, weight?: string) => {
|
|
377
|
+
const c1 = hexToRgb(h1);
|
|
378
|
+
const c2 = hexToRgb(h2);
|
|
379
|
+
if (c1 === null || c2 === null) return whole;
|
|
380
|
+
const w = weight ? parseFloat(weight.replace(/%$/, "")) / 100 : 0.5;
|
|
381
|
+
const mixed = [0, 1, 2].map((i) => Math.round(c1[i] * w + c2[i] * (1 - w)));
|
|
382
|
+
return `#${mixed.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
return scss;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Parse a #rgb / #rrggbb hex string into an [r, g, b] tuple, or null. */
|
|
389
|
+
function hexToRgb(color: string): [number, number, number] | null {
|
|
390
|
+
let c = color.trim().replace(/^#/, "");
|
|
391
|
+
if (c.length === 3) {
|
|
392
|
+
c = c.split("").map((ch) => ch + ch).join("");
|
|
393
|
+
}
|
|
394
|
+
if (!/^[0-9a-fA-F]{6}$/.test(c)) return null;
|
|
395
|
+
return [parseInt(c.slice(0, 2), 16), parseInt(c.slice(2, 4), 16), parseInt(c.slice(4, 6), 16)];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Adjust the HSL lightness of a hex color by `amount` (-1..1), return hex.
|
|
399
|
+
* Truncates (Math.trunc) to match the Python master's int(x*255) byte-for-byte. */
|
|
400
|
+
function adjustLightness(color: string, amount: number): string {
|
|
401
|
+
const rgb = hexToRgb(color);
|
|
402
|
+
if (rgb === null) return color;
|
|
403
|
+
let [r, g, b] = rgb.map((v) => v / 255) as [number, number, number];
|
|
404
|
+
const max = Math.max(r, g, b);
|
|
405
|
+
const min = Math.min(r, g, b);
|
|
406
|
+
let l = (max + min) / 2;
|
|
407
|
+
const d = max - min;
|
|
408
|
+
let h = 0;
|
|
409
|
+
let s = 0;
|
|
410
|
+
if (d !== 0) {
|
|
411
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
412
|
+
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
|
413
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
414
|
+
else h = (r - g) / d + 4;
|
|
415
|
+
h /= 6;
|
|
416
|
+
}
|
|
417
|
+
l = Math.max(0, Math.min(1, l + amount));
|
|
418
|
+
if (s === 0) {
|
|
419
|
+
r = g = b = l;
|
|
420
|
+
} else {
|
|
421
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
422
|
+
const p = 2 * l - q;
|
|
423
|
+
r = hueToRgb(p, q, h + 1 / 3);
|
|
424
|
+
g = hueToRgb(p, q, h);
|
|
425
|
+
b = hueToRgb(p, q, h - 1 / 3);
|
|
426
|
+
}
|
|
427
|
+
const hex = (v: number): string => Math.trunc(v * 255).toString(16).padStart(2, "0");
|
|
428
|
+
return `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function hueToRgb(p: number, q: number, t: number): number {
|
|
432
|
+
if (t < 0) t += 1;
|
|
433
|
+
if (t > 1) t -= 1;
|
|
434
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
435
|
+
if (t < 1 / 2) return q;
|
|
436
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
437
|
+
return p;
|
|
438
|
+
}
|
|
439
|
+
|
|
343
440
|
// ── Nesting Flattener ────────────────────────────────────────────
|
|
344
441
|
|
|
345
442
|
function flattenNesting(scss: string): string {
|
|
@@ -867,6 +867,30 @@ ${reset}
|
|
|
867
867
|
try {
|
|
868
868
|
const { Frond } = await import("../../frond/src/engine.js");
|
|
869
869
|
frondEngine = new Frond(templatesDir);
|
|
870
|
+
|
|
871
|
+
// Always-on Frond {% live %} refresh endpoint. Re-renders a server-rendered
|
|
872
|
+
// live block on demand (poll / sse), re-running its provider with the live
|
|
873
|
+
// request so auth re-applies. Parity with Python/PHP/Ruby /__frond/live/{name}.
|
|
874
|
+
router.addRoute({
|
|
875
|
+
method: "GET",
|
|
876
|
+
pattern: "/__frond/live/{name}",
|
|
877
|
+
handler: (req: any, res: any) => {
|
|
878
|
+
const { status, body } = Frond.respondLive(req, String(req.params?.name ?? ""));
|
|
879
|
+
return res.html(body, status);
|
|
880
|
+
},
|
|
881
|
+
meta: {
|
|
882
|
+
summary: "Frond live block refresh",
|
|
883
|
+
description: "Re-render a server-rendered {% live %} block by name.",
|
|
884
|
+
tags: ["System"],
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// Wire the WebSocket broadcaster so Frond.pushLive can push live-block
|
|
889
|
+
// updates to connected clients (best-effort). Broadcasts to the block's
|
|
890
|
+
// declared data-ws path, else falls back to a path named after the block.
|
|
891
|
+
Frond.setLiveBroadcaster((wsPath: string | null, name: string, envelope: string) => {
|
|
892
|
+
wsRouteManager.broadcastPath(wsPath || name, envelope);
|
|
893
|
+
});
|
|
870
894
|
} catch {
|
|
871
895
|
// Frond not available
|
|
872
896
|
}
|
|
@@ -13,6 +13,21 @@ import { join, resolve } from "node:path";
|
|
|
13
13
|
export type FilterFn = (value: unknown, ...args: unknown[]) => unknown;
|
|
14
14
|
export type TestFn = (value: unknown) => boolean;
|
|
15
15
|
|
|
16
|
+
/** A minimal request shape a {% live %} data provider receives. */
|
|
17
|
+
export interface LiveRequest {
|
|
18
|
+
headers?: Record<string, unknown>;
|
|
19
|
+
params?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
/** A {% live %} data provider — re-runs with the live request each refresh. */
|
|
22
|
+
export type LiveProvider = (req: LiveRequest) => Record<string, unknown>;
|
|
23
|
+
/** Result of respondLive — a pure {status, body} descriptor a route applies. */
|
|
24
|
+
export interface LiveResponse {
|
|
25
|
+
status: number;
|
|
26
|
+
body: string;
|
|
27
|
+
}
|
|
28
|
+
/** WebSocket broadcaster hook wired by @tina4/core so pushLive can broadcast. */
|
|
29
|
+
export type LiveBroadcaster = (wsPath: string | null, name: string, envelope: string) => void;
|
|
30
|
+
|
|
16
31
|
/** Marker class for strings that should not be auto-escaped. */
|
|
17
32
|
class SafeString {
|
|
18
33
|
constructor(public value: string) {}
|
|
@@ -163,6 +178,21 @@ const FORMAT_RE = /%%|%([-+ 0]*)(\d+)?(?:\.(\d+))?([sdifFeEgGxXob])/g;
|
|
|
163
178
|
const LEADING_WS_RE = /^\s+/;
|
|
164
179
|
const TRAILING_WS_RE = /\s+$/;
|
|
165
180
|
const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
|
|
181
|
+
// {% live "name" poll N | sse | ws "path" [src "url"] %}
|
|
182
|
+
const LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
|
|
183
|
+
const LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
|
|
184
|
+
const LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
|
|
185
|
+
|
|
186
|
+
/** Escape a value for a live-marker HTML attribute. Byte-identical order to
|
|
187
|
+
* the Python master / PHP liveAttr / Ruby live_attr so the emitted marker
|
|
188
|
+
* element matches across all four frameworks (& " < > — no apostrophe). */
|
|
189
|
+
function liveAttr(value: unknown): string {
|
|
190
|
+
return String(value)
|
|
191
|
+
.replace(/&/g, "&")
|
|
192
|
+
.replace(/"/g, """)
|
|
193
|
+
.replace(/</g, "<")
|
|
194
|
+
.replace(/>/g, ">");
|
|
195
|
+
}
|
|
166
196
|
|
|
167
197
|
// ── Caches (module level) ─────────────────────────────────────
|
|
168
198
|
|
|
@@ -1374,6 +1404,24 @@ export class Frond {
|
|
|
1374
1404
|
private static classGlobals: Map<string, unknown> = new Map();
|
|
1375
1405
|
private static classTests: Map<string, TestFn> = new Map();
|
|
1376
1406
|
|
|
1407
|
+
// ── Live-block registries (server-rendered {% live %} regions) ──
|
|
1408
|
+
// A {% live %} block registers three things when its page first renders:
|
|
1409
|
+
// liveFragments[name] -> the raw body source, re-rendered on every
|
|
1410
|
+
// refresh by GET /__frond/live/<name> or pushLive
|
|
1411
|
+
// liveSources[name] -> an optional data provider (liveSource) that
|
|
1412
|
+
// re-runs with the LIVE request each refresh, so
|
|
1413
|
+
// auth re-applies (IDOR guard)
|
|
1414
|
+
// liveWsPaths[name] -> the ws path a `ws "path"` block declared, the
|
|
1415
|
+
// pushLive broadcast target
|
|
1416
|
+
// Static so they persist across requests in the long-lived server. Mirrors
|
|
1417
|
+
// the Python master's class-level dicts and PHP/Ruby static registries.
|
|
1418
|
+
private static liveFragments: Map<string, string> = new Map();
|
|
1419
|
+
private static liveSources: Map<string, LiveProvider> = new Map();
|
|
1420
|
+
private static liveWsPaths: Map<string, string> = new Map();
|
|
1421
|
+
// Best-effort WebSocket broadcaster wired by @tina4/core at boot (frond is a
|
|
1422
|
+
// zero-dep leaf package and cannot import core). pushLive calls it if set.
|
|
1423
|
+
private static liveBroadcaster: LiveBroadcaster | null = null;
|
|
1424
|
+
|
|
1377
1425
|
/**
|
|
1378
1426
|
* Register a custom filter at the class level — available to every
|
|
1379
1427
|
* future ``new Frond()`` instance. Callable as ``Frond.addFilter()``
|
|
@@ -1412,6 +1460,9 @@ export class Frond {
|
|
|
1412
1460
|
Frond.classFilters.clear();
|
|
1413
1461
|
Frond.classGlobals.clear();
|
|
1414
1462
|
Frond.classTests.clear();
|
|
1463
|
+
Frond.liveFragments.clear();
|
|
1464
|
+
Frond.liveSources.clear();
|
|
1465
|
+
Frond.liveWsPaths.clear();
|
|
1415
1466
|
}
|
|
1416
1467
|
|
|
1417
1468
|
private templateDir: string;
|
|
@@ -1850,6 +1901,10 @@ export class Frond {
|
|
|
1850
1901
|
const [result, skip] = this.handleCache(tokens, i, context);
|
|
1851
1902
|
output.push(result);
|
|
1852
1903
|
i = skip;
|
|
1904
|
+
} else if (tag === "live") {
|
|
1905
|
+
const [result, skip] = this.handleLive(tokens, i, context);
|
|
1906
|
+
output.push(result);
|
|
1907
|
+
i = skip;
|
|
1853
1908
|
} else if (tag === "spaceless") {
|
|
1854
1909
|
const [result, skip] = this.handleSpaceless(tokens, i, context);
|
|
1855
1910
|
output.push(result);
|
|
@@ -2502,6 +2557,175 @@ export class Frond {
|
|
|
2502
2557
|
return [rendered, i];
|
|
2503
2558
|
}
|
|
2504
2559
|
|
|
2560
|
+
/**
|
|
2561
|
+
* Handle {% live "name" poll N | sse | ws "path" [src "url"] %}...{% endlive %}.
|
|
2562
|
+
*
|
|
2563
|
+
* Server-rendered live region. The body renders once for first paint, is
|
|
2564
|
+
* registered under <name> so GET /__frond/live/<name> (or a liveSource
|
|
2565
|
+
* provider) can re-render it, and is wrapped in a marker element that
|
|
2566
|
+
* frond.js wires to the chosen transport (poll / sse / ws). Mirrors the
|
|
2567
|
+
* Python master's _handle_live and PHP/Ruby handleLive.
|
|
2568
|
+
*/
|
|
2569
|
+
private handleLive(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
|
|
2570
|
+
const [content] = stripTag(tokens[start][1]);
|
|
2571
|
+
const m = content.match(LIVE_RE);
|
|
2572
|
+
if (!m) {
|
|
2573
|
+
throw new Error('live: expected {% live "name" poll N | sse | ws "path" %}');
|
|
2574
|
+
}
|
|
2575
|
+
const name = m[1];
|
|
2576
|
+
const rest = (m[2] || "").trim();
|
|
2577
|
+
const parts = rest.split(/\s+/).filter(Boolean);
|
|
2578
|
+
const mode = parts[0] || "";
|
|
2579
|
+
|
|
2580
|
+
const sm = rest.match(LIVE_SRC_RE);
|
|
2581
|
+
const src = sm ? sm[1] : null;
|
|
2582
|
+
if (src && (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("//"))) {
|
|
2583
|
+
throw new Error("live: src must be a same-origin path, not an absolute URL");
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
let interval: number | null = null;
|
|
2587
|
+
let wsPath: string | null = null;
|
|
2588
|
+
if (mode === "poll") {
|
|
2589
|
+
if (!parts[1] || !/^\d+$/.test(parts[1])) {
|
|
2590
|
+
throw new Error('live: poll requires seconds, e.g. {% live "x" poll 5 %}');
|
|
2591
|
+
}
|
|
2592
|
+
interval = parseInt(parts[1], 10);
|
|
2593
|
+
} else if (mode === "sse") {
|
|
2594
|
+
// no extra config
|
|
2595
|
+
} else if (mode === "ws") {
|
|
2596
|
+
const wm = rest.match(LIVE_WS_RE);
|
|
2597
|
+
if (!wm) {
|
|
2598
|
+
throw new Error('live: ws requires a path, e.g. {% live "x" ws "/ws/x" %}');
|
|
2599
|
+
}
|
|
2600
|
+
wsPath = wm[1];
|
|
2601
|
+
} else {
|
|
2602
|
+
throw new Error(`live: unknown transport "${mode}" (use poll N, sse, or ws "path")`);
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
// Collect body tokens up to {% endlive %}. Nested live is unsupported.
|
|
2606
|
+
const bodyTokens: Token[] = [];
|
|
2607
|
+
let i = start + 1;
|
|
2608
|
+
while (i < tokens.length) {
|
|
2609
|
+
if (tokens[i][0] === "BLOCK") {
|
|
2610
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
2611
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
2612
|
+
if (tag === "live") throw new Error("live: nested live blocks are not supported");
|
|
2613
|
+
if (tag === "endlive") {
|
|
2614
|
+
i++;
|
|
2615
|
+
break;
|
|
2616
|
+
}
|
|
2617
|
+
bodyTokens.push(tokens[i]);
|
|
2618
|
+
} else {
|
|
2619
|
+
bodyTokens.push(tokens[i]);
|
|
2620
|
+
}
|
|
2621
|
+
i++;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// Register the raw body source so the auto endpoint can re-render it.
|
|
2625
|
+
Frond.liveFragments.set(name, bodyTokens.map((t) => t[1]).join(""));
|
|
2626
|
+
|
|
2627
|
+
const endpoint = src || `/__frond/live/${name}`;
|
|
2628
|
+
const attrs = [`data-frond-live="${liveAttr(name)}"`, `id="live-${liveAttr(name)}"`];
|
|
2629
|
+
if (mode === "poll") {
|
|
2630
|
+
attrs.push('data-mode="poll"', `data-interval="${interval}"`, `data-src="${liveAttr(endpoint)}"`);
|
|
2631
|
+
} else if (mode === "sse") {
|
|
2632
|
+
attrs.push('data-mode="sse"', `data-src="${liveAttr(endpoint)}"`);
|
|
2633
|
+
} else if (mode === "ws") {
|
|
2634
|
+
Frond.liveWsPaths.set(name, wsPath as string);
|
|
2635
|
+
attrs.push('data-mode="ws"', `data-ws="${liveAttr(wsPath)}"`);
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
const firstPaint = this.renderTokens([...bodyTokens], context);
|
|
2639
|
+
return [`<div ${attrs.join(" ")}>${firstPaint}</div>`, i];
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// ── Live-block class API (mirrors Python master + PHP/Ruby facades) ──
|
|
2643
|
+
|
|
2644
|
+
/**
|
|
2645
|
+
* Re-render a registered {% live %} fragment by name with fresh data.
|
|
2646
|
+
* Returns the rendered HTML, or null if no fragment is registered under that
|
|
2647
|
+
* name yet (its page has not rendered). GET /__frond/live/<name> calls this
|
|
2648
|
+
* after resolving the provider data.
|
|
2649
|
+
*/
|
|
2650
|
+
static renderLive(name: string, data?: Record<string, unknown>): string | null {
|
|
2651
|
+
const source = Frond.liveFragments.get(name);
|
|
2652
|
+
if (source === undefined) return null;
|
|
2653
|
+
return new Frond().renderString(source, data || {});
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
/** Register a data provider for a {% live %} block. Invoked with the live
|
|
2657
|
+
* request on every refresh so auth re-applies. Mirrors Python's @live_source. */
|
|
2658
|
+
static liveSource(name: string, fn: LiveProvider): void {
|
|
2659
|
+
Frond.liveSources.set(name, fn);
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
/** The provider registered for a live block, or null. */
|
|
2663
|
+
static getLiveSource(name: string): LiveProvider | null {
|
|
2664
|
+
return Frond.liveSources.get(name) ?? null;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
/** Whether a live fragment has been registered (its page rendered). */
|
|
2668
|
+
static hasLiveFragment(name: string): boolean {
|
|
2669
|
+
return Frond.liveFragments.has(name);
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
/** The ws path a live block declared (data-ws), or null. */
|
|
2673
|
+
static getLiveWsPath(name: string): string | null {
|
|
2674
|
+
return Frond.liveWsPaths.get(name) ?? null;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
/**
|
|
2678
|
+
* Resolve GET /__frond/live/{name}: run the provider with the live request
|
|
2679
|
+
* (auth re-applies), re-render the fragment, and return a pure {status, body}
|
|
2680
|
+
* descriptor the route handler applies to the response. 404 for an unknown
|
|
2681
|
+
* name / unrendered fragment. Mirrors Python's live_endpoint / PHP respondLive.
|
|
2682
|
+
*/
|
|
2683
|
+
static respondLive(req: LiveRequest, name: string): LiveResponse {
|
|
2684
|
+
const provider = Frond.liveSources.get(name);
|
|
2685
|
+
if (!Frond.liveFragments.has(name) && provider === undefined) {
|
|
2686
|
+
return { status: 404, body: `live block not found: ${name}` };
|
|
2687
|
+
}
|
|
2688
|
+
let context: Record<string, unknown> = {};
|
|
2689
|
+
if (provider !== undefined) {
|
|
2690
|
+
const result = provider(req);
|
|
2691
|
+
context = result && typeof result === "object" ? result : {};
|
|
2692
|
+
}
|
|
2693
|
+
const html = Frond.renderLive(name, context);
|
|
2694
|
+
if (html === null) {
|
|
2695
|
+
return { status: 404, body: `live fragment not registered yet: ${name}` };
|
|
2696
|
+
}
|
|
2697
|
+
return { status: 200, body: html };
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
/** Wire the WebSocket broadcaster used by pushLive. Called once by @tina4/core
|
|
2701
|
+
* at server boot (frond is a zero-dep leaf and cannot import core). */
|
|
2702
|
+
static setLiveBroadcaster(fn: LiveBroadcaster | null): void {
|
|
2703
|
+
Frond.liveBroadcaster = fn;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* Re-render the '<name>' live fragment and push it to connected clients.
|
|
2708
|
+
* Broadcasts a {type,name,html} envelope over WebSocket to the block's
|
|
2709
|
+
* declared data-ws path (else a room named <name>). Returns the rendered
|
|
2710
|
+
* HTML, or null if the fragment is not registered. Mirrors Python push_live
|
|
2711
|
+
* / PHP pushLive. The broadcast is best-effort — a missing/failed broadcaster
|
|
2712
|
+
* never throws into the caller.
|
|
2713
|
+
*/
|
|
2714
|
+
static pushLive(name: string, data?: Record<string, unknown>): string | null {
|
|
2715
|
+
const html = Frond.renderLive(name, data);
|
|
2716
|
+
if (html === null) return null;
|
|
2717
|
+
|
|
2718
|
+
if (Frond.liveBroadcaster) {
|
|
2719
|
+
try {
|
|
2720
|
+
const envelope = JSON.stringify({ type: "live", name, html });
|
|
2721
|
+
Frond.liveBroadcaster(Frond.getLiveWsPath(name), name, envelope);
|
|
2722
|
+
} catch {
|
|
2723
|
+
// best-effort — never let a broadcast failure escape pushLive
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
return html;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2505
2729
|
private handleSpaceless(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
|
|
2506
2730
|
const bodyTokens: Token[] = [];
|
|
2507
2731
|
let i = start + 1;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { Frond } from "./engine.js";
|
|
2
|
-
export type { FilterFn, TestFn } from "./engine.js";
|
|
2
|
+
export type { FilterFn, TestFn, LiveProvider, LiveRequest, LiveResponse, LiveBroadcaster } from "./engine.js";
|
|
@@ -393,9 +393,10 @@ export function parseDatabaseUrl(url: string, username?: string, password?: stri
|
|
|
393
393
|
database,
|
|
394
394
|
};
|
|
395
395
|
} else {
|
|
396
|
-
// Normalize postgres://
|
|
397
|
-
|
|
398
|
-
|
|
396
|
+
// Normalize postgres:// and pgsql:// (the PDO/Laravel/Doctrine scheme
|
|
397
|
+
// name, issue #58) to postgresql:// for URL parsing.
|
|
398
|
+
const normalizedUrl = /^(postgres|pgsql):\/\//.test(url)
|
|
399
|
+
? url.replace(/^(postgres|pgsql):\/\//, "postgresql://")
|
|
399
400
|
: url;
|
|
400
401
|
|
|
401
402
|
let parsed: URL;
|