diff --git a/packages/mizan-django/generate/generator/lib/adapters/react.mjs b/packages/mizan-django/generate/generator/lib/adapters/react.mjs index ca7bda1..f58739b 100644 --- a/packages/mizan-django/generate/generator/lib/adapters/react.mjs +++ b/packages/mizan-django/generate/generator/lib/adapters/react.mjs @@ -106,7 +106,7 @@ export function generateReactAdapter(schema) { const deps = paramEntries.map(([pName]) => `params.${pName}`) lines.push(` }, [${deps.join(', ')}])`) lines.push('') - lines.push(` useEffect(() => { refetch() }, [refetch])`) + lines.push(` useEffect(() => { if (!data) refetch() }, [data, refetch])`) lines.push(` useEffect(() => registerContext('${ctxName}', params, refetch), [${deps.join(', ')}, refetch])`) lines.push('') lines.push(` return <${p}Ctx.Provider value={data}>{children}`) diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py index 6f31185..b4a30ea 100644 --- a/packages/mizan-django/src/mizan/client/executor.py +++ b/packages/mizan-django/src/mizan/client/executor.py @@ -161,6 +161,42 @@ def _check_auth_requirement( return None +_cache_log = logging.getLogger("mizan.cache") + + +def _purge_cache_for_invalidation( + invalidate: list, + request: HttpRequest | None = None, +) -> None: + """Purge origin-side cache for invalidation targets. Includes user_id if available.""" + cache = get_cache() + if cache is None: + return + + settings = get_settings() + if not settings.cache_secret: + return + + user_id = None + if request and hasattr(request, 'user') and hasattr(request.user, 'pk'): + uid = getattr(request.user, 'pk', None) + if uid is not None: + user_id = str(uid) + + try: + for entry in invalidate: + if isinstance(entry, str): + cache_purge(cache, entry) + elif isinstance(entry, dict): + cache_purge( + cache, entry["context"], entry.get("params"), + secret=settings.cache_secret, + user_id=user_id, + ) + except Exception: + _cache_log.warning("Cache purge failed", exc_info=True) + + def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]: """ Determine whether an affects target is a context name or function name. @@ -444,10 +480,11 @@ def execute_function( from django.http import HttpResponseBase if isinstance(output, HttpResponseBase): - # View path — add invalidation header, pass through the response + # View path — add invalidation header + purge origin cache invalidate = _resolve_invalidation(view_class, input_data) if invalidate: output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate) + _purge_cache_for_invalidation(invalidate, request) output["Cache-Control"] = "no-store" return output @@ -701,28 +738,9 @@ def function_call_view(request: HttpRequest) -> JsonResponse: response = JsonResponse(response_data) response["Cache-Control"] = "no-store" - # Always set the header transport too (Edge reads this) if invalidate_contexts: response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts) - - # Purge origin-side cache for invalidated contexts - _cache_log = logging.getLogger("mizan.cache") - cache = get_cache() - cache_settings = get_settings() - if cache is not None: - try: - for entry in invalidate_contexts: - if isinstance(entry, str): - # Broad purge (no params) — prefix scan - cache_purge(cache, entry) - elif isinstance(entry, dict): - # Scoped purge — recompute key and delete directly - cache_purge( - cache, entry["context"], entry.get("params"), - secret=cache_settings.cache_secret, - ) - except Exception: - _cache_log.warning("Cache purge failed", exc_info=True) + _purge_cache_for_invalidation(invalidate_contexts, request) return response diff --git a/packages/mizan-django/src/mizan/client/function.py b/packages/mizan-django/src/mizan/client/function.py index a0bf691..1d049f9 100644 --- a/packages/mizan-django/src/mizan/client/function.py +++ b/packages/mizan-django/src/mizan/client/function.py @@ -536,8 +536,8 @@ def _create_server_function( if cache is not True: meta["cache"] = cache - if meta: - FunctionWrapper._meta = {**FunctionWrapper._meta, **meta} + # Always assign a fresh dict to prevent shared-dict mutation across classes + FunctionWrapper._meta = {**meta} # Note: Registration happens via discovery (mizan_clients), not here. # This allows the decorator to be used without import-time side effects. diff --git a/packages/mizan-django/src/mizan/ssr/backend.py b/packages/mizan-django/src/mizan/ssr/backend.py index 7f87384..f9f3ac3 100644 --- a/packages/mizan-django/src/mizan/ssr/backend.py +++ b/packages/mizan-django/src/mizan/ssr/backend.py @@ -35,13 +35,21 @@ class MizanTemplate: self._bridge = bridge def render(self, context: dict[str, Any] | None = None, request: Any = None) -> str: + import json as _json + props = dict(context) if context else {} props.pop("request", None) props.pop("csrf_token", None) result = self._bridge.render(self.file_path, props) - return mark_safe(f'
{result.html}
') + # Serialize props as hydration data for client-side React + hydration_json = _json.dumps(props, sort_keys=True, default=str) + + return mark_safe( + f'
{result.html}
' + f'' + ) class MizanTemplates(BaseEngine): diff --git a/packages/mizan-django/src/mizan/ssr/bridge.py b/packages/mizan-django/src/mizan/ssr/bridge.py index 0fb28e1..78d1084 100644 --- a/packages/mizan-django/src/mizan/ssr/bridge.py +++ b/packages/mizan-django/src/mizan/ssr/bridge.py @@ -3,7 +3,7 @@ SSR Bridge — Manages a persistent Bun subprocess for React rendering. Protocol: newline-delimited JSON-RPC over stdin/stdout. -Request: {"id": 1, "method": "render", "params": {"component": "ProfilePage", "props": {...}}} +Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}} Response: {"id": 1, "html": "
...
"} The subprocess stays alive across requests. It is started on first use @@ -12,6 +12,7 @@ and restarted automatically if it crashes. from __future__ import annotations +import atexit import json import logging import subprocess @@ -41,12 +42,16 @@ class SSRBridge: self._timeout = timeout self._proc: subprocess.Popen | None = None self._lock = threading.Lock() + self._write_lock = threading.Lock() # Serializes stdin writes self._counter = 0 self._pending: dict[int, threading.Event] = {} self._results: dict[int, dict] = {} self._reader_thread: threading.Thread | None = None self._ready = threading.Event() + # Ensure cleanup on process exit + atexit.register(self.shutdown) + def _ensure_running(self) -> None: """Start the Bun subprocess if it's not running.""" if self._proc is not None and self._proc.poll() is None: @@ -134,12 +139,14 @@ class SSRBridge: "params": {"file": file, "props": props or {}}, }) + "\n" - try: - self._proc.stdin.write(request.encode("utf-8")) - self._proc.stdin.flush() - except (BrokenPipeError, OSError) as e: - del self._pending[msg_id] - raise RuntimeError(f"SSR worker pipe broken: {e}") + # Serialize stdin writes to prevent interleaving from concurrent threads + with self._write_lock: + try: + self._proc.stdin.write(request.encode("utf-8")) + self._proc.stdin.flush() + except (BrokenPipeError, OSError) as e: + self._pending.pop(msg_id, None) + raise RuntimeError(f"SSR worker pipe broken: {e}") if not event.wait(self._timeout): self._pending.pop(msg_id, None) @@ -155,33 +162,6 @@ class SSRBridge: return RenderResult(html=result["html"]) - def ping(self) -> bool: - """Health check. Returns True if the worker responds.""" - with self._lock: - self._ensure_running() - self._counter += 1 - msg_id = self._counter - - event = threading.Event() - self._pending[msg_id] = event - - request = json.dumps({"id": msg_id, "method": "ping"}) + "\n" - - try: - self._proc.stdin.write(request.encode("utf-8")) - self._proc.stdin.flush() - except (BrokenPipeError, OSError): - del self._pending[msg_id] - return False - - if not event.wait(self._timeout): - self._pending.pop(msg_id, None) - return False - - self._pending.pop(msg_id, None) - result = self._results.pop(msg_id) - return result.get("pong", False) - def shutdown(self) -> None: """Stop the Bun subprocess.""" if self._proc is not None: diff --git a/packages/mizan-runtime/src/index.ts b/packages/mizan-runtime/src/index.ts index 7fdf99d..24e877e 100644 --- a/packages/mizan-runtime/src/index.ts +++ b/packages/mizan-runtime/src/index.ts @@ -58,19 +58,26 @@ let _sessionReady: Promise | null = null * Initialize a session (fetches CSRF cookie from GET /session/). * Called automatically on first fetch if not called explicitly. * No-op if a CSRF cookie already exists. + * Retries on failure — resets so next call tries again. */ export function initSession(): Promise { if (_sessionReady) return _sessionReady _sessionReady = (async () => { - // If we already have a CSRF token, skip if (getCSRFToken()) return - try { - await fetch(`${config.baseUrl}/session/`, { credentials: 'include' }) - } catch (e) { - console.error('[mizan] Session init failed:', e) + for (let attempt = 0; attempt < 3; attempt++) { + try { + await fetch(`${config.baseUrl}/session/`, { credentials: 'include' }) + if (getCSRFToken()) return + } catch (e) { + console.warn(`[mizan] Session init attempt ${attempt + 1} failed:`, e) + } + if (attempt < 2) await new Promise(r => setTimeout(r, (attempt + 1) * 100)) } + + // All retries failed — reset so next call tries again + _sessionReady = null })() return _sessionReady @@ -88,26 +95,31 @@ interface ContextEntry { const contexts: Map> = new Map() +/** Deterministic JSON key for params — sorted to avoid order-dependency */ +function stableKey(params: Record): string { + return JSON.stringify(params, Object.keys(params).sort()) +} + export function registerContext( name: string, params: Record, refetch: RefetchFn, ): () => void { if (!contexts.has(name)) contexts.set(name, new Map()) - const key = JSON.stringify(params) + const key = stableKey(params) contexts.get(name)!.set(key, { params, refetch }) - return () => contexts.get(name)!.delete(key) + return () => contexts.get(name)?.delete(key) } // === Invalidation === const pending: Set = new Set() -const pendingScoped: Map> = new Map() +const pendingScoped: Array<{ context: string; params: Record }> = [] let scheduled = false export function invalidate(context: string, params?: Record): void { if (params) { - pendingScoped.set(context, params) + pendingScoped.push({ context, params }) } else { pending.add(context) } @@ -123,17 +135,17 @@ function flush(): void { if (entries) entries.forEach(entry => entry.refetch()) } - for (const [name, params] of pendingScoped) { + for (const { context: name, params } of pendingScoped) { if (pending.has(name)) continue const entries = contexts.get(name) if (!entries) continue - const key = JSON.stringify(params) + const key = stableKey(params) const entry = entries.get(key) if (entry) entry.refetch() } pending.clear() - pendingScoped.clear() + pendingScoped.length = 0 scheduled = false }