# Sexp Fragment Protocol: Component Defs Between Services ## Context Fragment endpoints return raw sexp source (e.g., `(~blog-nav-wrapper :items ...)`). The consuming service embeds this in its page sexp, which the client evaluates. But blog-specific components like `~blog-nav-wrapper` are only in blog's `_COMPONENT_ENV` — not in market's. So market's `client_components_tag()` never sends them to the client, causing "Unknown component" errors. The fix: transfer component definitions alongside fragments. Services tell the provider what they already have; the provider sends only what's missing. The consuming service registers received defs into its `_COMPONENT_ENV` so they're included in `client_components_tag()` output for the client. ## Approach: Structured Sexp Request/Response Replace the current GET + `X-Fragment-Request` header protocol with POST + sexp body. This aligns with the vision in `docs/sexpr-internal-protocol-first.md`. ### Request format (POST body) ```scheme (fragment-request :type "nav-tree" :params (:app-name "market" :path "/") :components (~blog-nav-wrapper ~blog-nav-item-link ~header-row-sx ...)) ``` `:components` lists component names already in the consumer's `_COMPONENT_ENV`. Provider skips these. ### Response format ```scheme (fragment-response :defs ((defcomp ~blog-nav-wrapper (&key ...) ...) (defcomp ~blog-nav-item-link ...)) :content (~blog-nav-wrapper :items ...)) ``` `:defs` contains only components the consumer doesn't have. `:content` is the fragment sexp (same as current response body). ## Changes ### 1. `shared/infrastructure/fragments.py` — Client side **`fetch_fragment()`**: Switch from GET to POST with sexp body. - Build request body using `sexp_call`: ```python from shared.sexp.helpers import sexp_call, SexpExpr from shared.sexp.jinja_bridge import _COMPONENT_ENV comp_names = [k for k in _COMPONENT_ENV if k.startswith("~")] body = sexp_call("fragment-request", type=fragment_type, params=params or {}, components=SexpExpr("(" + " ".join(comp_names) + ")")) ``` - POST to same URL, body as `text/sexp`, keep `X-Fragment-Request` header for backward compat - Parse response: extract `:defs` and `:content` from the sexp response - Register defs into `_COMPONENT_ENV` via `register_components()` - Return `:content` wrapped as `SexpExpr` **New helper `_parse_fragment_response(text)`**: - `parse()` the response sexp - Extract keyword args (reuse the keyword-extraction pattern from `evaluator.py`) - Return `(defs_source, content_source)` tuple ### 2. `shared/sexp/helpers.py` — Response builder **New `fragment_response(content, request_text)`**: ```python def fragment_response(content: str, request_text: str) -> str: """Build a structured fragment response with missing component defs.""" from .parser import parse, serialize from .types import Keyword, Component from .jinja_bridge import _COMPONENT_ENV # Parse request to get :components list req = parse(request_text) loaded = set() # extract :components keyword value ... # Diff against _COMPONENT_ENV, serialize missing defs defs_parts = [] for key, val in _COMPONENT_ENV.items(): if not isinstance(val, Component): continue if key in loaded or f"~{val.name}" in loaded: continue defs_parts.append(_serialize_defcomp(val)) defs_sexp = "(" + " ".join(defs_parts) + ")" if defs_parts else "nil" return sexp_call("fragment-response", defs=SexpExpr(defs_sexp), content=SexpExpr(content)) ``` ### 3. Fragment endpoints — All services **Generic change in each `bp/fragments/routes.py`**: Update the route handler to accept POST, read sexp body, use `fragment_response()` for the response. The `get_fragment` handler becomes: ```python @bp.route("/", methods=["GET", "POST"]) async def get_fragment(fragment_type: str): handler = _handlers.get(fragment_type) if handler is None: return Response("", status=200, content_type="text/sexp") content = await handler() # Structured sexp protocol (POST with sexp body) request_body = await request.get_data(as_text=True) if request_body and request.content_type == "text/sexp": from shared.sexp.helpers import fragment_response body = fragment_response(content, request_body) return Response(body, status=200, content_type="text/sexp") # Legacy GET fallback return Response(content, status=200, content_type="text/sexp") ``` Since all fragment endpoints follow the identical `_handlers` + `get_fragment` pattern, we can extract this into a shared helper in `fragments.py` or a new `shared/infrastructure/fragment_endpoint.py`. ### 4. Extract shared fragment endpoint helper To avoid touching every service's fragment routes, create a shared blueprint factory: **`shared/infrastructure/fragment_endpoint.py`**: ```python def create_fragment_blueprint(handlers: dict) -> Blueprint: """Create a fragment endpoint blueprint with sexp protocol support.""" bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") @bp.before_request async def _require_fragment_header(): if not request.headers.get(FRAGMENT_HEADER): return Response("", status=403) @bp.route("/", methods=["GET", "POST"]) async def get_fragment(fragment_type: str): handler = handlers.get(fragment_type) if handler is None: return Response("", status=200, content_type="text/sexp") content = await handler() # Sexp protocol: POST with structured request/response if request.method == "POST" and request.content_type == "text/sexp": request_body = await request.get_data(as_text=True) from shared.sexp.helpers import fragment_response body = fragment_response(content, request_body) return Response(body, status=200, content_type="text/sexp") return Response(content, status=200, content_type="text/sexp") return bp ``` Then each service's `register()` just returns `create_fragment_blueprint(_handlers)`. This is a small refactor since they all duplicate the same boilerplate today. ## Files to modify | File | Change | |------|--------| | `shared/infrastructure/fragments.py` | POST sexp body, parse response, register defs | | `shared/sexp/helpers.py` | `fragment_response()` builder, `_serialize_defcomp()` | | `shared/infrastructure/fragment_endpoint.py` | **New** — shared blueprint factory | | `blog/bp/fragments/routes.py` | Use `create_fragment_blueprint` | | `market/bp/fragments/routes.py` | Use `create_fragment_blueprint` | | `events/bp/fragments/routes.py` | Use `create_fragment_blueprint` | | `cart/bp/fragments/routes.py` | Use `create_fragment_blueprint` | | `account/bp/fragments/routes.py` | Use `create_fragment_blueprint` | | `orders/bp/fragments/routes.py` | Use `create_fragment_blueprint` | | `federation/bp/fragments/routes.py` | Use `create_fragment_blueprint` | | `relations/bp/fragments/routes.py` | Use `create_fragment_blueprint` | ## Verification 1. Start blog + market services: `./dev.sh blog market` 2. Load market page — should fetch nav-tree from blog with sexp protocol 3. Check market logs: no "Unknown component" errors 4. Inspect page source: `client_components_tag()` output includes `~blog-nav-wrapper` etc. 5. Cross-domain sx-get navigation (blog → market) works without reload 6. Run sexp tests: `python3 -m pytest shared/sexp/tests/ -x -q` 7. Second page load: `:components` list in request includes blog nav components, response `:defs` is empty