Rename all sexp directories, files, identifiers, and references to sx. artdag/ excluded (separate media processing DSL). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
7.5 KiB
Markdown
178 lines
7.5 KiB
Markdown
# 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("/<fragment_type>", 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("/<fragment_type>", 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
|