tina4-nodejs 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
|
|
6
6
|
"keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";var Tina4=(()=>{var H=Object.defineProperty;var pe=Object.getOwnPropertyDescriptor;var ge=Object.getOwnPropertyNames;var he=Object.prototype.hasOwnProperty;var me=(e,n)=>{for(var t in n)H(e,t,{get:n[t],enumerable:!0})},ye=(e,n,t,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of ge(n))!he.call(e,r)&&r!==t&&H(e,r,{get:()=>n[r],enumerable:!(o=pe(n,r))||o.enumerable});return e};var ve=e=>ye(H({},"__esModule",{value:!0}),e);var Ae={};me(Ae,{Tina4Element:()=>I,api:()=>ie,batch:()=>q,computed:()=>z,effect:()=>m,html:()=>X,isSignal:()=>w,navigate:()=>L,pwa:()=>le,route:()=>oe,router:()=>re,signal:()=>h,ws:()=>ue});var C=null,_=null,M=null;function b(e){M=e}function J(){return M}var B=null,V=null;var N=0,P=new Set;function h(e,n){let t=e,o=new Set,r={_t4:!0,get value(){if(C&&(o.add(C),_)){let a=C;_.push(()=>o.delete(a))}return t},set value(a){if(Object.is(a,t))return;let i=t;if(t=a,r._debugInfo&&r._debugInfo.updateCount++,V&&V(r,i,a),N>0)for(let s of o)P.add(s);else{let s;for(let c of[...o])try{c()}catch(l){s===void 0&&(s=l)}if(s!==void 0)throw s}},_subscribe(a){return o.add(a),()=>{o.delete(a)}},peek(){return t}};return B&&(r._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:o},B(r,n)),r}function z(e){let n=h(void 0);return m(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function m(e){let n=!1,t=[],o=()=>{if(n)return;for(let s of t)s();t=[];let a=C,i=_;C=o,_=t;try{e()}finally{C=a,_=i}};o();let r=()=>{n=!0;for(let a of t)a();t=[]};return M&&M.push(r),r}function q(e){N++;try{e()}finally{if(N--,N===0){let n=[...P];P.clear();let t;for(let o of n)try{o()}catch(r){t===void 0&&(t=r)}if(t!==void 0)throw t}}}function w(e){return e!==null&&typeof e=="object"&&e._t4===!0}var Q=new WeakMap,D="t4:";function X(e,...n){let t=Q.get(e);if(!t){t=document.createElement("template");let i="";for(let s=0;s<e.length;s++)i+=e[s],s<n.length&&(Te(i)?i+=`__t4_${s}__`:i+=`<!--${D}${s}-->`);t.innerHTML=i,Q.set(e,t)}let o=t.content.cloneNode(!0),r=be(o);for(let{marker:i,index:s}of r)ke(i,n[s]);let a=we(o);for(let i of a)Ce(i,n);return o}function be(e){let n=[];return W(e,t=>{if(t.nodeType===8){let o=t.data;if(o&&o.startsWith(D)){let r=parseInt(o.slice(D.length),10);n.push({marker:t,index:r})}}}),n}function we(e){let n=[];return W(e,t=>{t.nodeType===1&&n.push(t)}),n}function W(e,n){let t=e.childNodes;for(let o=0;o<t.length;o++){let r=t[o];n(r),W(r,n)}}function ke(e,n){let t=e.parentNode;if(t)if(w(n)){let o=document.createTextNode("");t.replaceChild(o,e),m(()=>{o.data=String(n.value??"")})}else if(typeof n=="function"){let o=document.createComment("");t.replaceChild(o,e);let r=[],a=[];m(()=>{for(let d of a)d();a=[];let i=[],s=J();b(i);let c=n();b(s),a=i;for(let d of r)d.parentNode?.removeChild(d);r=[];let l=F(c),f=o.parentNode;if(f)for(let d of l)f.insertBefore(d,o),r.push(d)})}else if(Y(n))t.replaceChild(n,e);else if(n instanceof Node)t.replaceChild(n,e);else if(Array.isArray(n)){let o=document.createDocumentFragment();for(let r of n){let a=F(r);for(let i of a)o.appendChild(i)}t.replaceChild(o,e)}else{let o=document.createTextNode(String(n??""));t.replaceChild(o,e)}}function Ce(e,n){let t=[];for(let o of Array.from(e.attributes)){let r=o.name,a=o.value;if(r.startsWith("@")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];typeof l=="function"&&e.addEventListener(s,f=>q(()=>l(f)))}t.push(r);continue}if(r.startsWith("?")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];if(w(l)){let f=l;m(()=>{f.value?e.setAttribute(s,""):e.removeAttribute(s)})}else typeof l=="function"?m(()=>{l()?e.setAttribute(s,""):e.removeAttribute(s)}):l&&e.setAttribute(s,"")}t.push(r);continue}if(r.startsWith(".")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];w(l)?m(()=>{e[s]=l.value}):e[s]=l}t.push(r);continue}let i=a.match(/__t4_(\d+)__/);if(i){let s=n[parseInt(i[1],10)];if(w(s)){let c=s;m(()=>{e.setAttribute(r,String(c.value??""))})}else typeof s=="function"?m(()=>{e.setAttribute(r,String(s()??""))}):e.setAttribute(r,String(s??""))}}for(let o of t)e.removeAttribute(o)}function F(e){if(e==null||e===!1)return[];if(Y(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...F(t));return n}return[document.createTextNode(String(e))]}function Y(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function Te(e){let n=!1,t=!1,o=!1;for(let r=0;r<e.length;r++){let a=e[r];a==="<"&&!n&&!t&&(o=!0),a===">"&&!n&&!t&&(o=!1),o&&(a==='"'&&!n&&(t=!t),a==="'"&&!t&&(n=!n))}return o}var Z=null,ee=null;var I=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[o,r]of Object.entries(t.props))this._props[o]=h(this._coerce(this.getAttribute(o),r))}static{this.props={}}static{this.styles=""}static{this.shadow=!0}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let r=document.createElement("style");r.textContent=t.styles,this._root.appendChild(r)}let o=this.render();o&&this._root.appendChild(o),this.onMount(),Z&&Z(this)}disconnectedCallback(){this.onUnmount(),ee&&ee(this)}attributeChangedCallback(t,o,r){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(r,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,o){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...o}))}onMount(){}onUnmount(){}_coerce(t,o){return o===Boolean?t!==null:o===Number?t!==null?Number(t):0:t??""}};var U=[],T=null,S="history",_e=!1,E=[],O=[],te=0;function oe(e,n){let t=[],o;e==="*"?o=".*":o=e.replace(/\{(\w+)\}/g,(a,i)=>(t.push(i),"([^/]+)"));let r=new RegExp(`^${o}$`);typeof n=="function"?U.push({pattern:e,regex:r,paramNames:t,handler:n}):U.push({pattern:e,regex:r,paramNames:t,handler:n.handler,guard:n.guard})}function L(e,n){if(S==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),R()}else location.hash="#"+e;else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),R()}function R(){if(!T)return;let e=performance.now(),n=++te,t=S==="hash"?location.hash.slice(1)||"/":location.pathname;for(let o of U){let r=t.match(o.regex);if(!r)continue;let a={};if(o.paramNames.forEach((c,l)=>{a[c]=decodeURIComponent(r[l+1])}),o.guard){let c=o.guard();if(c===!1)return;if(typeof c=="string"){L(c,{replace:!0});return}}for(let c of O)c();O=[],T.innerHTML="";let i=[];b(i);let s=o.handler(a);if(s instanceof Promise)s.then(c=>{if(b(null),n!==te){for(let f of i)f();return}ne(T,c),O=i;let l=performance.now()-e;for(let f of E)f({path:t,params:a,pattern:o.pattern,durationMs:l})});else{b(null),ne(T,s),O=i;let c=performance.now()-e;for(let l of E)l({path:t,params:a,pattern:o.pattern,durationMs:c})}return}}function ne(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var re={start(e){if(T=document.querySelector(e.target),!T)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);S=e.mode??"history",_e=!0,window.addEventListener("popstate",R),S==="hash"&&window.addEventListener("hashchange",R),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let o=S==="hash"?t.getAttribute("href"):t.pathname;L(o)}),R()},on(e,n){return E.push(n),()=>{let t=E.indexOf(n);t>=0&&E.splice(t,1)}}};var y={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},j=[],K=[],Se=0;function se(){try{return localStorage.getItem(y.tokenKey)}catch{return null}}function Ee(e){try{localStorage.setItem(y.tokenKey,e)}catch{}}async function x(e,n,t,o){let r={method:e,headers:{"Content-Type":"application/json",...y.headers}};if(y.auth){let d=se();d&&(r.headers.Authorization=`Bearer ${d}`)}if(t!==void 0&&e!=="GET"){let d=typeof t=="object"&&t!==null?{...t}:t;if(y.auth&&typeof d=="object"&&d!==null){let p=se();p&&(d.formToken=p)}r.body=JSON.stringify(d)}if(o?.headers&&Object.assign(r.headers,o.headers),o?.params){let d=Object.entries(o.params).map(([p,v])=>`${encodeURIComponent(p)}=${encodeURIComponent(String(v))}`).join("&");n+=(n.includes("?")?"&":"?")+d}let a=y.baseUrl+n;r._url=a,r._requestId=++Se;for(let d of j){let p=d(r);p&&(r=p)}let i=await fetch(a,r),s=i.headers.get("FreshToken");s&&Ee(s);let c=i.headers.get("Content-Type")??"",l;c.includes("json")?l=await i.json():l=await i.text();let f={status:i.status,data:l,ok:i.ok,headers:i.headers,_requestId:r._requestId};for(let d of K){let p=d(f);p&&(f=p)}if(!i.ok)throw f;return f.data}var ie={configure(e){Object.assign(y,e)},get(e,n){return x("GET",e,void 0,n)},post(e,n,t){return x("POST",e,n,t)},put(e,n,t){return x("PUT",e,n,t)},patch(e,n,t){return x("PATCH",e,n,t)},delete(e,n){return x("DELETE",e,void 0,n)},intercept(e,n){e==="request"?j.push(n):K.push(n)},_reset(){y.baseUrl="",y.auth=!1,y.tokenKey="tina4_token",y.headers={},j.length=0,K.length=0}};function ae(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),o=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
|
|
2
|
+
const CACHE = 'tina4-v1';
|
|
3
|
+
const PRECACHE = ${t};
|
|
4
|
+
const OFFLINE = ${o};
|
|
5
|
+
|
|
6
|
+
self.addEventListener('install', (e) => {
|
|
7
|
+
e.waitUntil(
|
|
8
|
+
caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting())
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
self.addEventListener('activate', (e) => {
|
|
13
|
+
e.waitUntil(self.clients.claim());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
self.addEventListener('fetch', (e) => {
|
|
17
|
+
const req = e.request;
|
|
18
|
+
if (req.method !== 'GET') return;
|
|
19
|
+
|
|
20
|
+
${n==="cache-first"?`
|
|
21
|
+
e.respondWith(
|
|
22
|
+
caches.match(req).then((cached) => cached || fetch(req).then((res) => {
|
|
23
|
+
const clone = res.clone();
|
|
24
|
+
caches.open(CACHE).then((c) => c.put(req, clone));
|
|
25
|
+
return res;
|
|
26
|
+
})).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
|
|
27
|
+
);`:n==="stale-while-revalidate"?`
|
|
28
|
+
e.respondWith(
|
|
29
|
+
caches.match(req).then((cached) => {
|
|
30
|
+
const fetched = fetch(req).then((res) => {
|
|
31
|
+
caches.open(CACHE).then((c) => c.put(req, res.clone()));
|
|
32
|
+
return res;
|
|
33
|
+
});
|
|
34
|
+
return cached || fetched;
|
|
35
|
+
}).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
|
|
36
|
+
);`:`
|
|
37
|
+
e.respondWith(
|
|
38
|
+
fetch(req).then((res) => {
|
|
39
|
+
const clone = res.clone();
|
|
40
|
+
caches.open(CACHE).then((c) => c.put(req, clone));
|
|
41
|
+
return res;
|
|
42
|
+
}).catch(() => caches.match(req).then((cached) =>
|
|
43
|
+
cached || (OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
|
|
44
|
+
))
|
|
45
|
+
);`}
|
|
46
|
+
});
|
|
47
|
+
`.trim()}function ce(e){let n={name:e.name,short_name:e.shortName??e.name,start_url:"/",display:e.display??"standalone",background_color:e.backgroundColor??"#ffffff",theme_color:e.themeColor??"#000000"};return e.icon&&(n.icons=[{src:e.icon,sizes:"192x192",type:"image/png"},{src:e.icon,sizes:"512x512",type:"image/png"}]),n}var le={register(e){let n=ce(e),t=new Blob([JSON.stringify(n)],{type:"application/json"}),o=document.createElement("link");o.rel="manifest",o.href=URL.createObjectURL(t),document.head.appendChild(o);let r=document.querySelector('meta[name="theme-color"]');if(r||(r=document.createElement("meta"),r.name="theme-color",document.head.appendChild(r)),r.content=e.themeColor??"#000000","serviceWorker"in navigator){let a=ae(e),i=new Blob([a],{type:"text/javascript"}),s=URL.createObjectURL(i);navigator.serviceWorker.register(s).catch(c=>{console.warn("[tina4] Service worker registration failed:",c)})}},generateServiceWorker(e){return ae(e)},generateManifest(e){return ce(e)}};var Re={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[]};function xe(e,n={}){let t={...Re,...n},o=h("connecting"),r=h(!1),a=h(null),i=h(null),s=h(0),c={message:[],open:[],close:[],error:[]},l=null,f=!1,d=t.reconnectDelay,p=null,v=0;function de(u){if(typeof u!="string")return u;try{return JSON.parse(u)}catch{return u}}function $(){o.value=v>0?"reconnecting":"connecting";try{l=new WebSocket(e,t.protocols)}catch{o.value="closed",r.value=!1;return}l.onopen=()=>{o.value="open",r.value=!0,i.value=null,v=0,d=t.reconnectDelay,s.value=0;for(let u of c.open)u()},l.onmessage=u=>{let g=de(u.data);a.value=g;for(let k of c.message)k(g)},l.onclose=u=>{o.value="closed",r.value=!1;for(let g of c.close)g(u.code,u.reason);!f&&t.reconnect&&v<t.reconnectAttempts&&fe()},l.onerror=u=>{i.value=u;for(let g of c.error)g(u)}}function fe(){v++,s.value=v,o.value="reconnecting",p=setTimeout(()=>{p=null,$()},d),d=Math.min(d*2,t.reconnectMaxDelay)}let G={status:o,connected:r,lastMessage:a,error:i,reconnectCount:s,send(u){if(!l||l.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let g=typeof u=="string"?u:JSON.stringify(u);l.send(g)},on(u,g){return c[u].push(g),()=>{let k=c[u],A=k.indexOf(g);A>=0&&k.splice(A,1)}},pipe(u,g){let k=A=>{u.value=g(A,u.value)};return G.on("message",k)},close(u,g){f=!0,p&&(clearTimeout(p),p=null),l&&l.close(u??1e3,g??""),o.value="closed",r.value=!1}};return $(),G}var ue={connect:xe};return ve(Ae);})();
|
|
@@ -17,7 +17,7 @@ export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from
|
|
|
17
17
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
18
18
|
export type { RouteInfo } from "./router.js";
|
|
19
19
|
export { discoverRoutes } from "./routeDiscovery.js";
|
|
20
|
-
export { MiddlewareChain, cors, requestLogger } from "./middleware.js";
|
|
20
|
+
export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger } from "./middleware.js";
|
|
21
21
|
export type { CorsConfig } from "./middleware.js";
|
|
22
22
|
export { createRequest, parseBody } from "./request.js";
|
|
23
23
|
export { createResponse } from "./response.js";
|
|
@@ -36,6 +36,76 @@ export class MiddlewareChain {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// ── Class-based middleware runner ────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runs class-based middleware that follows the beforeX / afterX naming convention.
|
|
43
|
+
*
|
|
44
|
+
* Static methods whose names start with "before" are executed by runBefore
|
|
45
|
+
* (prior to the route handler). Static methods starting with "after" are
|
|
46
|
+
* executed by runAfter (after the handler).
|
|
47
|
+
*
|
|
48
|
+
* Each static method receives (req, res) and returns [req, res].
|
|
49
|
+
* If a "before" method returns a response whose status code is >= 400
|
|
50
|
+
* the chain short-circuits and runBefore returns shouldContinue = false.
|
|
51
|
+
*/
|
|
52
|
+
export class MiddlewareRunner {
|
|
53
|
+
/**
|
|
54
|
+
* Execute every beforeX static method found on the supplied classes,
|
|
55
|
+
* in order. Returns the (possibly mutated) request and response pair and a
|
|
56
|
+
* boolean indicating whether the route handler should still run.
|
|
57
|
+
*
|
|
58
|
+
* Short-circuits when a before method sets a status >= 400.
|
|
59
|
+
*/
|
|
60
|
+
static runBefore(
|
|
61
|
+
classes: any[],
|
|
62
|
+
req: Tina4Request,
|
|
63
|
+
res: Tina4Response,
|
|
64
|
+
): [Tina4Request, Tina4Response, boolean] {
|
|
65
|
+
for (const cls of classes) {
|
|
66
|
+
const methods = Object.getOwnPropertyNames(cls).filter(
|
|
67
|
+
(name) => typeof cls[name] === "function" && name.startsWith("before"),
|
|
68
|
+
);
|
|
69
|
+
for (const method of methods) {
|
|
70
|
+
const result = cls[method](req, res);
|
|
71
|
+
if (Array.isArray(result)) {
|
|
72
|
+
[req, res] = result as [Tina4Request, Tina4Response];
|
|
73
|
+
}
|
|
74
|
+
// Short-circuit if the middleware set an error status
|
|
75
|
+
if (res.raw.statusCode >= 400 || res.raw.writableEnded) {
|
|
76
|
+
return [req, res, false];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [req, res, true];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Execute every afterX static method found on the supplied classes,
|
|
85
|
+
* in order. Returns the (possibly mutated) request and response pair.
|
|
86
|
+
*/
|
|
87
|
+
static runAfter(
|
|
88
|
+
classes: any[],
|
|
89
|
+
req: Tina4Request,
|
|
90
|
+
res: Tina4Response,
|
|
91
|
+
): [Tina4Request, Tina4Response] {
|
|
92
|
+
for (const cls of classes) {
|
|
93
|
+
const methods = Object.getOwnPropertyNames(cls).filter(
|
|
94
|
+
(name) => typeof cls[name] === "function" && name.startsWith("after"),
|
|
95
|
+
);
|
|
96
|
+
for (const method of methods) {
|
|
97
|
+
const result = cls[method](req, res);
|
|
98
|
+
if (Array.isArray(result)) {
|
|
99
|
+
[req, res] = result as [Tina4Request, Tina4Response];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return [req, res];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Built-in class-based middleware ─────────────────────────────
|
|
108
|
+
|
|
39
109
|
/** Configuration for the CORS middleware */
|
|
40
110
|
export interface CorsConfig {
|
|
41
111
|
/** Allowed origins. Default: "*" (or TINA4_CORS_ORIGINS env, comma-separated) */
|
|
@@ -49,7 +119,7 @@ export interface CorsConfig {
|
|
|
49
119
|
}
|
|
50
120
|
|
|
51
121
|
/**
|
|
52
|
-
* Built-in CORS middleware.
|
|
122
|
+
* Built-in CORS middleware (function form).
|
|
53
123
|
* Reads configuration from env vars if not provided:
|
|
54
124
|
* TINA4_CORS_ORIGINS — comma-separated list of allowed origins, or "*"
|
|
55
125
|
* TINA4_CORS_METHODS — comma-separated list of allowed methods
|
|
@@ -119,7 +189,167 @@ export function cors(config?: CorsConfig): Middleware {
|
|
|
119
189
|
};
|
|
120
190
|
}
|
|
121
191
|
|
|
122
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Class-based CORS middleware using the before/after convention.
|
|
194
|
+
* Wraps the same CORS logic as the `cors()` function middleware.
|
|
195
|
+
*
|
|
196
|
+
* Usage:
|
|
197
|
+
* Router.use(CorsMiddleware);
|
|
198
|
+
*/
|
|
199
|
+
export class CorsMiddleware {
|
|
200
|
+
static beforeCors(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
201
|
+
const originsRaw = process.env.TINA4_CORS_ORIGINS ?? "*";
|
|
202
|
+
const allowedOrigins = originsRaw.split(",").map((o) => o.trim());
|
|
203
|
+
|
|
204
|
+
const allowedMethods = process.env.TINA4_CORS_METHODS
|
|
205
|
+
?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
206
|
+
|
|
207
|
+
const allowedHeaders = process.env.TINA4_CORS_HEADERS
|
|
208
|
+
?? "Content-Type, Authorization";
|
|
209
|
+
|
|
210
|
+
const maxAge = process.env.TINA4_CORS_MAX_AGE
|
|
211
|
+
? parseInt(process.env.TINA4_CORS_MAX_AGE, 10)
|
|
212
|
+
: 86400;
|
|
213
|
+
|
|
214
|
+
const requestOrigin = req.headers.origin ?? "";
|
|
215
|
+
|
|
216
|
+
let originHeader: string | undefined;
|
|
217
|
+
if (allowedOrigins.includes("*")) {
|
|
218
|
+
originHeader = "*";
|
|
219
|
+
} else if (allowedOrigins.includes(requestOrigin)) {
|
|
220
|
+
originHeader = requestOrigin;
|
|
221
|
+
res.header("Vary", "Origin");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (originHeader) {
|
|
225
|
+
res.header("Access-Control-Allow-Origin", originHeader);
|
|
226
|
+
res.header("Access-Control-Allow-Methods", allowedMethods);
|
|
227
|
+
res.header("Access-Control-Allow-Headers", allowedHeaders);
|
|
228
|
+
|
|
229
|
+
if (req.method === "OPTIONS") {
|
|
230
|
+
res.header("Access-Control-Max-Age", String(maxAge));
|
|
231
|
+
res(null, 204);
|
|
232
|
+
}
|
|
233
|
+
} else if (req.method === "OPTIONS") {
|
|
234
|
+
res(null, 204);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return [req, res];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Class-based rate limiter middleware using the before/after convention.
|
|
243
|
+
* Uses the same sliding-window algorithm as the `rateLimiter()` function.
|
|
244
|
+
*
|
|
245
|
+
* Reads configuration from env vars:
|
|
246
|
+
* TINA4_RATE_LIMIT — max requests per window (default 100)
|
|
247
|
+
* TINA4_RATE_WINDOW — window duration in seconds (default 60)
|
|
248
|
+
*
|
|
249
|
+
* Usage:
|
|
250
|
+
* Router.use(RateLimiterMiddleware);
|
|
251
|
+
*/
|
|
252
|
+
export class RateLimiterMiddleware {
|
|
253
|
+
private static store = new Map<string, { timestamps: number[] }>();
|
|
254
|
+
private static cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
255
|
+
|
|
256
|
+
private static ensureCleanup(windowMs: number): void {
|
|
257
|
+
if (RateLimiterMiddleware.cleanupTimer) return;
|
|
258
|
+
RateLimiterMiddleware.cleanupTimer = setInterval(() => {
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
const cutoff = now - windowMs;
|
|
261
|
+
for (const [ip, entry] of RateLimiterMiddleware.store) {
|
|
262
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
263
|
+
if (entry.timestamps.length === 0) {
|
|
264
|
+
RateLimiterMiddleware.store.delete(ip);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}, 60_000);
|
|
268
|
+
if (RateLimiterMiddleware.cleanupTimer.unref) {
|
|
269
|
+
RateLimiterMiddleware.cleanupTimer.unref();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
static beforeRateLimit(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
274
|
+
const limit = process.env.TINA4_RATE_LIMIT
|
|
275
|
+
? parseInt(process.env.TINA4_RATE_LIMIT, 10)
|
|
276
|
+
: 100;
|
|
277
|
+
const windowSeconds = process.env.TINA4_RATE_WINDOW
|
|
278
|
+
? parseInt(process.env.TINA4_RATE_WINDOW, 10)
|
|
279
|
+
: 60;
|
|
280
|
+
const windowMs = windowSeconds * 1000;
|
|
281
|
+
|
|
282
|
+
RateLimiterMiddleware.ensureCleanup(windowMs);
|
|
283
|
+
|
|
284
|
+
const now = Date.now();
|
|
285
|
+
const cutoff = now - windowMs;
|
|
286
|
+
|
|
287
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
288
|
+
const ip = (typeof forwarded === "string" ? forwarded.split(",")[0].trim() : undefined)
|
|
289
|
+
?? req.socket?.remoteAddress
|
|
290
|
+
?? "unknown";
|
|
291
|
+
|
|
292
|
+
let entry = RateLimiterMiddleware.store.get(ip);
|
|
293
|
+
if (!entry) {
|
|
294
|
+
entry = { timestamps: [] };
|
|
295
|
+
RateLimiterMiddleware.store.set(ip, entry);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
299
|
+
|
|
300
|
+
const resetTimestamp = entry.timestamps.length > 0
|
|
301
|
+
? Math.ceil((entry.timestamps[0] + windowMs) / 1000)
|
|
302
|
+
: Math.ceil((now + windowMs) / 1000);
|
|
303
|
+
|
|
304
|
+
const remaining = Math.max(0, limit - entry.timestamps.length);
|
|
305
|
+
|
|
306
|
+
res.header("X-RateLimit-Limit", String(limit));
|
|
307
|
+
res.header("X-RateLimit-Remaining", String(Math.max(0, remaining - 1)));
|
|
308
|
+
res.header("X-RateLimit-Reset", String(resetTimestamp));
|
|
309
|
+
|
|
310
|
+
if (entry.timestamps.length >= limit) {
|
|
311
|
+
const retryAfter = Math.max(1, resetTimestamp - Math.ceil(now / 1000));
|
|
312
|
+
res.header("Retry-After", String(retryAfter));
|
|
313
|
+
res.header("X-RateLimit-Remaining", "0");
|
|
314
|
+
res({
|
|
315
|
+
error: "Too Many Requests",
|
|
316
|
+
statusCode: 429,
|
|
317
|
+
message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
|
318
|
+
}, 429);
|
|
319
|
+
return [req, res];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
entry.timestamps.push(now);
|
|
323
|
+
return [req, res];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Class-based request logger middleware using the before/after convention.
|
|
329
|
+
* `beforeLog` stamps the request start time.
|
|
330
|
+
* `afterLog` prints the coloured status line.
|
|
331
|
+
*
|
|
332
|
+
* Usage:
|
|
333
|
+
* Router.use(RequestLogger);
|
|
334
|
+
*/
|
|
335
|
+
export class RequestLogger {
|
|
336
|
+
static beforeLog(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
337
|
+
(req as any).startTime = Date.now();
|
|
338
|
+
return [req, res];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
static afterLog(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
342
|
+
const duration = Date.now() - ((req as any).startTime ?? Date.now());
|
|
343
|
+
const status = res.raw.statusCode;
|
|
344
|
+
const method = req.method ?? "?";
|
|
345
|
+
const url = req.url ?? "/";
|
|
346
|
+
const color = status >= 400 ? "\x1b[31m" : status >= 300 ? "\x1b[33m" : "\x1b[32m";
|
|
347
|
+
console.log(` ${color}${status}\x1b[0m ${method} ${url} \x1b[90m${duration}ms\x1b[0m`);
|
|
348
|
+
return [req, res];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Built-in request logger middleware (function form — kept for backwards compat)
|
|
123
353
|
export function requestLogger(): Middleware {
|
|
124
354
|
return (req, res, next) => {
|
|
125
355
|
const start = Date.now();
|
|
@@ -61,6 +61,31 @@ export class Router {
|
|
|
61
61
|
private routes: Map<string, CompiledRoute[]> = new Map();
|
|
62
62
|
private wsRoutes: WebSocketRouteDefinition[] = [];
|
|
63
63
|
|
|
64
|
+
/** Class-based middleware registered via `use()` / `Router.use()`. */
|
|
65
|
+
private static _classMiddlewares: any[] = [];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Register a class-based middleware (beforeX / afterX convention).
|
|
69
|
+
* Classes are stored globally and executed by MiddlewareRunner.
|
|
70
|
+
*/
|
|
71
|
+
static use(middlewareClass: any): void {
|
|
72
|
+
Router._classMiddlewares.push(middlewareClass);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get all registered class-based middleware classes.
|
|
77
|
+
*/
|
|
78
|
+
static getClassMiddlewares(): any[] {
|
|
79
|
+
return Router._classMiddlewares;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Clear all registered class-based middleware (useful for testing).
|
|
84
|
+
*/
|
|
85
|
+
static clearClassMiddlewares(): void {
|
|
86
|
+
Router._classMiddlewares = [];
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
/**
|
|
65
90
|
* Add a raw route definition (used internally and by file-based routing).
|
|
66
91
|
*/
|
|
@@ -398,6 +398,18 @@ export class Session {
|
|
|
398
398
|
this.save();
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Clear all session data without destroying the session.
|
|
403
|
+
* The session ID and cookie remain — only the data is wiped.
|
|
404
|
+
*/
|
|
405
|
+
clear(): void {
|
|
406
|
+
if (!this.data) return;
|
|
407
|
+
for (const key of Object.keys(this.data)) {
|
|
408
|
+
delete this.data[key];
|
|
409
|
+
}
|
|
410
|
+
this.save();
|
|
411
|
+
}
|
|
412
|
+
|
|
401
413
|
/**
|
|
402
414
|
* Destroy the entire session.
|
|
403
415
|
*/
|