tw-framework 0.2.3__py3-none-any.whl
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.
- tw_framework/__init__.py +13 -0
- tw_framework/advanced_diagnostics.py +25 -0
- tw_framework/asset_optimizer.py +30 -0
- tw_framework/ast_nodes.py +283 -0
- tw_framework/build.py +7 -0
- tw_framework/build_performance.py +26 -0
- tw_framework/build_report.py +84 -0
- tw_framework/cli.py +1258 -0
- tw_framework/code_splitting.py +76 -0
- tw_framework/common.py +22 -0
- tw_framework/compiler.py +4817 -0
- tw_framework/compiler_stats.py +51 -0
- tw_framework/dead_code.py +44 -0
- tw_framework/dependency_graph.py +91 -0
- tw_framework/deploy.py +7 -0
- tw_framework/dev.py +9 -0
- tw_framework/diagnostics.py +98 -0
- tw_framework/dynamic_imports.py +51 -0
- tw_framework/error_formatter.py +53 -0
- tw_framework/framework.py +3216 -0
- tw_framework/hydration.py +129 -0
- tw_framework/incremental_cache.py +53 -0
- tw_framework/interpreter.py +42 -0
- tw_framework/ir.py +171 -0
- tw_framework/lexer.py +52 -0
- tw_framework/lowering.py +68 -0
- tw_framework/parser.py +167 -0
- tw_framework/partial_rebuild.py +24 -0
- tw_framework/performance_analyzer.py +37 -0
- tw_framework/plugin_runtime.py +196 -0
- tw_framework/production_optimizer.py +82 -0
- tw_framework/reactivity.py +349 -0
- tw_framework/render_css.py +22 -0
- tw_framework/render_html.py +259 -0
- tw_framework/route_optimizer.py +40 -0
- tw_framework/router.py +7 -0
- tw_framework/runtime_model.py +23 -0
- tw_framework/runtime_values.py +31 -0
- tw_framework/semantic.py +162 -0
- tw_framework/server.py +532 -0
- tw_framework/signature.py +51 -0
- tw_framework/static_dynamic_auto.py +32 -0
- tw_framework/streaming.py +135 -0
- tw_framework/test_build_cli_pytest.py +1078 -0
- tw_framework/tree_shaking.py +48 -0
- tw_framework/twm_parser.py +274 -0
- tw_framework-0.2.3.dist-info/METADATA +8 -0
- tw_framework-0.2.3.dist-info/RECORD +51 -0
- tw_framework-0.2.3.dist-info/WHEEL +5 -0
- tw_framework-0.2.3.dist-info/entry_points.txt +2 -0
- tw_framework-0.2.3.dist-info/top_level.txt +1 -0
tw_framework/server.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TW Production Server Runtime
|
|
3
|
+
- Real SSR (render server / render edge)
|
|
4
|
+
- Real API routes (production)
|
|
5
|
+
- Middleware (same as dev)
|
|
6
|
+
- Static file serving with ETag/Cache-Control
|
|
7
|
+
- Graceful shutdown, threading, keep-alive
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import http.server
|
|
11
|
+
import logging
|
|
12
|
+
import mimetypes
|
|
13
|
+
import os
|
|
14
|
+
from collections import OrderedDict
|
|
15
|
+
import signal
|
|
16
|
+
import socketserver
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.parse
|
|
20
|
+
from typing import Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from . import compiler
|
|
23
|
+
from .common import content_hash, log
|
|
24
|
+
from .framework import (
|
|
25
|
+
TWProject,
|
|
26
|
+
RouteMatch,
|
|
27
|
+
is_path_within,
|
|
28
|
+
normalize_url_path,
|
|
29
|
+
render_error_html,
|
|
30
|
+
format_compiler_error,
|
|
31
|
+
decode_request_body,
|
|
32
|
+
parse_cookie_header,
|
|
33
|
+
render_cookie_header,
|
|
34
|
+
load_project_env,
|
|
35
|
+
configure_compiler_paths,
|
|
36
|
+
invalidate_compiler_caches,
|
|
37
|
+
build_preview_candidates,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ─── SSR Page Cache ───────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
class SSRCache:
|
|
47
|
+
"""
|
|
48
|
+
In-memory TTL cache for server-rendered pages.
|
|
49
|
+
Respects `page { revalidate N }` — after N seconds, next request rebuilds.
|
|
50
|
+
"""
|
|
51
|
+
def __init__(self, max_entries: int = 512):
|
|
52
|
+
# NOTE: This cache lives for the lifetime of the server process.
|
|
53
|
+
# Keep it bounded to avoid unbounded memory growth under many unique routes.
|
|
54
|
+
env_max = os.environ.get("TW_SSR_CACHE_MAX", "").strip()
|
|
55
|
+
if env_max:
|
|
56
|
+
try:
|
|
57
|
+
max_entries = int(env_max)
|
|
58
|
+
except ValueError:
|
|
59
|
+
logger.warning("Invalid TW_SSR_CACHE_MAX=%r; using default %d", env_max, max_entries)
|
|
60
|
+
self.max_entries = max(0, int(max_entries))
|
|
61
|
+
self._store: "OrderedDict[str, dict]" = OrderedDict()
|
|
62
|
+
self._lock = threading.Lock()
|
|
63
|
+
|
|
64
|
+
def get(self, key: str) -> Optional[bytes]:
|
|
65
|
+
with self._lock:
|
|
66
|
+
entry = self._store.get(key)
|
|
67
|
+
if not entry:
|
|
68
|
+
return None
|
|
69
|
+
if entry["ttl"] and (time.monotonic() - entry["at"]) > entry["ttl"]:
|
|
70
|
+
del self._store[key]
|
|
71
|
+
return None
|
|
72
|
+
# Mark as recently used (LRU)
|
|
73
|
+
self._store.move_to_end(key)
|
|
74
|
+
return entry["body"]
|
|
75
|
+
|
|
76
|
+
def _enforce_namespace_limit(self, namespace: Optional[str], namespace_max: Optional[int]):
|
|
77
|
+
if not namespace or not namespace_max or namespace_max <= 0:
|
|
78
|
+
return
|
|
79
|
+
while True:
|
|
80
|
+
matching_keys = [cache_key for cache_key, entry in self._store.items() if entry.get("namespace") == namespace]
|
|
81
|
+
if len(matching_keys) <= namespace_max:
|
|
82
|
+
return
|
|
83
|
+
self._store.pop(matching_keys[0], None)
|
|
84
|
+
|
|
85
|
+
def set(
|
|
86
|
+
self,
|
|
87
|
+
key: str,
|
|
88
|
+
body: bytes,
|
|
89
|
+
ttl: Optional[float],
|
|
90
|
+
*,
|
|
91
|
+
namespace: Optional[str] = None,
|
|
92
|
+
namespace_max: Optional[int] = None,
|
|
93
|
+
):
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._store[key] = {
|
|
96
|
+
"body": body,
|
|
97
|
+
"at": time.monotonic(),
|
|
98
|
+
"ttl": ttl,
|
|
99
|
+
"namespace": namespace,
|
|
100
|
+
}
|
|
101
|
+
self._store.move_to_end(key)
|
|
102
|
+
self._enforce_namespace_limit(namespace, namespace_max)
|
|
103
|
+
if self.max_entries and len(self._store) > self.max_entries:
|
|
104
|
+
# Evict least-recently-used entries
|
|
105
|
+
while self.max_entries and len(self._store) > self.max_entries:
|
|
106
|
+
self._store.popitem(last=False)
|
|
107
|
+
|
|
108
|
+
def invalidate(self, key: str):
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._store.pop(key, None)
|
|
111
|
+
|
|
112
|
+
def clear(self):
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._store.clear()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ─── ETag / static file helpers ──────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def compute_etag(data: bytes) -> str:
|
|
120
|
+
return '"' + content_hash(data) + '"'
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def serve_static_file(path: str) -> Optional[Tuple[bytes, str, str]]:
|
|
124
|
+
"""Returns (body, content_type, etag) or None."""
|
|
125
|
+
if not os.path.isfile(path):
|
|
126
|
+
return None
|
|
127
|
+
with open(path, "rb") as f:
|
|
128
|
+
body = f.read()
|
|
129
|
+
content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
130
|
+
etag = compute_etag(body)
|
|
131
|
+
return body, content_type, etag
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def try_brotli_or_gzip(path: str) -> Optional[Tuple[bytes, str]]:
|
|
135
|
+
"""Returns (compressed_body, encoding) if pre-compressed variant exists."""
|
|
136
|
+
for variant, encoding in [(path + ".br", "br"), (path + ".gz", "gz")]:
|
|
137
|
+
if os.path.isfile(variant):
|
|
138
|
+
with open(variant, "rb") as f:
|
|
139
|
+
return f.read(), encoding
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ─── Production Request Handler ──────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def make_production_handler(project: TWProject, output_dir: Optional[str], ssr_cache: SSRCache):
|
|
146
|
+
|
|
147
|
+
output_dir_abs = os.path.abspath(output_dir) if output_dir else None
|
|
148
|
+
|
|
149
|
+
class TWProductionHandler(http.server.BaseHTTPRequestHandler):
|
|
150
|
+
server_version = "TWServer/1.0"
|
|
151
|
+
|
|
152
|
+
def log_message(self, fmt, *args):
|
|
153
|
+
ts = time.strftime("%H:%M:%S")
|
|
154
|
+
log(f"[{ts}] {self.command} {self.path} — {fmt % args}")
|
|
155
|
+
|
|
156
|
+
def do_GET(self): self._handle("GET")
|
|
157
|
+
def do_POST(self): self._handle("POST")
|
|
158
|
+
def do_PUT(self): self._handle("PUT")
|
|
159
|
+
def do_PATCH(self): self._handle("PATCH")
|
|
160
|
+
def do_DELETE(self): self._handle("DELETE")
|
|
161
|
+
def do_OPTIONS(self):self._handle("OPTIONS")
|
|
162
|
+
def do_HEAD(self): self._handle("HEAD")
|
|
163
|
+
|
|
164
|
+
def _handle(self, method: str):
|
|
165
|
+
raw_path = self.path
|
|
166
|
+
url_path = normalize_url_path(raw_path)
|
|
167
|
+
|
|
168
|
+
# Health check
|
|
169
|
+
if url_path == "/__tw/health":
|
|
170
|
+
self._send(200, b"ok", "text/plain; charset=utf-8")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
request_headers = dict(self.headers)
|
|
174
|
+
|
|
175
|
+
# ── Modern request middleware hook (extensions) ───────────────
|
|
176
|
+
hook_state = project.extensions.emit(
|
|
177
|
+
"beforeRequest",
|
|
178
|
+
method=method,
|
|
179
|
+
raw_path=raw_path,
|
|
180
|
+
url_path=url_path,
|
|
181
|
+
request_headers=request_headers,
|
|
182
|
+
request_meta={"client_ip": self.client_address[0] if self.client_address else ""},
|
|
183
|
+
dev_mode=False,
|
|
184
|
+
)
|
|
185
|
+
if hook_state.get("response"):
|
|
186
|
+
response = hook_state["response"]
|
|
187
|
+
self._send(
|
|
188
|
+
response["status"],
|
|
189
|
+
response["body"],
|
|
190
|
+
response["content_type"],
|
|
191
|
+
extra_headers=response.get("headers", []),
|
|
192
|
+
cookies=response.get("cookies", []),
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
if hook_state.get("redirect"):
|
|
196
|
+
location = str(hook_state["redirect"])
|
|
197
|
+
self._send(
|
|
198
|
+
302, b"",
|
|
199
|
+
"text/plain; charset=utf-8",
|
|
200
|
+
extra_headers=[("Location", location)] + list(hook_state.get("headers", [])),
|
|
201
|
+
cookies=list(hook_state.get("cookies", [])),
|
|
202
|
+
)
|
|
203
|
+
return
|
|
204
|
+
if hook_state.get("rewrite"):
|
|
205
|
+
url_path = normalize_url_path(str(hook_state["rewrite"]))
|
|
206
|
+
request_headers = hook_state.get("request_headers", request_headers)
|
|
207
|
+
|
|
208
|
+
# ── Middleware ────────────────────────────────────────────────
|
|
209
|
+
mw = project.apply_middleware(
|
|
210
|
+
raw_path,
|
|
211
|
+
request_headers,
|
|
212
|
+
request_meta={"client_ip": self.client_address[0] if self.client_address else ""},
|
|
213
|
+
method=method,
|
|
214
|
+
)
|
|
215
|
+
if mw.get("response"):
|
|
216
|
+
response = mw["response"]
|
|
217
|
+
self._send(
|
|
218
|
+
response["status"],
|
|
219
|
+
response["body"],
|
|
220
|
+
response["content_type"],
|
|
221
|
+
extra_headers=response.get("headers", []),
|
|
222
|
+
cookies=response.get("cookies", []),
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
if mw.get("redirect"):
|
|
226
|
+
self._send(
|
|
227
|
+
302, b"",
|
|
228
|
+
"text/plain; charset=utf-8",
|
|
229
|
+
extra_headers=[("Location", mw["redirect"])] + mw.get("headers", []),
|
|
230
|
+
cookies=mw.get("cookies", []),
|
|
231
|
+
)
|
|
232
|
+
return
|
|
233
|
+
url_path = normalize_url_path(mw.get("path", url_path))
|
|
234
|
+
|
|
235
|
+
# ── API routes ────────────────────────────────────────────────
|
|
236
|
+
api_route = project.resolve_api_route(url_path)
|
|
237
|
+
if api_route is not None:
|
|
238
|
+
body_data = decode_request_body(self) if method in {"POST", "PUT", "PATCH"} else {}
|
|
239
|
+
try:
|
|
240
|
+
resp = project.execute_api_route(api_route, method, raw_path, request_headers, body_data)
|
|
241
|
+
self._send(
|
|
242
|
+
resp["status"],
|
|
243
|
+
resp["body"],
|
|
244
|
+
resp["content_type"],
|
|
245
|
+
extra_headers=mw.get("headers", []) + resp.get("headers", []),
|
|
246
|
+
cookies=mw.get("cookies", []) + resp.get("cookies", []),
|
|
247
|
+
)
|
|
248
|
+
except Exception as err:
|
|
249
|
+
logger.exception("Unhandled API route error: %s %s -> %s", method, raw_path, api_route)
|
|
250
|
+
self._serve_500(f"API Error: {type(err).__name__}: {err}", mw)
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
# ── Static assets (assets/, _tw/static/chunks/) ───────────────
|
|
254
|
+
asset = project.resolve_asset(url_path)
|
|
255
|
+
if asset is not None:
|
|
256
|
+
payload, ct = asset
|
|
257
|
+
etag = compute_etag(payload)
|
|
258
|
+
if self.headers.get("If-None-Match") == etag:
|
|
259
|
+
self._send(304, b"", ct)
|
|
260
|
+
return
|
|
261
|
+
self._send(200, payload, ct,
|
|
262
|
+
extra_headers=[("ETag", etag), ("Cache-Control", "public, max-age=31536000, immutable")] + mw.get("headers", []),
|
|
263
|
+
cookies=mw.get("cookies", []))
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# ── Pre-built static output (dist/) ───────────────────────────
|
|
267
|
+
if output_dir_abs and method in {"GET", "HEAD"}:
|
|
268
|
+
for candidate in build_preview_candidates(output_dir_abs, url_path):
|
|
269
|
+
candidate = os.path.abspath(candidate)
|
|
270
|
+
if not is_path_within(output_dir_abs, candidate):
|
|
271
|
+
continue
|
|
272
|
+
result = serve_static_file(candidate)
|
|
273
|
+
if result:
|
|
274
|
+
body_bytes, ct, etag = result
|
|
275
|
+
if self.headers.get("If-None-Match") == etag:
|
|
276
|
+
self._send(304, b"", ct)
|
|
277
|
+
return
|
|
278
|
+
# Prefer pre-compressed if client accepts
|
|
279
|
+
accept_enc = self.headers.get("Accept-Encoding", "")
|
|
280
|
+
compressed = try_brotli_or_gzip(candidate)
|
|
281
|
+
if compressed:
|
|
282
|
+
cbody, enc = compressed
|
|
283
|
+
if (enc == "br" and "br" in accept_enc) or (enc == "gz" and "gzip" in accept_enc):
|
|
284
|
+
enc_header = "br" if enc == "br" else "gzip"
|
|
285
|
+
self._send(200, cbody, ct,
|
|
286
|
+
extra_headers=[
|
|
287
|
+
("ETag", etag),
|
|
288
|
+
("Content-Encoding", enc_header),
|
|
289
|
+
("Cache-Control", "public, max-age=3600"),
|
|
290
|
+
("Vary", "Accept-Encoding"),
|
|
291
|
+
] + mw.get("headers", []),
|
|
292
|
+
cookies=mw.get("cookies", []))
|
|
293
|
+
return
|
|
294
|
+
self._send(200, body_bytes if method != "HEAD" else b"", ct,
|
|
295
|
+
extra_headers=[("ETag", etag), ("Cache-Control", "public, max-age=3600")] + mw.get("headers", []),
|
|
296
|
+
cookies=mw.get("cookies", []))
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
# ── SSR / dynamic page ────────────────────────────────────────
|
|
300
|
+
match = project.resolve_route(url_path)
|
|
301
|
+
if not match:
|
|
302
|
+
self._serve_404(mw)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
self._serve_page(match, method, mw, raw_path, request_headers)
|
|
306
|
+
|
|
307
|
+
def _build_page_cache_key(self, match: RouteMatch, raw_path: str, request_headers: Dict[str, str], render_mode: str, page_ast) -> str:
|
|
308
|
+
parsed = urllib.parse.urlparse(raw_path)
|
|
309
|
+
cache_by = getattr(page_ast, "cache_by", None)
|
|
310
|
+
if render_mode == "edge" and not cache_by:
|
|
311
|
+
cookie_hash = content_hash(request_headers.get("Cookie", "")) if request_headers.get("Cookie") else ""
|
|
312
|
+
query = parsed.query or ""
|
|
313
|
+
return f"{match.route_path}::{render_mode}::default::{query}::{cookie_hash}"
|
|
314
|
+
if not cache_by:
|
|
315
|
+
return f"{match.route_path}::{render_mode}"
|
|
316
|
+
selector = str(cache_by).strip()
|
|
317
|
+
if selector.startswith("cookie:"):
|
|
318
|
+
cookie_name = selector.split(":", 1)[1]
|
|
319
|
+
cookies = parse_cookie_header(request_headers.get("Cookie", ""))
|
|
320
|
+
return f"{match.route_path}::{render_mode}::{selector}::{cookies.get(cookie_name, '')}"
|
|
321
|
+
if selector.startswith("header:"):
|
|
322
|
+
header_name = selector.split(":", 1)[1]
|
|
323
|
+
return f"{match.route_path}::{render_mode}::{selector}::{request_headers.get(header_name, '')}"
|
|
324
|
+
if selector == "query":
|
|
325
|
+
return f"{match.route_path}::{render_mode}::{selector}::{parsed.query or ''}"
|
|
326
|
+
return f"{match.route_path}::{render_mode}::{selector}"
|
|
327
|
+
|
|
328
|
+
def _serve_page(self, match: RouteMatch, method: str, mw: dict, raw_path: str, request_headers: Dict[str, str]):
|
|
329
|
+
page_path = match.page_info["path"]
|
|
330
|
+
try:
|
|
331
|
+
page_ast = compiler.load_page_ast_from_file(page_path)
|
|
332
|
+
except Exception as err:
|
|
333
|
+
self._serve_500(format_compiler_error(page_path, err), mw)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
render_mode = getattr(page_ast, "render_mode", "static")
|
|
337
|
+
revalidate_ttl = getattr(page_ast, "revalidate", None)
|
|
338
|
+
if revalidate_ttl is not None:
|
|
339
|
+
try:
|
|
340
|
+
revalidate_ttl = float(revalidate_ttl)
|
|
341
|
+
except (TypeError, ValueError):
|
|
342
|
+
logger.exception("Invalid `revalidate` value in %s: %r", page_path, revalidate_ttl)
|
|
343
|
+
revalidate_ttl = None
|
|
344
|
+
|
|
345
|
+
cache_key = self._build_page_cache_key(match, raw_path, request_headers, render_mode, page_ast)
|
|
346
|
+
cache_size = getattr(page_ast, "cache_size", None)
|
|
347
|
+
try:
|
|
348
|
+
cache_size = int(cache_size) if cache_size is not None else None
|
|
349
|
+
except (TypeError, ValueError):
|
|
350
|
+
cache_size = None
|
|
351
|
+
|
|
352
|
+
# Static pages: try SSR cache first
|
|
353
|
+
if render_mode in {"static", "edge"}:
|
|
354
|
+
cached = ssr_cache.get(cache_key)
|
|
355
|
+
if cached is not None:
|
|
356
|
+
self._send(200, cached, "text/html; charset=utf-8",
|
|
357
|
+
extra_headers=[("X-TW-Cache", "HIT"), ("X-TW-Render", render_mode)] + mw.get("headers", []),
|
|
358
|
+
cookies=mw.get("cookies", []))
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
response = project.compile_match_response(match, dev_mode=False)
|
|
363
|
+
except Exception as err:
|
|
364
|
+
self._serve_500(format_compiler_error(page_path, err), mw)
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
body_bytes = response["html"].encode("utf-8")
|
|
368
|
+
status = response.get("status", 200)
|
|
369
|
+
page_headers = response.get("headers", [])
|
|
370
|
+
|
|
371
|
+
# Cache rendered output for static/edge pages with revalidate
|
|
372
|
+
if render_mode in {"static", "edge"} and revalidate_ttl:
|
|
373
|
+
ssr_cache.set(
|
|
374
|
+
cache_key,
|
|
375
|
+
body_bytes,
|
|
376
|
+
revalidate_ttl,
|
|
377
|
+
namespace=match.route_path,
|
|
378
|
+
namespace_max=cache_size,
|
|
379
|
+
)
|
|
380
|
+
elif render_mode == "static":
|
|
381
|
+
# Static pages without revalidate: cache indefinitely (until server restart)
|
|
382
|
+
ssr_cache.set(
|
|
383
|
+
cache_key,
|
|
384
|
+
body_bytes,
|
|
385
|
+
None,
|
|
386
|
+
namespace=match.route_path,
|
|
387
|
+
namespace_max=cache_size,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
self._send(
|
|
391
|
+
status,
|
|
392
|
+
body_bytes if self.command != "HEAD" else b"",
|
|
393
|
+
"text/html; charset=utf-8",
|
|
394
|
+
extra_headers=[
|
|
395
|
+
("X-TW-Cache", "MISS"),
|
|
396
|
+
("X-TW-Render", render_mode),
|
|
397
|
+
] + mw.get("headers", []) + page_headers,
|
|
398
|
+
cookies=mw.get("cookies", []),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _serve_404(self, mw: dict):
|
|
402
|
+
try:
|
|
403
|
+
custom = project.compile_special_page(404, dev_mode=False)
|
|
404
|
+
if custom:
|
|
405
|
+
self._send(404, custom.encode("utf-8"), "text/html; charset=utf-8",
|
|
406
|
+
extra_headers=mw.get("headers", []), cookies=mw.get("cookies", []))
|
|
407
|
+
return
|
|
408
|
+
except Exception:
|
|
409
|
+
logger.exception("Failed to compile custom 404 page")
|
|
410
|
+
self._send(404, render_error_html("Not Found", f"Route not found: {normalize_url_path(self.path)}", 404),
|
|
411
|
+
"text/html; charset=utf-8", extra_headers=mw.get("headers", []), cookies=mw.get("cookies", []))
|
|
412
|
+
|
|
413
|
+
def _serve_500(self, message: str, mw: dict):
|
|
414
|
+
try:
|
|
415
|
+
custom = project.compile_special_page(500, dev_mode=False)
|
|
416
|
+
if custom:
|
|
417
|
+
self._send(500, custom.encode("utf-8"), "text/html; charset=utf-8",
|
|
418
|
+
extra_headers=mw.get("headers", []), cookies=mw.get("cookies", []))
|
|
419
|
+
return
|
|
420
|
+
except Exception:
|
|
421
|
+
logger.exception("Failed to compile custom 500 page")
|
|
422
|
+
self._send(
|
|
423
|
+
500,
|
|
424
|
+
render_error_html("Server Error", message, 500),
|
|
425
|
+
"text/html; charset=utf-8",
|
|
426
|
+
extra_headers=mw.get("headers", []),
|
|
427
|
+
cookies=mw.get("cookies", []),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def _send(self, status: int, body: bytes, content_type: str,
|
|
431
|
+
extra_headers: Optional[List] = None,
|
|
432
|
+
cookies: Optional[List] = None):
|
|
433
|
+
self.send_response(status)
|
|
434
|
+
self.send_header("Content-Type", content_type)
|
|
435
|
+
self.send_header("Content-Length", str(len(body)))
|
|
436
|
+
for name, value in (extra_headers or []):
|
|
437
|
+
self.send_header(name, value)
|
|
438
|
+
for name, value in (cookies or []):
|
|
439
|
+
self.send_header(
|
|
440
|
+
"Set-Cookie",
|
|
441
|
+
render_cookie_header(
|
|
442
|
+
name,
|
|
443
|
+
value,
|
|
444
|
+
config=project.config,
|
|
445
|
+
request_headers=dict(self.headers),
|
|
446
|
+
server_port=self.server.server_address[1],
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
self.end_headers()
|
|
450
|
+
if body:
|
|
451
|
+
try:
|
|
452
|
+
self.wfile.write(body)
|
|
453
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
454
|
+
pass
|
|
455
|
+
|
|
456
|
+
return TWProductionHandler
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ─── Threaded TCP server ──────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
|
462
|
+
allow_reuse_address = True
|
|
463
|
+
daemon_threads = True
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# ─── run_production_server ────────────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
def run_production_server(
|
|
469
|
+
project_root: str,
|
|
470
|
+
host: str = "0.0.0.0",
|
|
471
|
+
port: int = 8000,
|
|
472
|
+
output_dir: Optional[str] = None,
|
|
473
|
+
workers: Optional[int] = None,
|
|
474
|
+
):
|
|
475
|
+
"""
|
|
476
|
+
Start the TW production server.
|
|
477
|
+
|
|
478
|
+
- Serves SSR pages (render static | server | edge)
|
|
479
|
+
- Handles real API routes
|
|
480
|
+
- Applies middleware
|
|
481
|
+
- Serves pre-built static files from output_dir if provided
|
|
482
|
+
- ETag, Cache-Control, brotli/gzip negotiation
|
|
483
|
+
- Graceful SIGTERM/SIGINT shutdown
|
|
484
|
+
"""
|
|
485
|
+
project_root = os.path.abspath(project_root)
|
|
486
|
+
configure_compiler_paths(project_root)
|
|
487
|
+
invalidate_compiler_caches()
|
|
488
|
+
load_project_env(project_root, "production")
|
|
489
|
+
|
|
490
|
+
project = TWProject(project_root)
|
|
491
|
+
config_cache_max = (
|
|
492
|
+
compiler.get_config_value(project.config, "ssr", "cache_max")
|
|
493
|
+
or project.config.get("ssr.cache_max")
|
|
494
|
+
or project.config.get("ssr_cache_max")
|
|
495
|
+
)
|
|
496
|
+
ssr_cache = SSRCache(max_entries=int(config_cache_max) if config_cache_max is not None else 512)
|
|
497
|
+
|
|
498
|
+
handler = make_production_handler(project, output_dir, ssr_cache)
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
server = ThreadedTCPServer((host, port), handler)
|
|
502
|
+
except OSError as err:
|
|
503
|
+
raise RuntimeError(
|
|
504
|
+
f"Could not bind to port {port}: {err}\n"
|
|
505
|
+
f"Try: TW_PORT={port + 1} tw serve"
|
|
506
|
+
) from err
|
|
507
|
+
|
|
508
|
+
log("🚀 TW Production Server")
|
|
509
|
+
log(f" Listening: http://{host}:{port}")
|
|
510
|
+
log(f" Project: {project_root}")
|
|
511
|
+
if output_dir and os.path.isdir(output_dir):
|
|
512
|
+
log(f" Static: {os.path.abspath(output_dir)}")
|
|
513
|
+
log(" SSR cache: enabled")
|
|
514
|
+
log(" Press Ctrl+C to stop\n")
|
|
515
|
+
|
|
516
|
+
stop_event = threading.Event()
|
|
517
|
+
|
|
518
|
+
def _shutdown(signum, frame):
|
|
519
|
+
log("\nShutting down...")
|
|
520
|
+
stop_event.set()
|
|
521
|
+
threading.Thread(target=server.shutdown, daemon=True).start()
|
|
522
|
+
|
|
523
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
524
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
server.serve_forever()
|
|
528
|
+
except KeyboardInterrupt:
|
|
529
|
+
pass
|
|
530
|
+
finally:
|
|
531
|
+
server.server_close()
|
|
532
|
+
log("Server stopped.")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .common import content_hash
|
|
7
|
+
|
|
8
|
+
TW_SIGNATURE_SALT = "TW-FRAMEWORK-V1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compute_tw_signature(program: Any, *, length: int = 16) -> str:
|
|
12
|
+
"""
|
|
13
|
+
किसी भी TW program (AST या IR, दोनों में .to_dict() होता है) का
|
|
14
|
+
deterministic fingerprint निकालता है।
|
|
15
|
+
|
|
16
|
+
यह कोई गुप्त secret नहीं है — कोई भी जिसके पास वही `.tw` source और
|
|
17
|
+
वही TW framework version है, वो यही signature दोबारा बना सकता है।
|
|
18
|
+
यही तो इसका मकसद है: इससे कोई भी `tw verify` चलाकर स्वतंत्र रूप से
|
|
19
|
+
यह पुष्टि कर सकता है कि दिया गया HTML असल में किसी specific TW source
|
|
20
|
+
को compile करने से आया है, न कि कोई हाथ से लिखा हुआ HTML जो सिर्फ
|
|
21
|
+
"TW से बना है" का दावा करता है।
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
payload = program.to_dict()
|
|
25
|
+
except Exception:
|
|
26
|
+
payload = {"repr": repr(program)}
|
|
27
|
+
serialized = json.dumps(payload, sort_keys=True, default=str, ensure_ascii=False)
|
|
28
|
+
return content_hash(f"{TW_SIGNATURE_SALT}::{serialized}", length=length)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_signature_banner(signature: str, *, title: str = "") -> str:
|
|
32
|
+
safe_title = (title or "").replace("-->", "-- >")
|
|
33
|
+
return (
|
|
34
|
+
f"<!-- Built with TW Framework (https://github.com/ffakraj-ui/twlang) "
|
|
35
|
+
f"- tw-signature:{signature} -->"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_signature_meta_tag(signature: str) -> str:
|
|
40
|
+
return (
|
|
41
|
+
f'<meta name="generator" content="TW Framework">\n'
|
|
42
|
+
f'<meta name="tw-signature" content="{signature}">'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"compute_tw_signature",
|
|
48
|
+
"build_signature_banner",
|
|
49
|
+
"build_signature_meta_tag",
|
|
50
|
+
"TW_SIGNATURE_SALT",
|
|
51
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Automatic static/dynamic route detection for TW Framework.
|
|
3
|
+
|
|
4
|
+
When `render auto` is set, the compiler chooses the best rendering mode
|
|
5
|
+
based on the page's content and dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from . import compiler
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def determine_render_mode(page_path: str) -> str:
|
|
17
|
+
"""Analyze a page and return the recommended render mode."""
|
|
18
|
+
raw = compiler.read_text_file(page_path)
|
|
19
|
+
# Check for explicit render mode
|
|
20
|
+
import re
|
|
21
|
+
match = re.search(r'render\s+(\w+)', raw)
|
|
22
|
+
if match:
|
|
23
|
+
mode = match.group(1).lower()
|
|
24
|
+
if mode in ("static", "server", "edge"):
|
|
25
|
+
return mode
|
|
26
|
+
# Auto-detect: if the page has dynamic content (e.g., `for`, `if`, `state`), use server
|
|
27
|
+
if "state {" in raw or "for " in raw or "if " in raw:
|
|
28
|
+
return "server"
|
|
29
|
+
return "static"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["determine_render_mode"]
|