create-caspian-app 1.0.21 → 1.1.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/dist/main.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from casp.components_compiler import transform_components
2
2
  from casp.html_native import (
3
+ _ESCAPED_BRACE_PLACEHOLDER_RE,
3
4
  mask_escaped_brace_entities,
4
5
  parse_fragment,
5
6
  restore_escaped_brace_entities,
@@ -9,6 +10,7 @@ import asyncio
9
10
  import inspect
10
11
  import os
11
12
  import importlib.util
13
+ import re
12
14
  import secrets
13
15
  import traceback
14
16
  import json
@@ -20,8 +22,6 @@ from fastapi import (
20
22
  Request,
21
23
  Response,
22
24
  WebSocket,
23
- WebSocketDisconnect,
24
- status,
25
25
  )
26
26
  from fastapi.responses import (
27
27
  RedirectResponse,
@@ -49,8 +49,7 @@ from casp.rpc import register_rpc_routes, rpc_limiter
49
49
  from casp.layout import (
50
50
  render_with_nested_layouts,
51
51
  compile_template,
52
- load_template_file,
53
- render_page,
52
+ _finalize_page_region,
54
53
  _runtime_injections,
55
54
  _runtime_metadata,
56
55
  )
@@ -743,180 +742,25 @@ class RequestDiagnosticsMiddleware:
743
742
  # ====
744
743
  # WebSocket Routes (optional - gated by caspian.config.json `websocket`)
745
744
  # ====
746
- WEBSOCKET_PATH = "/ws/live"
747
- PUBLIC_WEBSOCKET_PATH = "/ws/public"
748
- WEBSOCKET_IDLE_TIMEOUT_SECONDS = max(
749
- 10,
750
- int(os.getenv('WEBSOCKET_IDLE_TIMEOUT_SECONDS', 120)),
751
- )
752
- MAX_WEBSOCKET_MESSAGE_BYTES = max(
753
- 256,
754
- int(os.getenv('MAX_WEBSOCKET_MESSAGE_BYTES', 4096)),
755
- )
756
- # Messages one connection may send per rolling window. Each accepted message
757
- # fans out to every socket in the pool, so an unthrottled client turns a single
758
- # cheap connection into a broadcast flood against everyone else.
759
- MAX_WEBSOCKET_MESSAGES_PER_WINDOW = max(
760
- 1,
761
- int(os.getenv('MAX_WEBSOCKET_MESSAGES_PER_WINDOW', 20)),
762
- )
763
- WEBSOCKET_RATE_WINDOW_SECONDS = max(
764
- 1,
765
- int(os.getenv('WEBSOCKET_RATE_WINDOW_SECONDS', 10)),
766
- )
767
-
768
-
769
- class WebSocketMessageRate:
770
- """Sliding-window send budget for a single connection.
771
-
772
- Kept per-socket rather than per-IP: the pool is the shared resource being
773
- protected, and one abusive connection should not be able to spend another
774
- client's budget.
775
- """
776
-
777
- def __init__(self, limit: int, window_seconds: int):
778
- self._limit = limit
779
- self._window_seconds = window_seconds
780
- self._timestamps: list[float] = []
781
-
782
- def allow(self) -> bool:
783
- now = time.monotonic()
784
- cutoff = now - self._window_seconds
785
- self._timestamps = [t for t in self._timestamps if t > cutoff]
786
-
787
- if len(self._timestamps) >= self._limit:
788
- return False
789
-
790
- self._timestamps.append(now)
791
- return True
792
-
793
-
794
- async def _run_websocket_channel(
795
- websocket: WebSocket,
796
- manager: Any,
797
- payload: dict[str, Any] | None,
798
- ready_message: str,
799
- ):
800
- if not await manager.connect(websocket):
801
- # Pool at capacity; `connect` already closed with 1013 (try again later).
802
- return
803
-
804
- message_rate = WebSocketMessageRate(
805
- MAX_WEBSOCKET_MESSAGES_PER_WINDOW,
806
- WEBSOCKET_RATE_WINDOW_SECONDS,
807
- )
808
- ready_payload: dict[str, Any] = {
809
- "type": "ready",
810
- "message": ready_message,
811
- }
812
- if payload is not None:
813
- ready_payload["payload"] = payload
814
- await websocket.send_json(ready_payload)
815
-
816
- try:
817
- while True:
818
- try:
819
- raw_message = await asyncio.wait_for(
820
- websocket.receive_text(),
821
- timeout=WEBSOCKET_IDLE_TIMEOUT_SECONDS,
822
- )
823
- except asyncio.TimeoutError:
824
- await websocket.close(code=status.WS_1000_NORMAL_CLOSURE)
825
- return
826
-
827
- if len(raw_message.encode("utf-8")) > MAX_WEBSOCKET_MESSAGE_BYTES:
828
- await websocket.close(code=status.WS_1009_MESSAGE_TOO_BIG)
829
- return
830
-
831
- # Checked before parsing so a flood costs nothing but the read.
832
- if not message_rate.allow():
833
- await websocket.send_json({
834
- "type": "error",
835
- "message": "Too many messages. Slow down.",
836
- })
837
- continue
838
-
839
- try:
840
- message = json.loads(raw_message)
841
- except json.JSONDecodeError:
842
- await websocket.send_json({
843
- "type": "error",
844
- "message": "Messages must be valid JSON.",
845
- })
846
- continue
847
-
848
- if not isinstance(message, dict):
849
- await websocket.send_json({
850
- "type": "error",
851
- "message": "Messages must be JSON objects.",
852
- })
853
- continue
854
-
855
- message_type = str(message.get("type", "message"))
856
- if message_type == "ping":
857
- await websocket.send_json({"type": "pong", "time": int(time.time())})
858
- continue
859
-
860
- text = str(message.get("text", "")).strip()
861
- if not text:
862
- await websocket.send_json({
863
- "type": "error",
864
- "message": "Message text is required.",
865
- })
866
- continue
867
-
868
- outgoing_payload: dict[str, Any] = {
869
- "type": "message",
870
- "text": text[:1000],
871
- "time": int(time.time()),
872
- }
873
- if payload is not None:
874
- outgoing_payload["payload"] = payload
875
- await manager.broadcast_json(outgoing_payload)
876
- except WebSocketDisconnect:
877
- return
878
- finally:
879
- manager.disconnect(websocket)
880
-
881
745
 
882
746
  if cfg.websocket:
883
747
  # Optional, feature-gated module: only generated when websocket is enabled
884
748
  # in caspian.config.json, so suppress the static "module not found" check.
885
- from src.lib.websocket.websocket_security import ( # type: ignore[import-not-found]
886
- authorize_websocket,
887
- public_websocket_connections,
888
- websocket_connections,
749
+ from src.lib.websocket.sockets import ( # type: ignore[import-not-found]
750
+ SOCKET_PATH,
751
+ serve_named_socket,
889
752
  )
890
753
 
891
- # Both endpoints share ONE guard (`authorize_websocket`) that delegates to
892
- # Caspian's `Auth`, and ONE transport loop (`_run_websocket_channel`). They
893
- # differ only by auth policy and broadcast pool. To role-gate a channel,
894
- # pass `roles=[...]`; to add another channel, add an endpoint that calls the
895
- # same guard.
896
-
897
- @app.websocket(WEBSOCKET_PATH)
898
- async def websocket_live_endpoint(websocket: WebSocket):
899
- if await authorize_websocket(websocket, require_auth=True) is None:
900
- return
901
-
902
- await _run_websocket_channel(
903
- websocket,
904
- websocket_connections,
905
- None,
906
- "Private WebSocket connected.",
907
- )
908
-
909
- @app.websocket(PUBLIC_WEBSOCKET_PATH)
910
- async def websocket_public_endpoint(websocket: WebSocket):
911
- if await authorize_websocket(websocket, require_auth=False) is None:
912
- return
913
-
914
- await _run_websocket_channel(
915
- websocket,
916
- public_websocket_connections,
917
- {"guest": True, "scope": "public"},
918
- "Public WebSocket connected.",
919
- )
754
+ # Named sockets: the server half of `pp.socket(...)`. One endpoint for
755
+ # every `@socket()` function; the function is named in the `name` query
756
+ # parameter and the arguments arrive as the connection's first frame.
757
+ # Auth policy is per socket -- `@socket(require_auth=True, allowed_roles=
758
+ # [...])` -- so there are no separate public/private channel endpoints.
759
+ # Origin check, auth delegation, and connection/message limits live in
760
+ # `serve_named_socket` so this stays a pure wiring point.
761
+ @app.websocket(SOCKET_PATH)
762
+ async def websocket_named_socket_endpoint(websocket: WebSocket):
763
+ await serve_named_socket(websocket)
920
764
 
921
765
  # ====
922
766
  # Route Registration
@@ -944,7 +788,6 @@ def load_route_module(file_path: str):
944
788
  assert spec is not None and spec.loader is not None, f"Cannot load spec for {file_path}"
945
789
  module = importlib.util.module_from_spec(spec)
946
790
  spec.loader.exec_module(module)
947
- setattr(module, 'render_page', render_page)
948
791
  _route_module_cache[abs_path] = (mtime_ns, module)
949
792
  _route_signature_cache.pop(abs_path, None)
950
793
  return module
@@ -1057,8 +900,7 @@ def register_routes():
1057
900
  idx = get_files_index()
1058
901
  for route in idx.routes:
1059
902
  base_path = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
1060
- file_name = "index.py" if route.has_py else "index.html"
1061
- full_path = f"{base_path}/{file_name}".replace('//', '/')
903
+ full_path = f"{base_path}/index.py".replace('//', '/')
1062
904
  register_single_route(route.fastapi_rule, full_path)
1063
905
 
1064
906
 
@@ -1087,77 +929,74 @@ def register_single_route(url_pattern: str, file_path: str):
1087
929
 
1088
930
  page_content_source = file_path
1089
931
 
1090
- if file_path.endswith('.py'):
1091
- module = load_route_module(file_path)
1092
- if not hasattr(module, 'page'):
1093
- raise AttributeError(f"Missing 'def page():' in {file_path}")
1094
-
1095
- sig = get_page_signature(file_path, module.page)
1096
- call_kwargs = {}
1097
- call_args = []
1098
-
1099
- if kwargs:
1100
- call_args.append(kwargs)
1101
- if 'request' in sig.parameters:
1102
- call_kwargs['request'] = request
1103
-
1104
- for name, param in sig.parameters.items():
1105
- if name in call_kwargs:
1106
- continue
1107
- if name in ("kwargs",):
1108
- continue
1109
- if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
1110
- continue
1111
- if name in request.query_params:
1112
- call_kwargs[name] = _coerce_query_param(
1113
- request, name, param)
1114
-
1115
- if inspect.iscoroutinefunction(module.page):
1116
- result = await module.page(*call_args, **call_kwargs)
1117
- else:
1118
- result = module.page(*call_args, **call_kwargs)
1119
-
1120
- if isinstance(result, Response):
1121
- return result
1122
-
1123
- if inspect.isasyncgen(result) or inspect.isgenerator(result):
1124
- return SSE(cast("AsyncGenerator | Generator", result))
1125
-
1126
- cache_settings = getattr(module, 'cache_settings', None)
1127
- if cache_settings:
1128
- req_should_cache = cache_settings.enabled
1129
- req_cache_ttl = cache_settings.ttl
1130
-
1131
- if isinstance(result, tuple):
1132
- page_content = result[0]
1133
- content = str(page_content)
1134
- page_content_source = getattr(
1135
- page_content, 'source_path', file_path)
1136
- if len(result) >= 2 and isinstance(result[1], dict):
1137
- page_layout_props = result[1]
1138
- else:
1139
- content = str(result)
1140
- page_content_source = getattr(result, 'source_path', file_path)
1141
-
1142
- dynamic_meta = _runtime_metadata.get()
1143
- static_meta = getattr(module, 'metadata', None)
1144
-
1145
- def extract_meta(obj):
1146
- d = {}
1147
- if not obj:
1148
- return d
1149
- if obj.title:
1150
- d['title'] = obj.title
1151
- if obj.description:
1152
- d['description'] = obj.description
1153
- if obj.extra:
1154
- d.update(obj.extra)
1155
- return d
932
+ module = load_route_module(file_path)
933
+ if not hasattr(module, 'page'):
934
+ raise AttributeError(f"Missing 'def page():' in {file_path}")
935
+
936
+ sig = get_page_signature(file_path, module.page)
937
+ call_kwargs = {}
938
+ call_args = []
939
+
940
+ if kwargs:
941
+ call_args.append(kwargs)
942
+ if 'request' in sig.parameters:
943
+ call_kwargs['request'] = request
944
+
945
+ for name, param in sig.parameters.items():
946
+ if name in call_kwargs:
947
+ continue
948
+ if name in ("kwargs",):
949
+ continue
950
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
951
+ continue
952
+ if name in request.query_params:
953
+ call_kwargs[name] = _coerce_query_param(
954
+ request, name, param)
1156
955
 
1157
- page_metadata.update(extract_meta(static_meta))
1158
- page_metadata.update(extract_meta(dynamic_meta))
956
+ if inspect.iscoroutinefunction(module.page):
957
+ result = await module.page(*call_args, **call_kwargs)
958
+ else:
959
+ result = module.page(*call_args, **call_kwargs)
960
+
961
+ if isinstance(result, Response):
962
+ return result
963
+
964
+ if inspect.isasyncgen(result) or inspect.isgenerator(result):
965
+ return SSE(cast("AsyncGenerator | Generator", result))
966
+
967
+ cache_settings = getattr(module, 'cache_settings', None)
968
+ if cache_settings:
969
+ req_should_cache = cache_settings.enabled
970
+ req_cache_ttl = cache_settings.ttl
971
+
972
+ if isinstance(result, tuple):
973
+ page_content = result[0]
974
+ content = str(page_content)
975
+ page_content_source = getattr(
976
+ page_content, 'source_path', file_path)
977
+ if len(result) >= 2 and isinstance(result[1], dict):
978
+ page_layout_props = result[1]
1159
979
  else:
1160
- content = load_template_file(file_path)
980
+ content = str(result)
981
+ page_content_source = getattr(result, 'source_path', file_path)
982
+
983
+ dynamic_meta = _runtime_metadata.get()
984
+ static_meta = getattr(module, 'metadata', None)
985
+
986
+ def extract_meta(obj):
987
+ d = {}
988
+ if not obj:
989
+ return d
990
+ if obj.title:
991
+ d['title'] = obj.title
992
+ if obj.description:
993
+ d['description'] = obj.description
994
+ if obj.extra:
995
+ d.update(obj.extra)
996
+ return d
997
+
998
+ page_metadata.update(extract_meta(static_meta))
999
+ page_metadata.update(extract_meta(dynamic_meta))
1161
1000
 
1162
1001
  full_context = {**kwargs, "request": request, **page_layout_props}
1163
1002
 
@@ -1198,17 +1037,16 @@ def register_single_route(url_pattern: str, file_path: str):
1198
1037
  '.', '_').replace('[', '').replace(']', '').replace('(', '').replace(')', '')
1199
1038
 
1200
1039
  route_methods = ['GET', 'POST']
1201
- if file_path.endswith('.py'):
1202
- module = load_route_module(file_path)
1203
- declared_route_methods = getattr(module, 'route_methods', None)
1204
- if isinstance(declared_route_methods, (list, tuple)) and declared_route_methods:
1205
- normalized_methods = [
1206
- str(method).strip().upper()
1207
- for method in declared_route_methods
1208
- if str(method).strip()
1209
- ]
1210
- if normalized_methods:
1211
- route_methods = list(dict.fromkeys(normalized_methods))
1040
+ module = load_route_module(file_path)
1041
+ declared_route_methods = getattr(module, 'route_methods', None)
1042
+ if isinstance(declared_route_methods, (list, tuple)) and declared_route_methods:
1043
+ normalized_methods = [
1044
+ str(method).strip().upper()
1045
+ for method in declared_route_methods
1046
+ if str(method).strip()
1047
+ ]
1048
+ if normalized_methods:
1049
+ route_methods = list(dict.fromkeys(normalized_methods))
1212
1050
 
1213
1051
  app.add_api_route(url_pattern, make_handler,
1214
1052
  methods=route_methods, name=endpoint)
@@ -1234,11 +1072,186 @@ def defer_component_roots(html_output: str) -> str:
1234
1072
  if 'pp-component' not in html_output:
1235
1073
  return html_output
1236
1074
 
1075
+ # Fast path: the render pipeline recorded the page subtree's exact
1076
+ # serialized bytes. That string is finished bs4-serializer output -- fully
1077
+ # normalized, so re-parsing it is pure cost -- and it is usually almost the
1078
+ # entire document. Mask it behind a token, run the (now tiny) parse over
1079
+ # the layout shell, and apply the same wrap/entity-protection transforms to
1080
+ # the region string-level. Every mismatch falls back to the full parse.
1081
+ if not _DEFER_FAST_DISABLED:
1082
+ region = _finalize_page_region.get()
1083
+ if (
1084
+ region
1085
+ and len(region) >= _DEFER_REGION_MIN_BYTES
1086
+ and html_output.count(region) == 1
1087
+ ):
1088
+ deferred = _defer_with_verbatim_region(html_output, region)
1089
+ if deferred is not None:
1090
+ return deferred
1091
+
1237
1092
  masked_html, placeholders = mask_escaped_brace_entities(html_output)
1238
1093
  soup = parse_fragment(masked_html)
1239
1094
  return _defer_component_roots_in_soup(soup, placeholders, html_output)
1240
1095
 
1241
1096
 
1097
+ _DEFER_FAST_DISABLED = os.getenv(
1098
+ 'CASP_DEFER_FAST', '').strip().lower() in {'0', 'off', 'false', 'no'}
1099
+ # Below this, masking the region saves less than the two extra scans it costs.
1100
+ _DEFER_REGION_MIN_BYTES = 4096
1101
+
1102
+
1103
+ def _protect_region_brace_entities(value: str) -> str:
1104
+ """String-level equivalent of the in-tree brace-entity protection.
1105
+
1106
+ Inside a deferred ``<template>``, each literal brace entity must gain one
1107
+ extra encoding layer (``&#123;`` -> ``&amp;#123;``) so the browser's parse
1108
+ of the response consumes the outer layer and PulsePoint still sees the
1109
+ entity. The tree path achieves this by restoring masked entities into the
1110
+ parsed template and letting the serializer escape the ``&``; on a verbatim
1111
+ region the same result is a direct substitution.
1112
+ """
1113
+ from casp.html_native import _ESCAPED_BRACE_ENTITY_RE
1114
+
1115
+ return _ESCAPED_BRACE_ENTITY_RE.sub(
1116
+ lambda match: '&amp;' + match.group(0)[1:], value)
1117
+
1118
+
1119
+ def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
1120
+ """Defer pass with the page subtree masked as an opaque token.
1121
+
1122
+ Returns ``None`` whenever the document does not match the shape this fast
1123
+ path understands, in which case the caller re-runs the full-parse pass.
1124
+ """
1125
+ from casp.html_native import _PLACEHOLDER_COUNTER
1126
+
1127
+ # The region's own root boundary key, when its first tag carries one. The
1128
+ # region is serializer output, so '>' cannot appear inside an attribute
1129
+ # value and the first '>' reliably ends the opening tag. Edge whitespace is
1130
+ # common (a template authored as a triple-quoted string), so probe the
1131
+ # stripped core.
1132
+ region_root_key = None
1133
+ region_core = region.strip()
1134
+ open_tag_end = region_core.find('>')
1135
+ if region_core.startswith('<') and open_tag_end > 0:
1136
+ key_match = re.search(
1137
+ r'\spp-component="([^"]+)"', region_core[:open_tag_end + 1])
1138
+ if key_match:
1139
+ region_root_key = key_match.group(1)
1140
+
1141
+ token = f"__PP_DEFER_REGION_{next(_PLACEHOLDER_COUNTER)}__"
1142
+ masked_doc = html_output.replace(region, token, 1)
1143
+
1144
+ masked_html, placeholders = mask_escaped_brace_entities(masked_doc)
1145
+ soup = parse_fragment(masked_html)
1146
+ body = soup.body
1147
+ if body is None:
1148
+ return None
1149
+
1150
+ token_node = None
1151
+ for node in body.descendants:
1152
+ if isinstance(node, NavigableString) and token in node:
1153
+ token_node = node
1154
+ break
1155
+ if token_node is None:
1156
+ # The region did not land in the body (or the parse split the token);
1157
+ # nothing this path can reason about.
1158
+ return None
1159
+
1160
+ # Inside ANY boundary ancestor means the region ends up inside a deferred
1161
+ # template (the outermost one gets wrapped below, or already is one), so
1162
+ # its entities need the protection layer but no wrapper of its own.
1163
+ region_enclosed = any(
1164
+ isinstance(parent, Tag) and parent.has_attr('pp-component')
1165
+ for parent in token_node.parents
1166
+ )
1167
+
1168
+ if not region_enclosed and region_root_key is None and 'pp-component' in region:
1169
+ # Boundaries live inside the region but its root is not one: they
1170
+ # would need wrapping at arbitrary depth, which only the tree pass can
1171
+ # locate.
1172
+ return None
1173
+
1174
+ roots = []
1175
+ stack = [
1176
+ child for child in reversed(body.contents) if isinstance(child, Tag)
1177
+ ]
1178
+ while stack:
1179
+ el = stack.pop()
1180
+ if el.has_attr('pp-component'):
1181
+ if el.name != 'template':
1182
+ roots.append(el)
1183
+ continue
1184
+ stack.extend(
1185
+ child for child in reversed(el.contents) if isinstance(child, Tag)
1186
+ )
1187
+
1188
+ for root in roots:
1189
+ key = root.get('pp-component')
1190
+ if key is None:
1191
+ continue
1192
+ template = soup.new_tag('template')
1193
+ template['pp-component'] = key
1194
+ root.insert_before(template)
1195
+ template.append(root.extract())
1196
+
1197
+ if placeholders:
1198
+ def protect_brace_entities(value: str) -> str:
1199
+ return _ESCAPED_BRACE_PLACEHOLDER_RE.sub(
1200
+ lambda match: placeholders.get(match.group(0), match.group(0)),
1201
+ value,
1202
+ )
1203
+
1204
+ for template in body.select('template[pp-component]'):
1205
+ for node in list(template.descendants):
1206
+ if isinstance(node, NavigableString):
1207
+ original = str(node)
1208
+ if '__PP_ESCAPED_BRACE_' not in original:
1209
+ continue
1210
+ content = protect_brace_entities(original)
1211
+ if content != original:
1212
+ node.replace_with(content)
1213
+ elif isinstance(node, Tag):
1214
+ for name, value in node.attrs.items():
1215
+ if isinstance(value, str):
1216
+ if '__PP_ESCAPED_BRACE_' in value:
1217
+ node.attrs[name] = protect_brace_entities(
1218
+ value)
1219
+ elif isinstance(value, list):
1220
+ for index, item in enumerate(value):
1221
+ item = str(item)
1222
+ if '__PP_ESCAPED_BRACE_' in item:
1223
+ item = protect_brace_entities(item)
1224
+ value[index] = item
1225
+
1226
+ serialized = restore_escaped_brace_entities(
1227
+ serialize_fragment(soup), placeholders)
1228
+ if token not in serialized:
1229
+ return None
1230
+
1231
+ if region_enclosed:
1232
+ region_out = _protect_region_brace_entities(region)
1233
+ elif region_root_key is not None:
1234
+ # The tree path wraps only the root ELEMENT; whitespace at the region's
1235
+ # edges stays outside the template. Mirror that here. An edge comment
1236
+ # would be ambiguous to split off string-level, so leave that shape to
1237
+ # the full parse.
1238
+ core = region_core
1239
+ if not core.startswith('<') or core[1] in ('!', '?') or core.endswith('-->'):
1240
+ return None
1241
+ prefix_len = region.find('<')
1242
+ prefix = region[:prefix_len]
1243
+ suffix = region[prefix_len + len(core):]
1244
+ region_out = (
1245
+ f'{prefix}<template pp-component="{region_root_key}">'
1246
+ f'{_protect_region_brace_entities(core)}'
1247
+ f'</template>{suffix}'
1248
+ )
1249
+ else:
1250
+ region_out = region
1251
+
1252
+ return serialized.replace(token, region_out, 1)
1253
+
1254
+
1242
1255
  def _defer_component_roots_in_soup(
1243
1256
  soup,
1244
1257
  placeholders,
@@ -1256,13 +1269,27 @@ def _defer_component_roots_in_soup(
1256
1269
  if body is None:
1257
1270
  return unchanged()
1258
1271
 
1259
- roots = [
1260
- el for el in body.select('[pp-component]')
1261
- if el.name != 'template'
1262
- and not any(
1263
- parent.has_attr('pp-component') for parent in el.parents
1264
- )
1272
+ # Outermost boundaries only, found in one pruned walk. The previous
1273
+ # ``body.select('[pp-component]')`` matched every nested boundary and then
1274
+ # walked each match's full ancestor chain to discard it -- O(depth) per
1275
+ # boundary on documents whose boundary count is the whole point of the
1276
+ # page. Stopping the descent at the first boundary visits each outermost
1277
+ # subtree root exactly once and never enumerates the nested ones. An
1278
+ # element already inside a ``<template pp-component>`` stays untouched,
1279
+ # matching the ancestor-check semantics.
1280
+ roots = []
1281
+ stack = [
1282
+ child for child in reversed(body.contents) if isinstance(child, Tag)
1265
1283
  ]
1284
+ while stack:
1285
+ el = stack.pop()
1286
+ if el.has_attr('pp-component'):
1287
+ if el.name != 'template':
1288
+ roots.append(el)
1289
+ continue
1290
+ stack.extend(
1291
+ child for child in reversed(el.contents) if isinstance(child, Tag)
1292
+ )
1266
1293
  if not roots:
1267
1294
  return unchanged()
1268
1295
 
@@ -1286,26 +1313,36 @@ def _defer_component_roots_in_soup(
1286
1313
  # Placeholders outside deferred component templates are restored normally
1287
1314
  # after serialization.
1288
1315
  if placeholders:
1316
+ # One compiled-regex pass per string instead of one full ``replace``
1317
+ # scan per placeholder, and nodes that carry no token (the vast
1318
+ # majority) are skipped by a C-level substring probe.
1289
1319
  def protect_brace_entities(value: str) -> str:
1290
- protected = value
1291
- for placeholder, entity in placeholders.items():
1292
- protected = protected.replace(placeholder, entity)
1293
- return protected
1320
+ return _ESCAPED_BRACE_PLACEHOLDER_RE.sub(
1321
+ lambda match: placeholders.get(match.group(0), match.group(0)),
1322
+ value,
1323
+ )
1294
1324
 
1295
1325
  for template in body.select('template[pp-component]'):
1296
1326
  for node in list(template.descendants):
1297
1327
  if isinstance(node, NavigableString):
1298
1328
  original = str(node)
1329
+ if '__PP_ESCAPED_BRACE_' not in original:
1330
+ continue
1299
1331
  content = protect_brace_entities(original)
1300
1332
  if content != original:
1301
1333
  node.replace_with(content)
1302
1334
  elif isinstance(node, Tag):
1303
1335
  for name, value in node.attrs.items():
1304
1336
  if isinstance(value, str):
1305
- node.attrs[name] = protect_brace_entities(value)
1337
+ if '__PP_ESCAPED_BRACE_' in value:
1338
+ node.attrs[name] = protect_brace_entities(
1339
+ value)
1306
1340
  elif isinstance(value, list):
1307
1341
  for index, item in enumerate(value):
1308
- value[index] = protect_brace_entities(str(item))
1342
+ item = str(item)
1343
+ if '__PP_ESCAPED_BRACE_' in item:
1344
+ item = protect_brace_entities(item)
1345
+ value[index] = item
1309
1346
 
1310
1347
  return restore_escaped_brace_entities(serialize_fragment(soup), placeholders)
1311
1348