tigrbl-kernel 0.4.5.dev4__py3-none-any.whl → 0.4.5.dev8__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.
tigrbl_kernel/__init__.py CHANGED
@@ -18,6 +18,7 @@ _LAZY_EXPORTS = {
18
18
  "PackedKernel": "models",
19
19
  "SchemaIn": "models",
20
20
  "SchemaOut": "models",
21
+ "SchemaStored": "models",
21
22
  }
22
23
 
23
24
  _default_kernel = None
@@ -73,6 +74,7 @@ __all__ = [
73
74
  "PackedKernel",
74
75
  "SchemaIn",
75
76
  "SchemaOut",
77
+ "SchemaStored",
76
78
  "build_kernel_plan",
77
79
  "build_packed_kernel",
78
80
  "build_packed_kernel_measurement_view",
tigrbl_kernel/_build.py CHANGED
@@ -91,6 +91,12 @@ _DECODER_TIME = 9
91
91
  _WS_FAST_PATH_BYPASS_ATOM_NAMES = frozenset(
92
92
  {
93
93
  "ingress.ctx_init",
94
+ "ingress.transport_extract",
95
+ "transport.sink_bind",
96
+ "ingress.input_prepare",
97
+ "dispatch.binding_match",
98
+ "dispatch.binding_parse",
99
+ "dispatch.input_normalize",
94
100
  "dispatch.op_resolve",
95
101
  "schema.collect_in",
96
102
  "handler.custom",
@@ -213,6 +219,8 @@ def _is_batch_domain_run(run: Any) -> bool:
213
219
  except ValueError:
214
220
  return False
215
221
  domain = parts[index + 1] if index + 1 < len(parts) else None
222
+ if domain == "transport" and parts[-1] == "sink_bind":
223
+ return False
216
224
  return domain in {"transport", "intent", "batch", "fanout"}
217
225
 
218
226
 
@@ -1156,8 +1164,18 @@ def _pack_kernel_plan(
1156
1164
  )
1157
1165
  alias_name = str(getattr(meta, "alias", "") or "")
1158
1166
  target_name = str(getattr(meta, "target", "") or "")
1167
+ opview = opviews[program_id] if program_id < len(opviews) else None
1159
1168
  compiled_param_eligible = not (
1160
- alias_name.startswith("bulk_") or target_name.startswith("bulk_")
1169
+ alias_name.startswith("bulk_")
1170
+ or target_name.startswith("bulk_")
1171
+ # Selector aliases and URL precedence are validated by wire.validate_in.
1172
+ # The scalar compiled decoder does not implement that contract yet.
1173
+ or any(
1174
+ entry.get("selector")
1175
+ for entry in (
1176
+ getattr(getattr(opview, "schema_in", None), "by_field", {}) or {}
1177
+ ).values()
1178
+ )
1161
1179
  )
1162
1180
  compiled_param_phase_steps = (
1163
1181
  program_compiled_param_phase_steps[program_id]
tigrbl_kernel/_compile.py CHANGED
@@ -95,6 +95,7 @@ def _route_metadata_for_binding(binding: Any) -> dict[str, Any]:
95
95
  metadata["lane"] = lane
96
96
  metadata["inner_framing"] = framing_kind(inner_framing)
97
97
  metadata["inner_framing_spec"] = framing_spec_name(inner_framing)
98
+ metadata["rpc_method"] = getattr(binding, "rpc_method", None)
98
99
  if inner_framing is not None:
99
100
  metadata["inner_framing_kind"] = str(inner_framing)
100
101
  metadata["inner_framing_spec"] = framing_spec_name(inner_framing)
@@ -206,8 +207,6 @@ def _compile_plan(self: Any, app: Any) -> KernelPlan:
206
207
  from tigrbl_core._spec.binding_spec import (
207
208
  HttpJsonRpcBindingSpec,
208
209
  HttpRestBindingSpec,
209
- HttpJsonRpcProtocolBindingSpec,
210
- HttpRestProtocolBindingSpec,
211
210
  HttpStreamBindingSpec,
212
211
  SseBindingSpec,
213
212
  WebSocketProtocolBindingSpec,
@@ -281,7 +280,9 @@ def _compile_plan(self: Any, app: Any) -> KernelPlan:
281
280
  meta_index
282
281
  )
283
282
 
284
- elif isinstance(binding, (HttpJsonRpcBindingSpec, HttpJsonRpcProtocolBindingSpec)):
283
+ elif isinstance(
284
+ binding, (HttpJsonRpcBindingSpec, HttpJsonRpcProtocolBindingSpec)
285
+ ):
285
286
  rpc_path = str(
286
287
  getattr(
287
288
  binding,
@@ -302,8 +303,12 @@ def _compile_plan(self: Any, app: Any) -> KernelPlan:
302
303
  proto_bucket = route_data.setdefault(
303
304
  binding.proto, {"paths": {}, "endpoints": {}}
304
305
  )
305
- path_bucket = proto_bucket.setdefault("paths", {}).setdefault(rpc_path, {})
306
- endpoint_bucket = proto_bucket.setdefault("endpoints", {}).setdefault(rpc_path, {})
306
+ path_bucket = proto_bucket.setdefault("paths", {}).setdefault(
307
+ rpc_path, {}
308
+ )
309
+ endpoint_bucket = proto_bucket.setdefault(
310
+ "endpoints", {}
311
+ ).setdefault(rpc_path, {})
307
312
  proto_bucket[rpc_method] = meta_index
308
313
  row = {
309
314
  "meta_index": meta_index,
@@ -326,6 +331,17 @@ def _compile_plan(self: Any, app: Any) -> KernelPlan:
326
331
  bucket = route_data.setdefault(
327
332
  binding.proto, {"exact": {}, "templated": []}
328
333
  )
334
+ if (
335
+ isinstance(
336
+ binding, (WsBindingSpec, WebSocketProtocolBindingSpec)
337
+ )
338
+ and framing_kind(getattr(binding, "framing", None)) == "jsonrpc"
339
+ and bool(getattr(sp, "expose_method", True))
340
+ ):
341
+ # JSON-RPC method operations are dispatched inside the
342
+ # framework-owned WebSocket session. Only the hidden
343
+ # transport operation may own the socket path itself.
344
+ continue
329
345
  selector = binding.path
330
346
  route_metadata = _route_metadata_for_binding(binding)
331
347
  opkey_to_meta[OpKey(proto=binding.proto, selector=selector)] = (
@@ -345,13 +361,25 @@ def _compile_plan(self: Any, app: Any) -> KernelPlan:
345
361
  }
346
362
  )
347
363
  else:
348
- bucket["exact"][selector] = meta_index
349
- bucket.setdefault("exact_metadata", {})[selector] = {
364
+ route_row = {
350
365
  "path": binding.path,
351
366
  "meta_index": meta_index,
352
367
  "selector": selector,
353
368
  **route_metadata,
354
369
  }
370
+ if binding.proto == "webtransport":
371
+ bucket["exact"].setdefault(selector, meta_index)
372
+ bucket.setdefault("routes", {}).setdefault(
373
+ selector, []
374
+ ).append(route_row)
375
+ bucket.setdefault("exact_metadata", {}).setdefault(
376
+ selector, route_row
377
+ )
378
+ else:
379
+ bucket["exact"][selector] = meta_index
380
+ bucket.setdefault("exact_metadata", {})[selector] = (
381
+ route_row
382
+ )
355
383
 
356
384
  semantic = KernelPlan(
357
385
  proto_indices=route_data,
tigrbl_kernel/models.py CHANGED
@@ -51,6 +51,13 @@ class SchemaOut:
51
51
  expose: Tuple[str, ...]
52
52
 
53
53
 
54
+ @dataclass(frozen=True)
55
+ class SchemaStored:
56
+ fields: Tuple[str, ...]
57
+ by_field: Dict[str, Dict[str, object]]
58
+ required_from_client: Tuple[str, ...] = ()
59
+
60
+
54
61
  @dataclass(frozen=True)
55
62
  class OpView:
56
63
  schema_in: SchemaIn
@@ -59,6 +66,7 @@ class OpView:
59
66
  virtual_producers: Dict[str, Callable[[object, dict], object]]
60
67
  to_stored_transforms: Dict[str, Callable[[object, dict], object]]
61
68
  refresh_hints: Tuple[str, ...]
69
+ schema_stored: SchemaStored | None = None
62
70
 
63
71
 
64
72
  @dataclass(frozen=True, slots=True)
@@ -2,7 +2,41 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any, Dict, Mapping
4
4
 
5
- from .models import OpView, SchemaIn, SchemaOut
5
+ from tigrbl_core.schema.builder.helpers import (
6
+ _normalize_py_type,
7
+ _storage_python_type,
8
+ _spec_python_type,
9
+ _input_selector_entries,
10
+ _input_filter_entries,
11
+ )
12
+
13
+ from .models import OpView, SchemaIn, SchemaOut, SchemaStored
14
+
15
+ _READ_ONLY_TARGETS = frozenset(
16
+ {
17
+ "read",
18
+ "list",
19
+ "count",
20
+ "exists",
21
+ "aggregate",
22
+ "group_by",
23
+ "subscribe",
24
+ "tail",
25
+ "download",
26
+ }
27
+ )
28
+ _NON_PERSISTING_TARGETS = frozenset(
29
+ {
30
+ "delete",
31
+ "clear",
32
+ "bulk_delete",
33
+ "close_stream",
34
+ "close_session",
35
+ }
36
+ )
37
+ _CREATE_LIKE_TARGETS = frozenset({"create", "bulk_create"})
38
+ _UPDATE_LIKE_TARGETS = frozenset({"update", "merge", "bulk_update", "bulk_merge"})
39
+ _REPLACE_LIKE_TARGETS = frozenset({"replace", "bulk_replace"})
6
40
 
7
41
 
8
42
  def _storage_requires_input(storage: Any, alias: str) -> bool:
@@ -22,22 +56,87 @@ def _storage_requires_input(storage: Any, alias: str) -> bool:
22
56
  return not bool(getattr(storage, "nullable", True)) and not has_default
23
57
 
24
58
 
59
+ def _storage_has_server_default(spec: Any, storage: Any) -> bool:
60
+ return bool(
61
+ getattr(storage, "default", None) is not None
62
+ or getattr(storage, "server_default", None) is not None
63
+ or callable(getattr(storage, "default_factory", None))
64
+ or callable(getattr(spec, "default_factory", None))
65
+ )
66
+
67
+
68
+ def _supports_stored_shape(target: str) -> bool:
69
+ lowered = str(target or "").lower()
70
+ return lowered not in _READ_ONLY_TARGETS and lowered not in _NON_PERSISTING_TARGETS
71
+
72
+
73
+ def _is_input_enabled(io: Any, semantic_verbs: tuple[str, ...]) -> bool:
74
+ in_verbs = set(getattr(io, "in_verbs", ()) or ())
75
+ return any(name in in_verbs for name in semantic_verbs)
76
+
77
+
78
+ def _is_paired_enabled(io: Any, semantic_verbs: tuple[str, ...]) -> bool:
79
+ cfg = getattr(io, "_paired", None)
80
+ if cfg is None:
81
+ return False
82
+ paired_verbs = set(getattr(cfg, "verbs", ()) or ())
83
+ return any(name in paired_verbs for name in semantic_verbs)
84
+
85
+
86
+ def _include_in_stored_schema(
87
+ *,
88
+ spec: Any,
89
+ io: Any,
90
+ storage: Any,
91
+ semantic_verbs: tuple[str, ...],
92
+ target: str,
93
+ ) -> bool:
94
+ lowered = str(target or "").lower()
95
+ if storage is None or not _supports_stored_shape(lowered):
96
+ return False
97
+ if _is_input_enabled(io, semantic_verbs) or _is_paired_enabled(io, semantic_verbs):
98
+ return True
99
+ if lowered in _CREATE_LIKE_TARGETS or lowered in _UPDATE_LIKE_TARGETS:
100
+ return True
101
+ if lowered in _REPLACE_LIKE_TARGETS:
102
+ return True
103
+ return False
104
+
105
+
106
+ def _stored_required(storage: Any, spec: Any, target: str, *, derived: bool) -> bool:
107
+ lowered = str(target or "").lower()
108
+ if lowered in _UPDATE_LIKE_TARGETS:
109
+ return False
110
+ if lowered in _NON_PERSISTING_TARGETS or lowered in _READ_ONLY_TARGETS:
111
+ return False
112
+ if derived:
113
+ return True
114
+ return not bool(
115
+ getattr(storage, "nullable", True)
116
+ ) and not _storage_has_server_default(
117
+ spec,
118
+ storage,
119
+ )
120
+
121
+
25
122
  def compile_opview_from_specs(specs: Mapping[str, Any], sp: Any) -> OpView:
26
123
  """Build a basic OpView from collected specs when no app/model is present."""
27
124
  alias = str(getattr(sp, "alias", "") or "")
28
125
  target = str(getattr(sp, "target", alias) or alias)
29
126
  semantic_verbs = tuple(
30
127
  dict.fromkeys(
31
- name
32
- for name in (alias, target)
33
- if isinstance(name, str) and name
128
+ name for name in (alias, target) if isinstance(name, str) and name
34
129
  )
35
130
  )
36
131
 
37
132
  in_fields: list[str] = []
38
133
  out_fields: list[str] = []
134
+ stored_fields: list[str] = []
39
135
  by_field_in: Dict[str, Dict[str, object]] = {}
40
136
  by_field_out: Dict[str, Dict[str, object]] = {}
137
+ by_field_stored: Dict[str, Dict[str, object]] = {}
138
+ required_from_client: list[str] = []
139
+ to_stored_transforms: Dict[str, Any] = {}
41
140
 
42
141
  for name, spec in specs.items():
43
142
  io = getattr(spec, "io", None)
@@ -51,7 +150,7 @@ def compile_opview_from_specs(specs: Mapping[str, Any], sp: Any) -> OpView:
51
150
  meta: Dict[str, object] = {"in_enabled": True}
52
151
  if storage is None:
53
152
  meta["virtual"] = True
54
- py_type = getattr(fs, "py_type", None)
153
+ py_type = _spec_python_type(spec)
55
154
  if py_type is not None and py_type is not Any:
56
155
  meta["py_type"] = py_type
57
156
  constraints = getattr(fs, "constraints", {}) or {}
@@ -72,7 +171,13 @@ def compile_opview_from_specs(specs: Mapping[str, Any], sp: Any) -> OpView:
72
171
  getattr(io, "header_required_in", False)
73
172
  )
74
173
  required = bool(
75
- (fs and any(name in getattr(fs, "required_in", ()) for name in semantic_verbs))
174
+ (
175
+ fs
176
+ and any(
177
+ name in getattr(fs, "required_in", ())
178
+ for name in semantic_verbs
179
+ )
180
+ )
76
181
  or _storage_requires_input(storage, target)
77
182
  )
78
183
  meta["required"] = required
@@ -96,6 +201,64 @@ def compile_opview_from_specs(specs: Mapping[str, Any], sp: Any) -> OpView:
96
201
  meta_out["py_type"] = py_type
97
202
  by_field_out[name] = meta_out
98
203
 
204
+ if _include_in_stored_schema(
205
+ spec=spec,
206
+ io=io,
207
+ storage=storage,
208
+ semantic_verbs=semantic_verbs,
209
+ target=target,
210
+ ):
211
+ stored_fields.append(name)
212
+ input_enabled = _is_input_enabled(io, semantic_verbs)
213
+ paired_enabled = _is_paired_enabled(io, semantic_verbs)
214
+ server_default = _storage_has_server_default(spec, storage)
215
+ py_type = getattr(fs, "py_type", None)
216
+ if py_type is None or py_type is Any:
217
+ py_type = _storage_python_type(getattr(storage, "type_", None))
218
+ else:
219
+ py_type = _normalize_py_type(py_type)
220
+ stored_meta: Dict[str, object] = {
221
+ "nullable": bool(getattr(storage, "nullable", True)),
222
+ "from_client": input_enabled,
223
+ "required_from_client": bool(
224
+ input_enabled and _storage_requires_input(storage, target)
225
+ ),
226
+ "server_default": server_default,
227
+ }
228
+ if py_type is not Any:
229
+ stored_meta["py_type"] = py_type
230
+ constraints = getattr(fs, "constraints", {}) or {}
231
+ if isinstance(constraints, Mapping):
232
+ max_length = constraints.get("max_length")
233
+ if isinstance(max_length, int) and max_length > 0:
234
+ stored_meta["max_length"] = max_length
235
+
236
+ transform = getattr(getattr(storage, "transform", None), "to_stored", None)
237
+ if callable(transform):
238
+ to_stored_transforms[name] = transform
239
+
240
+ derived = paired_enabled or callable(transform)
241
+ stored_meta["derived"] = derived
242
+ stored_meta["required"] = _stored_required(
243
+ storage,
244
+ spec,
245
+ target,
246
+ derived=derived,
247
+ )
248
+ if stored_meta["required_from_client"]:
249
+ required_from_client.append(name)
250
+ by_field_stored[name] = stored_meta
251
+
252
+ for name, meta in {
253
+ **_input_filter_entries(specs, target),
254
+ **_input_selector_entries(specs, target),
255
+ }.items():
256
+ if name not in by_field_in:
257
+ in_fields.append(name)
258
+ by_field_in[name] = meta
259
+ elif meta.get("selector"):
260
+ by_field_in[name] = {**meta, **by_field_in[name], "selector": True}
261
+
99
262
  schema_in = SchemaIn(
100
263
  fields=tuple(sorted(in_fields)),
101
264
  by_field={field: by_field_in.get(field, {}) for field in sorted(in_fields)},
@@ -105,6 +268,18 @@ def compile_opview_from_specs(specs: Mapping[str, Any], sp: Any) -> OpView:
105
268
  by_field={field: by_field_out.get(field, {}) for field in sorted(out_fields)},
106
269
  expose=tuple(sorted(out_fields)),
107
270
  )
271
+ stored_fields_sorted = tuple(sorted(stored_fields))
272
+ schema_stored = (
273
+ SchemaStored(
274
+ fields=stored_fields_sorted,
275
+ by_field={
276
+ field: by_field_stored.get(field, {}) for field in stored_fields_sorted
277
+ },
278
+ required_from_client=tuple(sorted(required_from_client)),
279
+ )
280
+ if stored_fields_sorted
281
+ else None
282
+ )
108
283
  paired_index: Dict[str, Dict[str, object]] = {}
109
284
  for field, col in specs.items():
110
285
  io = getattr(col, "io", None)
@@ -131,6 +306,7 @@ def compile_opview_from_specs(specs: Mapping[str, Any], sp: Any) -> OpView:
131
306
  schema_out=schema_out,
132
307
  paired_index=paired_index,
133
308
  virtual_producers={},
134
- to_stored_transforms={},
309
+ to_stored_transforms=to_stored_transforms,
135
310
  refresh_hints=(),
311
+ schema_stored=schema_stored,
136
312
  )
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Mapping, MutableMapping
4
+ from dataclasses import dataclass
5
+ import json
6
+ from typing import Any
4
7
 
5
8
  from .models import KernelPlan, PackedKernel
6
9
  from .packed_access import (
@@ -15,7 +18,9 @@ ExactRouteVerifyCache = MutableMapping[
15
18
  int, Mapping[int, Mapping[int, tuple[tuple[str, int], ...]]]
16
19
  ]
17
20
  ExactWebSocketCache = MutableMapping[int, Mapping[tuple[str, str], int]]
18
- ExactJsonRpcCache = MutableMapping[int, Mapping[str, Mapping[str, tuple[int, str, str]]]]
21
+ ExactJsonRpcCache = MutableMapping[
22
+ int, Mapping[str, Mapping[str, tuple[int, str, str]]]
23
+ ]
19
24
 
20
25
 
21
26
  def resolve_hot_exact_route_slices(
@@ -32,9 +37,7 @@ def resolve_hot_exact_route_slices(
32
37
  if method_ids is None or path_hashes is None or program_ids is None:
33
38
  cache[packed_id] = {}
34
39
  return {}
35
- if not (
36
- int(method_ids.count) == int(path_hashes.count) == int(program_ids.count)
37
- ):
40
+ if not (int(method_ids.count) == int(path_hashes.count) == int(program_ids.count)):
38
41
  cache[packed_id] = {}
39
42
  return {}
40
43
  directory: dict[int, tuple[int, int]] = {}
@@ -229,11 +232,148 @@ def resolve_hot_exact_jsonrpc_routes(
229
232
  selector = str(entry.get("selector") or f"{endpoint}:{rpc_method}")
230
233
  method_map[rpc_method] = (meta_index, str(proto), selector)
231
234
 
232
- frozen = {endpoint: dict(method_map) for endpoint, method_map in exact_routes.items()}
235
+ frozen = {
236
+ endpoint: dict(method_map) for endpoint, method_map in exact_routes.items()
237
+ }
233
238
  cache[plan_id] = frozen
234
239
  return frozen
235
240
 
236
241
 
242
+ @dataclass(frozen=True, slots=True)
243
+ class WebTransportProgramSelection:
244
+ program_id: int = -1
245
+ path_params: Mapping[str, str] | None = None
246
+ lane: str | None = None
247
+ rpc_envelope: Mapping[str, Any] | None = None
248
+ disposition: str = "no_match"
249
+
250
+
251
+ def _webtransport_route_rows(
252
+ plan: KernelPlan,
253
+ path: str,
254
+ ) -> tuple[tuple[Mapping[str, Any], Mapping[str, str]], ...]:
255
+ bucket = plan.proto_indices.get("webtransport")
256
+ if not isinstance(bucket, Mapping):
257
+ return ()
258
+
259
+ rows: list[tuple[Mapping[str, Any], Mapping[str, str]]] = []
260
+ exact_routes = bucket.get("routes")
261
+ if isinstance(exact_routes, Mapping):
262
+ exact = exact_routes.get(path)
263
+ if isinstance(exact, list):
264
+ rows.extend((row, {}) for row in exact if isinstance(row, Mapping))
265
+
266
+ templated = bucket.get("templated")
267
+ if isinstance(templated, list):
268
+ for row in templated:
269
+ if not isinstance(row, Mapping):
270
+ continue
271
+ pattern = row.get("pattern")
272
+ if pattern is None or not hasattr(pattern, "match"):
273
+ continue
274
+ matched = pattern.match(path)
275
+ if matched is not None:
276
+ rows.append((row, matched.groupdict()))
277
+ return tuple(rows)
278
+
279
+
280
+ def _webtransport_rpc_envelope(message: Mapping[str, Any]) -> Mapping[str, Any] | None:
281
+ payload = message.get("data")
282
+ if payload is None:
283
+ payload = message.get("bytes")
284
+ if isinstance(payload, (bytes, bytearray, memoryview)):
285
+ try:
286
+ payload = json.loads(bytes(payload).decode("utf-8"))
287
+ except (UnicodeDecodeError, json.JSONDecodeError):
288
+ return None
289
+ elif isinstance(payload, str):
290
+ try:
291
+ payload = json.loads(payload)
292
+ except json.JSONDecodeError:
293
+ return None
294
+ return payload if isinstance(payload, Mapping) else None
295
+
296
+
297
+ def resolve_webtransport_program(
298
+ plan: KernelPlan,
299
+ *,
300
+ path: str,
301
+ message: Mapping[str, Any],
302
+ ) -> WebTransportProgramSelection:
303
+ event_type = str(message.get("type") or "")
304
+ rows = _webtransport_route_rows(plan, path)
305
+ if event_type in {
306
+ "webtransport.connect",
307
+ "webtransport.disconnect",
308
+ "transport.emit.complete",
309
+ "transport.emit.failed",
310
+ }:
311
+ return WebTransportProgramSelection(disposition="lifecycle")
312
+
313
+ lane: str | None = None
314
+ rpc_envelope: Mapping[str, Any] | None = None
315
+ rpc_method: str | None = None
316
+ if event_type == "webtransport.stream.receive":
317
+ direction = str(message.get("stream_direction") or "bidi")
318
+ lane = (
319
+ "unidi_client_stream" if direction == "client_to_server" else "bidi_stream"
320
+ )
321
+ if lane == "bidi_stream":
322
+ rpc_envelope = _webtransport_rpc_envelope(message)
323
+ method = rpc_envelope.get("method") if rpc_envelope is not None else None
324
+ rpc_method = method if isinstance(method, str) and method else None
325
+ elif event_type == "webtransport.datagram.receive":
326
+ lane = "datagram"
327
+ else:
328
+ return WebTransportProgramSelection(disposition="unsupported_event")
329
+
330
+ candidates = [
331
+ (row, params)
332
+ for row, params in rows
333
+ if str(row.get("lane") or row.get("profile") or "") == lane
334
+ ]
335
+ if lane == "bidi_stream":
336
+ if rpc_method is None:
337
+ return WebTransportProgramSelection(
338
+ lane=lane,
339
+ rpc_envelope=rpc_envelope,
340
+ disposition="invalid_jsonrpc",
341
+ )
342
+ candidates = [
343
+ (row, params)
344
+ for row, params in candidates
345
+ if row.get("rpc_method") == rpc_method
346
+ ]
347
+ if not candidates:
348
+ return WebTransportProgramSelection(
349
+ lane=lane,
350
+ rpc_envelope=rpc_envelope,
351
+ disposition="method_not_found",
352
+ )
353
+
354
+ if len(candidates) != 1:
355
+ return WebTransportProgramSelection(
356
+ lane=lane,
357
+ rpc_envelope=rpc_envelope,
358
+ disposition="ambiguous" if candidates else "no_match",
359
+ )
360
+ row, path_params = candidates[0]
361
+ program_id = row.get("meta_index")
362
+ if not isinstance(program_id, int):
363
+ return WebTransportProgramSelection(
364
+ lane=lane,
365
+ rpc_envelope=rpc_envelope,
366
+ disposition="invalid_plan",
367
+ )
368
+ return WebTransportProgramSelection(
369
+ program_id=program_id,
370
+ path_params=dict(path_params),
371
+ lane=lane,
372
+ rpc_envelope=rpc_envelope,
373
+ disposition="dispatch",
374
+ )
375
+
376
+
237
377
  def normalize_jsonrpc_mount_path(path: str) -> str:
238
378
  normalized = str(path or "").rstrip("/")
239
379
  return normalized or "/"
@@ -1,8 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Mapping
3
+ from collections.abc import Iterable, Mapping
4
4
  from typing import Any
5
5
 
6
+ from tigrbl_atoms.atoms.framing import websocket_jsonrpc as _jsonrpc_framing
7
+ from tigrbl_atoms.atoms.transport import websocket as _websocket_transport
8
+ from tigrbl_typing.status.converters import http_exc_to_rpc
9
+ from tigrbl_typing.status.exceptions import HTTPException
10
+
6
11
  ANCHORS: tuple[str, ...] = (
7
12
  "transport.accept",
8
13
  "transport.receive",
@@ -13,22 +18,243 @@ ANCHORS: tuple[str, ...] = (
13
18
  "transport.close",
14
19
  )
15
20
 
21
+ SESSION_LIFECYCLE_PHASES: tuple[str, ...] = (
22
+ "SESSION_OPEN",
23
+ "POST_SESSION_OPEN",
24
+ "MESSAGE_RECEIVE",
25
+ "INGRESS_PARSE",
26
+ "INGRESS_DISPATCH",
27
+ "PRE_TX_BEGIN",
28
+ "START_TX",
29
+ "PRE_HANDLER",
30
+ "HANDLER",
31
+ "POST_HANDLER",
32
+ "PRE_COMMIT",
33
+ "TX_COMMIT",
34
+ "POST_COMMIT",
35
+ "EGRESS_SHAPE",
36
+ "EGRESS_FINALIZE",
37
+ "POST_RESPONSE",
38
+ "PRE_SESSION_CLOSE",
39
+ "SESSION_CLOSE",
40
+ "POST_SESSION_CLOSE",
41
+ )
42
+
43
+ SESSION_STRUCTURAL_PHASES: tuple[str, ...] = (
44
+ "SESSION_OPEN",
45
+ "MESSAGE_RECEIVE",
46
+ "SESSION_CLOSE",
47
+ )
48
+
49
+ SESSION_HOOK_PHASES: tuple[str, ...] = (
50
+ "POST_SESSION_OPEN",
51
+ "PRE_SESSION_CLOSE",
52
+ "POST_SESSION_CLOSE",
53
+ )
54
+
55
+ CORE_REALTIME_ATOMS: tuple[str, ...] = (
56
+ "transport.accept",
57
+ "transport.receive",
58
+ "transport.emit",
59
+ "transport.close",
60
+ "framing.decode",
61
+ "framing.encode",
62
+ "dispatch.binding.match",
63
+ "dispatch.binding.parse",
64
+ "subscription.register",
65
+ "subscription.unregister",
66
+ "publish.prepare",
67
+ "publish.enqueue",
68
+ "publish.fanout",
69
+ )
70
+
71
+ ATOM_PHASE_PLACEMENT: dict[str, str] = {
72
+ "transport.accept": "SESSION_OPEN",
73
+ "transport.receive": "MESSAGE_RECEIVE",
74
+ "dispatch.binding.match": "INGRESS_DISPATCH",
75
+ "dispatch.binding.parse": "INGRESS_DISPATCH",
76
+ "framing.decode": "INGRESS_PARSE",
77
+ "subscription.register": "POST_COMMIT",
78
+ "subscription.unregister": "PRE_SESSION_CLOSE",
79
+ "publish.prepare": "POST_COMMIT",
80
+ "publish.enqueue": "POST_COMMIT",
81
+ "publish.fanout": "EGRESS_FINALIZE",
82
+ "framing.encode": "EGRESS_FINALIZE",
83
+ "transport.emit": "EGRESS_FINALIZE",
84
+ "transport.close": "SESSION_CLOSE",
85
+ }
86
+
87
+ PUBLISH_POLICY: dict[str, object] = {
88
+ "automatic_for_oltp": False,
89
+ "publish_only_op": True,
90
+ "create_only_publishes": False,
91
+ "create_and_publish_requires_effect": True,
92
+ "rollback_suppresses_publish": True,
93
+ "placement": {
94
+ "HANDLER": ("publish.intent",),
95
+ "POST_COMMIT": ("publish.prepare", "publish.enqueue"),
96
+ "EGRESS_FINALIZE": ("publish.fanout", "framing.encode", "transport.emit"),
97
+ "POST_RESPONSE": ("cleanup.metrics",),
98
+ },
99
+ }
100
+
101
+ SUBSCRIBE_POLICY: dict[str, object] = {
102
+ "operation_shape": "finite_request_response",
103
+ "ack_framing": "jsonrpc",
104
+ "owns_receive_loop": False,
105
+ "registration": {"atom": "subscription.register", "phase": "POST_COMMIT"},
106
+ "cleanup": {"atom": "subscription.unregister", "phase": "PRE_SESSION_CLOSE"},
107
+ }
108
+
109
+
110
+ def _binding_framing(binding: Mapping[str, Any]) -> str:
111
+ framing = (
112
+ binding.get("framing", "text")
113
+ if isinstance(binding, Mapping)
114
+ else getattr(binding, "framing", "text")
115
+ )
116
+ if isinstance(framing, str):
117
+ return framing.lower()
118
+ name = type(framing).__name__.lower()
119
+ if "jsonrpc" in name:
120
+ return "jsonrpc"
121
+ if "ndjson" in name:
122
+ return "ndjson"
123
+ if "text" in name:
124
+ return "text"
125
+ return name
126
+
127
+
128
+ def _binding_path(binding: Mapping[str, Any]) -> str | None:
129
+ if isinstance(binding, Mapping):
130
+ path = binding.get("path") or binding.get("connect_path")
131
+ else:
132
+ path = getattr(binding, "path", None) or getattr(
133
+ binding,
134
+ "connect_path",
135
+ None,
136
+ )
137
+ return str(path) if path is not None else None
138
+
139
+
140
+ def _op_bindings(op: Any) -> tuple[Any, ...]:
141
+ bindings = (
142
+ op.get("bindings", ())
143
+ if isinstance(op, Mapping)
144
+ else getattr(op, "bindings", ())
145
+ )
146
+ return tuple(bindings or ())
147
+
148
+
149
+ def _op_alias(op: Any) -> str:
150
+ alias = op.get("alias") if isinstance(op, Mapping) else getattr(op, "alias", None)
151
+ if not isinstance(alias, str) or not alias:
152
+ raise ValueError("websocket jsonrpc op requires alias")
153
+ return alias
154
+
155
+
156
+ def _op_method(op: Any) -> str:
157
+ if isinstance(op, Mapping):
158
+ method = (
159
+ op.get("op_method")
160
+ or op.get("op_id")
161
+ or op.get("identity")
162
+ or op.get("canonical_identity")
163
+ or op.get("name")
164
+ )
165
+ else:
166
+ method = (
167
+ getattr(op, "op_method", None)
168
+ or getattr(op, "op_id", None)
169
+ or getattr(op, "identity", None)
170
+ or getattr(op, "canonical_identity", None)
171
+ or getattr(op, "name", None)
172
+ )
173
+ if method is None:
174
+ table = getattr(op, "table", None)
175
+ table_name = getattr(table, "__name__", None)
176
+ alias = getattr(op, "alias", None)
177
+ if table_name and alias:
178
+ method = f"{table_name}.{alias}"
179
+ elif alias:
180
+ method = str(alias)
181
+ if not isinstance(method, str) or not method:
182
+ raise ValueError("websocket jsonrpc op requires canonical op identity")
183
+ return method
184
+
185
+
186
+ def _table_identity(table: Any) -> str:
187
+ name = getattr(table, "name", None)
188
+ if isinstance(name, str) and name:
189
+ return name
190
+ model = getattr(table, "model", None)
191
+ model_name = getattr(model, "__name__", None)
192
+ if isinstance(model_name, str) and model_name:
193
+ return model_name
194
+ model_ref = getattr(table, "model_ref", None)
195
+ if isinstance(model_ref, str) and model_ref:
196
+ return model_ref.rsplit(":", 1)[-1].rsplit(".", 1)[-1]
197
+ resource = getattr(table, "resource", None)
198
+ if isinstance(resource, str) and resource:
199
+ return resource
200
+ raise ValueError("websocket jsonrpc table requires name, model, or model_ref")
201
+
202
+
203
+ def _binding_proto(binding: Any) -> str:
204
+ if isinstance(binding, Mapping):
205
+ proto = binding.get("proto") or binding.get("protocol") or "ws"
206
+ else:
207
+ proto = getattr(binding, "proto", None) or getattr(binding, "protocol", None)
208
+ return str(proto or "ws").lower()
209
+
210
+
211
+ def _register_jsonrpc_op(
212
+ endpoint: dict[str, Any],
213
+ *,
214
+ path: str,
215
+ method: str,
216
+ op: Any,
217
+ ) -> None:
218
+ for binding in _op_bindings(op):
219
+ if _binding_path(binding) != path:
220
+ continue
221
+ if _binding_proto(binding) not in {"ws", "wss", "websocket"}:
222
+ continue
223
+ if _binding_framing(binding) != "jsonrpc":
224
+ continue
225
+ if method in endpoint:
226
+ raise ValueError(
227
+ f"duplicate websocket jsonrpc registration for {path} {method}"
228
+ )
229
+ endpoint[method] = op
230
+
16
231
 
17
232
  def compile_websocket_chain(binding: Mapping[str, Any]) -> dict[str, object]:
18
233
  scheme = binding.get("scheme", "ws")
19
234
  if scheme not in {"ws", "wss"}:
20
235
  raise ValueError("websocket scheme must be ws or wss")
236
+ framing = _binding_framing(binding)
21
237
  tls = dict((binding.get("extensions") or {}).get("tls") or {})
22
238
  return {
23
239
  "binding": "wss" if scheme == "wss" else "websocket",
24
240
  "exchange": "bidirectional_stream",
25
241
  "family": "message",
26
242
  "path": binding.get("path"),
243
+ "framing": framing,
27
244
  "anchors": ANCHORS,
245
+ "lifecycle_phases": SESSION_LIFECYCLE_PHASES,
246
+ "structural_phases": SESSION_STRUCTURAL_PHASES,
247
+ "hook_phases": SESSION_HOOK_PHASES,
248
+ "realtime_atoms": CORE_REALTIME_ATOMS,
249
+ "atom_phase_placement": ATOM_PHASE_PLACEMENT,
250
+ "subscribe_policy": SUBSCRIBE_POLICY,
251
+ "publish_policy": PUBLISH_POLICY,
28
252
  "loop_region": {
29
253
  "loop_id": "websocket.receive",
30
254
  "role": "message",
31
255
  "break_conditions": ("websocket.disconnect",),
256
+ "enter_phase": "MESSAGE_RECEIVE",
257
+ "exit_target": "PRE_SESSION_CLOSE",
32
258
  },
33
259
  "close": {"code": 1000, "reason": ""},
34
260
  "scope_metadata": {
@@ -38,4 +264,270 @@ def compile_websocket_chain(binding: Mapping[str, Any]) -> dict[str, object]:
38
264
  }
39
265
 
40
266
 
41
- __all__ = ["compile_websocket_chain"]
267
+ def compile_websocket_jsonrpc_dispatch_index(
268
+ *,
269
+ path: str,
270
+ ops: Iterable[Any] = (),
271
+ tables: Iterable[Any] = (),
272
+ ) -> dict[str, object]:
273
+ endpoint: dict[str, Any] = {}
274
+ for table in tables:
275
+ table_name = _table_identity(table)
276
+ for op in tuple(getattr(table, "ops", ()) or ()):
277
+ _register_jsonrpc_op(
278
+ endpoint,
279
+ path=path,
280
+ method=f"{table_name}.{_op_alias(op)}",
281
+ op=op,
282
+ )
283
+ for op in ops:
284
+ _register_jsonrpc_op(
285
+ endpoint,
286
+ path=path,
287
+ method=_op_method(op),
288
+ op=op,
289
+ )
290
+ return {
291
+ "ws": {"exact": {path: "session_program"}},
292
+ "ws.jsonrpc": {"endpoints": {path: endpoint}},
293
+ }
294
+
295
+
296
+ def _jsonrpc_error(
297
+ code: int,
298
+ message: str,
299
+ request_id: Any,
300
+ *,
301
+ data: Any | None = None,
302
+ ) -> dict[str, Any]:
303
+ error: dict[str, Any] = {"code": code, "message": message}
304
+ if data is not None:
305
+ error["data"] = data
306
+ return {"jsonrpc": "2.0", "error": error, "id": request_id}
307
+
308
+
309
+ def _jsonrpc_result(result: Any, request_id: Any) -> dict[str, Any]:
310
+ return {"jsonrpc": "2.0", "result": result, "id": request_id}
311
+
312
+
313
+ def _ctx_get(ctx: Any, key: str, default: Any = None) -> Any:
314
+ getter = getattr(ctx, "get", None)
315
+ if callable(getter):
316
+ try:
317
+ return getter(key, default)
318
+ except Exception:
319
+ pass
320
+ return getattr(ctx, key, default)
321
+
322
+
323
+ def _session_rpc_context(
324
+ payload: Mapping[str, Any],
325
+ request_id: Any,
326
+ session_ctx: Any,
327
+ ) -> dict[str, Any]:
328
+ rpc_ctx: dict[str, Any] = {
329
+ "jsonrpc": dict(payload),
330
+ "jsonrpc_request_id": request_id,
331
+ }
332
+ if session_ctx is None:
333
+ return rpc_ctx
334
+ temp = _ctx_get(session_ctx, "temp", {})
335
+ if isinstance(temp, Mapping):
336
+ realtime = temp.get("realtime")
337
+ websocket = temp.get("websocket")
338
+ if isinstance(realtime, Mapping):
339
+ rpc_ctx["realtime"] = realtime
340
+ if isinstance(websocket, Mapping):
341
+ rpc_ctx["websocket"] = websocket
342
+ channel = _ctx_get(session_ctx, "channel")
343
+ if channel is not None:
344
+ rpc_ctx["channel"] = channel
345
+ return rpc_ctx
346
+
347
+
348
+ async def _maybe_await(value: Any) -> Any:
349
+ if hasattr(value, "__await__"):
350
+ return await value
351
+ return value
352
+
353
+
354
+ async def _cleanup_realtime_session(ctx: Any) -> None:
355
+ temp = _ctx_get(ctx, "temp", {})
356
+ if not isinstance(temp, Mapping):
357
+ return
358
+ realtime = temp.get("realtime")
359
+ if not isinstance(realtime, Mapping):
360
+ return
361
+ broker = realtime.get("broker")
362
+ session_id = realtime.get("session_id")
363
+ unregister = getattr(broker, "unsubscribe_session", None)
364
+ if callable(unregister) and session_id is not None:
365
+ await _maybe_await(unregister(str(session_id)))
366
+
367
+
368
+ def _resolve_rpc_target(router: Any, method: str) -> tuple[Any, str] | None:
369
+ table_name, sep, alias = method.partition(".")
370
+ if not sep or not table_name or not alias:
371
+ return None
372
+ tables = getattr(router, "tables", None)
373
+ if isinstance(tables, Mapping):
374
+ model = tables.get(table_name)
375
+ if isinstance(model, type):
376
+ return model, alias
377
+ for candidate in tables.values():
378
+ if (
379
+ isinstance(candidate, type)
380
+ and getattr(candidate, "__name__", None) == table_name
381
+ ):
382
+ return candidate, alias
383
+ return table_name, alias
384
+
385
+
386
+ def _op_tx_scope(model: Any, alias: str) -> str:
387
+ ops = getattr(getattr(model, "ops", None), "all", ())
388
+ for spec in tuple(ops or ()):
389
+ if getattr(spec, "alias", None) == alias:
390
+ return str(getattr(spec, "tx_scope", "inherit") or "inherit")
391
+ return "inherit"
392
+
393
+
394
+ async def _dispatch_jsonrpc_payload(
395
+ router: Any,
396
+ payload: Any,
397
+ *,
398
+ session_ctx: Any = None,
399
+ ) -> Any:
400
+ if not isinstance(payload, Mapping) or payload.get("jsonrpc") != "2.0":
401
+ return _jsonrpc_error(-32600, "Invalid Request", None)
402
+ request_id = payload.get("id")
403
+ method = payload.get("method")
404
+ if not isinstance(method, str) or not method:
405
+ return _jsonrpc_error(-32601, "Method not found", request_id)
406
+ target = _resolve_rpc_target(router, method)
407
+ if target is None:
408
+ return _jsonrpc_error(-32601, "Method not found", request_id)
409
+ params = payload.get("params", {})
410
+ if params is None:
411
+ params = {}
412
+ if not isinstance(params, (dict, list, tuple)):
413
+ return _jsonrpc_error(-32602, "Invalid params", request_id)
414
+ model, alias = target
415
+ rpc_call = getattr(router, "rpc_call", None)
416
+ if not callable(rpc_call):
417
+ return _jsonrpc_error(-32603, "Internal error", request_id)
418
+ kwargs: dict[str, Any] = {
419
+ "ctx": _session_rpc_context(payload, request_id, session_ctx)
420
+ }
421
+ if _op_tx_scope(model, alias) == "none":
422
+ kwargs["db"] = {}
423
+ try:
424
+ result = await rpc_call(model, alias, params, **kwargs)
425
+ except AttributeError:
426
+ return _jsonrpc_error(-32601, "Method not found", request_id)
427
+ except HTTPException as exc:
428
+ code, message, data = http_exc_to_rpc(exc)
429
+ return _jsonrpc_error(code, message, request_id, data=data)
430
+ except Exception as exc:
431
+ return _jsonrpc_error(
432
+ -32603,
433
+ "Internal error",
434
+ request_id,
435
+ data={"detail": str(exc)},
436
+ )
437
+ if "id" not in payload:
438
+ return None
439
+ return _jsonrpc_result(result, request_id)
440
+
441
+
442
+ async def _dispatch_jsonrpc_decoded(
443
+ router: Any,
444
+ payload: Any,
445
+ *,
446
+ session_ctx: Any = None,
447
+ ) -> Any:
448
+ if isinstance(payload, list):
449
+ responses = []
450
+ for item in payload:
451
+ response = await _dispatch_jsonrpc_payload(
452
+ router,
453
+ item,
454
+ session_ctx=session_ctx,
455
+ )
456
+ if response is not None:
457
+ responses.append(response)
458
+ return responses or None
459
+ return await _dispatch_jsonrpc_payload(router, payload, session_ctx=session_ctx)
460
+
461
+
462
+ def build_websocket_jsonrpc_session_handler(
463
+ router: Any,
464
+ *,
465
+ subprotocol: str = "jsonrpc",
466
+ realtime_broker: Any = None,
467
+ ) -> Any:
468
+ """Build a kernel-owned WebSocket JSON-RPC session handler step."""
469
+
470
+ async def _websocket_jsonrpc_session(ctx: Any) -> None:
471
+ await _websocket_transport.accept(ctx, subprotocol=subprotocol)
472
+ channel = ctx.get("channel")
473
+ channel_state = getattr(channel, "state", None)
474
+ if isinstance(channel_state, dict):
475
+ channel_state["transport_sent"] = True
476
+ temp = ctx.get("temp", {})
477
+ if isinstance(temp, dict):
478
+ ws_state = temp.setdefault("websocket", {})
479
+ realtime_state = temp.setdefault("realtime", {})
480
+ session_id = ws_state.setdefault("session_id", f"ws:{id(ctx)}")
481
+ realtime_state.setdefault("session_id", session_id)
482
+ realtime_state.setdefault("sink", ctx.get("channel"))
483
+ if realtime_broker is not None:
484
+ realtime_state.setdefault("broker", realtime_broker)
485
+ try:
486
+ while True:
487
+ await _websocket_transport.receive(ctx)
488
+ ws_state = ctx.get("temp", {}).setdefault("websocket", {})
489
+ if isinstance(ws_state, dict) and ws_state.get("disconnected"):
490
+ break
491
+ try:
492
+ decoded = await _jsonrpc_framing.decode(ctx)
493
+ response = await _dispatch_jsonrpc_decoded(
494
+ router,
495
+ decoded,
496
+ session_ctx=ctx,
497
+ )
498
+ except ValueError:
499
+ response = _jsonrpc_error(-32700, "Parse error", None)
500
+ if response is None:
501
+ continue
502
+ ws_state["response"] = response
503
+ await _jsonrpc_framing.encode(ctx)
504
+ await _websocket_transport.emit(ctx)
505
+ finally:
506
+ await _cleanup_realtime_session(ctx)
507
+ await _websocket_transport.close(ctx)
508
+
509
+ setattr(
510
+ _websocket_jsonrpc_session,
511
+ "__tigrbl_label",
512
+ "kernel:websocket:jsonrpc:session",
513
+ )
514
+ setattr(
515
+ _websocket_jsonrpc_session,
516
+ "__tigrbl_atom_name__",
517
+ "websocket.jsonrpc.session",
518
+ )
519
+ return _websocket_jsonrpc_session
520
+
521
+
522
+ __all__ = [
523
+ "ATOM_PHASE_PLACEMENT",
524
+ "CORE_REALTIME_ATOMS",
525
+ "PUBLISH_POLICY",
526
+ "SESSION_HOOK_PHASES",
527
+ "SESSION_LIFECYCLE_PHASES",
528
+ "SESSION_STRUCTURAL_PHASES",
529
+ "SUBSCRIBE_POLICY",
530
+ "build_websocket_jsonrpc_session_handler",
531
+ "compile_websocket_chain",
532
+ "compile_websocket_jsonrpc_dispatch_index",
533
+ ]
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+ import struct
8
+ import json
9
+ import inspect
10
+
11
+ from tigrbl_atoms.atoms.framing import WebTransportRecordDecoder
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class WebTransportSessionPlan:
16
+ """Session-scoped carrier plan for framed control records.
17
+
18
+ The plan owns record decoder state across ASGI receive calls. One inbound
19
+ carrier chunk may yield zero, one, or several operation-dispatch events;
20
+ completing any one operation does not complete the bidirectional stream.
21
+ """
22
+
23
+ pending: deque[dict[str, Any]] = field(default_factory=deque)
24
+ control_decoders: dict[str, WebTransportRecordDecoder] = field(
25
+ default_factory=dict
26
+ )
27
+
28
+ def has_pending(self) -> bool:
29
+ return bool(self.pending)
30
+
31
+ def pop_pending(self) -> dict[str, Any]:
32
+ return self.pending.popleft()
33
+
34
+ def project_receive(self, message: Mapping[str, Any]) -> None:
35
+ event = dict(message)
36
+ if (
37
+ event.get("type") != "webtransport.stream.receive"
38
+ or str(event.get("stream_direction") or "bidi") != "bidi"
39
+ ):
40
+ self.pending.append(event)
41
+ return
42
+ data = event.get("data")
43
+ if data is None:
44
+ data = event.get("bytes")
45
+ if not isinstance(data, (bytes, bytearray, memoryview)):
46
+ self.pending.append(event)
47
+ return
48
+ raw = bytes(data)
49
+ stream_id = str(event.get("stream_id"))
50
+ decoder = self.control_decoders.get(stream_id)
51
+ if decoder is None and raw.lstrip().startswith((b"{", b"[")):
52
+ # Compatibility for unframed peers while declared framed bindings
53
+ # migrate. New peers must use uint32 big-endian record boundaries.
54
+ self.pending.append(event)
55
+ return
56
+ if decoder is None and len(raw) >= 4:
57
+ declared_size = struct.unpack_from("!I", raw, 0)[0]
58
+ if declared_size > 3 * 1024 * 1024:
59
+ self.pending.append(event)
60
+ return
61
+ if decoder is None:
62
+ decoder = WebTransportRecordDecoder()
63
+ self.control_decoders[stream_id] = decoder
64
+ more = event.get("more")
65
+ records = decoder.feed(raw, final=more is False)
66
+ if more is False:
67
+ self.control_decoders.pop(stream_id, None)
68
+ for record in records:
69
+ projected = dict(event)
70
+ projected.pop("bytes", None)
71
+ projected["data"] = json.dumps(
72
+ record, separators=(",", ":")
73
+ ).encode("utf-8")
74
+ projected["framing"] = "jsonrpc"
75
+ projected["jsonrpc_complete"] = True
76
+ self.pending.append(projected)
77
+
78
+ def close(self) -> None:
79
+ self.pending.clear()
80
+ self.control_decoders.clear()
81
+
82
+ async def cleanup(self, *callbacks: Any) -> None:
83
+ self.close()
84
+ for callback in callbacks:
85
+ if not callable(callback):
86
+ continue
87
+ result = callback()
88
+ if inspect.isawaitable(result):
89
+ await result
90
+
91
+
92
+ __all__ = ["WebTransportSessionPlan"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tigrbl-kernel
3
- Version: 0.4.5.dev4
3
+ Version: 0.4.5.dev8
4
4
  Summary: Kernel orchestration for composing Tigrbl runtime plans, bindings, operation dispatch, and optimized ASGI execution.
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -225,14 +225,14 @@ Classifier: Programming Language :: Python :: 3.14
225
225
  Classifier: Topic :: Internet :: WWW/HTTP
226
226
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
227
227
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
228
- Requires-Dist: tigrbl-atoms
229
- Requires-Dist: tigrbl-core
228
+ Requires-Dist: tigrbl-atoms (>=0.4.5.dev5)
229
+ Requires-Dist: tigrbl-core (>=0.4.5.dev6)
230
230
  Requires-Dist: tigrbl-typing
231
231
  Project-URL: Discord, https://discord.gg/K4YTAPapjR
232
232
  Project-URL: Homepage, https://github.com/tigrbl/tigrbl
233
233
  Project-URL: Issues, https://github.com/tigrbl/tigrbl/issues
234
234
  Project-URL: Organization, https://github.com/tigrbl
235
- Project-URL: Repository, https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_kernel
235
+ Project-URL: Repository, https://github.com/tigrbl/tigrbl/tree/master/pkgs/45_kernel/tigrbl_kernel
236
236
  Description-Content-Type: text/markdown
237
237
 
238
238
  <div align="center">
@@ -242,7 +242,7 @@ Description-Content-Type: text/markdown
242
242
  <a href="https://pypi.org/project/tigrbl-kernel/"><img src="https://img.shields.io/pypi/v/tigrbl-kernel?label=PyPI" alt="PyPI version for tigrbl-kernel"/></a>
243
243
  <a href="https://pypi.org/project/tigrbl-kernel/"><img src="https://static.pepy.tech/badge/tigrbl-kernel" alt="Downloads for tigrbl-kernel"/></a>
244
244
  <a href="https://discord.gg/K4YTAPapjR"><img src="https://img.shields.io/badge/Discord-Join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord community for tigrbl-kernel"/></a>
245
- <a href="https://github.com/tigrbl/tigrbl/blob/master/pkgs/core/tigrbl_kernel/README.md"><img src="https://hits.sh/github.com/tigrbl/tigrbl/blob/master/pkgs/core/tigrbl_kernel/README.md.svg?label=hits" alt="Repository hits for tigrbl-kernel README"/></a>
245
+ <a href="https://github.com/tigrbl/tigrbl/blob/master/pkgs/45_kernel/tigrbl_kernel/README.md"><img src="https://hits.sh/github.com/tigrbl/tigrbl/blob/master/pkgs/45_kernel/tigrbl_kernel/README.md.svg?label=hits" alt="Repository hits for tigrbl-kernel README"/></a>
246
246
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-525252" alt="Apache 2.0 license"/></a>
247
247
  <a href="pyproject.toml"><img src="https://img.shields.io/badge/python-3.10%2C%203.11%2C%203.12%2C%203.13%2C%203.14-3776ab" alt="Python versions 3.10 | 3.11 | 3.12 | 3.13 | 3.14 for tigrbl-kernel"/></a>
248
248
  <a href="https://github.com/tigrbl/tigrbl/blob/master/docs/README.md"><img src="https://img.shields.io/badge/workspace-core-1f6feb" alt="Workspace group for tigrbl-kernel"/></a>
@@ -266,7 +266,7 @@ Framework maintainers, extension authors, and advanced users composing Tigrbl fr
266
266
 
267
267
  ## Where does tigrbl-kernel fit?
268
268
 
269
- `tigrbl-kernel` lives at `pkgs/core/tigrbl_kernel` and serves a focused layer in the split Tigrbl framework.
269
+ `tigrbl-kernel` lives at `pkgs/45_kernel/tigrbl_kernel` and serves a focused layer in the split Tigrbl framework.
270
270
 
271
271
  ## How does tigrbl-kernel work?
272
272
 
@@ -288,7 +288,7 @@ pip install tigrbl-kernel
288
288
  | Surface | Value |
289
289
  |---|---|
290
290
  | PyPI package | [`tigrbl-kernel`](https://pypi.org/project/tigrbl-kernel/) |
291
- | Repository path | [`pkgs/core/tigrbl_kernel`](https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_kernel) |
291
+ | Repository path | [`pkgs/45_kernel/tigrbl_kernel`](https://github.com/tigrbl/tigrbl/tree/master/pkgs/45_kernel/tigrbl_kernel) |
292
292
  | Python import root | `tigrbl_kernel` |
293
293
  | Console scripts | none declared |
294
294
  | Entry points | none declared |
@@ -444,7 +444,7 @@ Choose `tigrbl-kernel` when the quick-answer table matches your use case. Choose
444
444
 
445
445
  - Community: [Discord](https://discord.gg/K4YTAPapjR).
446
446
  - Issues: [GitHub Issues](https://github.com/tigrbl/tigrbl/issues).
447
- - Repository: [pkgs/core/tigrbl_kernel](https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_kernel).
447
+ - Repository: [pkgs/45_kernel/tigrbl_kernel](https://github.com/tigrbl/tigrbl/tree/master/pkgs/45_kernel/tigrbl_kernel).
448
448
 
449
449
  ## Package-local Boundary
450
450
 
@@ -455,7 +455,7 @@ This file is a package-local distribution entry point. This README is the packag
455
455
  - Package status: governed package in the `tigrbl/tigrbl` workspace.
456
456
  - Governance source: [SSOT registry](https://github.com/tigrbl/tigrbl/blob/master/.ssot/registry.json).
457
457
  - Release evidence: [publish workflow](https://github.com/tigrbl/tigrbl/actions/workflows/publish.yml) validates package builds, tests, GitHub release assets, and PyPI publication for managed packages.
458
- - Local certification guard: `pkgs/core/tigrbl_tests/tests/unit/test_package_badges_and_notices.py` verifies every package README keeps the Discord badge, Apache 2.0 badge, explicit Python-version badge, `LICENSE`, and `NOTICE`.
458
+ - Local certification guard: `pkgs/97_tests/tigrbl_tests/tests/unit/test_package_badges_and_notices.py` verifies every package README keeps the Discord badge, Apache 2.0 badge, explicit Python-version badge, `LICENSE`, and `NOTICE`.
459
459
  - Scope note: this README documents the package boundary. Runtime feature support remains governed by `.ssot/` entities and the conformance docs linked below.
460
460
 
461
461
  ## License
@@ -1,6 +1,6 @@
1
- tigrbl_kernel/__init__.py,sha256=fE63Iyq2XrWqFTsvSC28iomE5nW6HsDUOEoQQtPNZ0I,2233
2
- tigrbl_kernel/_build.py,sha256=kHaS9k4cD5hg1eRj8aQnbPbUkTbOM8npE_yeOsiwUK8,50397
3
- tigrbl_kernel/_compile.py,sha256=g1_TP-wMp-O2qLjWkBupXHJ_cAFjeoldXl9L2hDF_gw,15248
1
+ tigrbl_kernel/__init__.py,sha256=61zqMAzNVSEJKPyYNpiUfoJkkTVYJ-XSYUI3hyOTY7g,2283
2
+ tigrbl_kernel/_build.py,sha256=h2-bpRVXRoHzbhyAAuFM4D_Mq6N75sTHb4kjwxXIgTc,51151
3
+ tigrbl_kernel/_compile.py,sha256=i-p_DB__Fem-f0G4CX_K1s3gSRYmXl3ewEKp4FWF6FU,16601
4
4
  tigrbl_kernel/atoms.py,sha256=ZNGj2PBUMu1BvPjNYlz4_vu4vhx4gD20C7zNE_GKfe8,12253
5
5
  tigrbl_kernel/cache.py,sha256=NxuqvH03xYwcQoY86ie1hmtDeA36XdiLmNyx2ImLQ6k,2596
6
6
  tigrbl_kernel/callbacks.py,sha256=r_ZI19nbAoDM-uf-FMUmWWAF9WaEKJK3Fpg-58ui8a4,772
@@ -19,12 +19,12 @@ tigrbl_kernel/lifecycle_matrix.py,sha256=SCpcCY8csYyepNGmElYzretQcdM9Yud-XYPcYH8
19
19
  tigrbl_kernel/loop_modes.py,sha256=HnT3upEUK75R_sf92TrmOyszic0Kt-rDCDU93NhPL9M,1636
20
20
  tigrbl_kernel/loop_regions.py,sha256=M4GbV5hujPA6vTGDoW_r-ElGlwPgorB8xNjP-22Xpy0,2224
21
21
  tigrbl_kernel/measure.py,sha256=eK_ceLmF_Waao3v2KHjiaZQmbvRaji9GLrvnjXo9pmw,52310
22
- tigrbl_kernel/models.py,sha256=fcOQGRlilZaWWromScYYyc42zqX4O83jSt1MzXBgf4M,14493
22
+ tigrbl_kernel/models.py,sha256=Uau8gfwl5U2mctWyApyrnSYlPPYpS8nBcJd4Wxl5PAo,14703
23
23
  tigrbl_kernel/opchannel_capabilities.py,sha256=5zM9-1fc-W8BXyXF4VVWrIsmdwCH5xLraGyO_AJwRrs,1541
24
- tigrbl_kernel/opview_compiler.py,sha256=DbZ3E0GYl_JEgExELjaNE7v2W4hl9LqvAB-CE5TwP7o,5370
24
+ tigrbl_kernel/opview_compiler.py,sha256=jR2Oe8chSGpYI_-IFFDtip9TZkDrDEkW03UWGdgPJn4,11284
25
25
  tigrbl_kernel/ordering.py,sha256=C6fHkAwQA_1AY7sEAUy_FC5U3eFmsyqcls1li1qG5MA,13576
26
26
  tigrbl_kernel/packed_access.py,sha256=KhvAt-X4_bsowYVQ47tWEIfQhpcvTmPG7BqzuEWF87U,3587
27
- tigrbl_kernel/packed_selectors.py,sha256=ZgPvOTUsTSF_ZIpQHM3acQhdIbM0-c6lBW7WjdAROI4,8609
27
+ tigrbl_kernel/packed_selectors.py,sha256=-ZOydklrxMdjITHjM05HlqKCl1hj0oqw3WOS-fQXvz4,13268
28
28
  tigrbl_kernel/payload.py,sha256=eyHo-cAUXLWQQ9-xZVOl_rjFudA-djMZji9hoK-v9IE,2167
29
29
  tigrbl_kernel/protocol_anchors.py,sha256=0jAXmSQ-OD3AGv7MM-2vF7hQmZtHG2bXwFM1ooi0Geg,863
30
30
  tigrbl_kernel/protocol_bindings.py,sha256=oE50T0YIEDAPXqxpXnmSAmJWX_SbSPdXBL8FtFF7OgI,18760
@@ -34,7 +34,7 @@ tigrbl_kernel/protocol_chains/http_unary.py,sha256=7EABv0bUAJ5UI3TWP-S-8LtgwZJLj
34
34
  tigrbl_kernel/protocol_chains/lifespan.py,sha256=kJdUuD-Yy4rJr5VeLv0Qs9WjDI_EcYlaJEKovc_LS7Y,609
35
35
  tigrbl_kernel/protocol_chains/sse.py,sha256=GASXjoFSxLHQmP85z2BscEkOlc-uSojr5JMjv2XZN8M,1149
36
36
  tigrbl_kernel/protocol_chains/static_files.py,sha256=KxtD5_7MOpl400oLutkVFawwgZsyPEh6gNtWy7Q34pc,807
37
- tigrbl_kernel/protocol_chains/websocket.py,sha256=FkXJU7h005Mrry9twcq1qIUNxmz_DEoPULCV2vDAxZs,1150
37
+ tigrbl_kernel/protocol_chains/websocket.py,sha256=_pSAHPc9ZGIhtINpeqJkM_X7GfsBFN0u9rqiQyGg1ZE,16948
38
38
  tigrbl_kernel/protocol_completion.py,sha256=VqQN54ZYZGbwxotZaLRj6Ai2r_HJFBdl2mCl1_LCICs,1075
39
39
  tigrbl_kernel/protocol_fusion.py,sha256=uaj_CM3z68NohnV0qE872jrd-pTEgL8oPQkdHKsaz8k,2407
40
40
  tigrbl_kernel/protocol_legality_matrix.py,sha256=71VbwQU4sR0MnB3Netwu7ooij6HSLLV6lzkRG5b-YjM,4128
@@ -52,8 +52,9 @@ tigrbl_kernel/transport_events.py,sha256=CyPhs69H7mnKU7gNgt4XtH-eRJcihSaXxQDzC0i
52
52
  tigrbl_kernel/types.py,sha256=Pf2e6xvZAcxD_uJZ7inMETpmhNyz4YGT27YuWdSHZ-I,2431
53
53
  tigrbl_kernel/utils.py,sha256=xf4lZAIH64DhpdHHyzPJ0LSAiUKWSaC5wIL1NJkHVro,6979
54
54
  tigrbl_kernel/webtransport_events.py,sha256=SWADw4m2JsCHHvMfH7QcVPtjOZ0kfjiCwwoiLhCokmk,10820
55
- tigrbl_kernel-0.4.5.dev4.dist-info/METADATA,sha256=bnh551RyvGwYYvucG97P8mzTdpuBb5MwQBmPqIxT6Yk,27961
56
- tigrbl_kernel-0.4.5.dev4.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
57
- tigrbl_kernel-0.4.5.dev4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
58
- tigrbl_kernel-0.4.5.dev4.dist-info/licenses/NOTICE,sha256=EvJMTshzsWz43LiK-DeN2ZuLtrP49cxvlrFlJ8F_buc,221
59
- tigrbl_kernel-0.4.5.dev4.dist-info/RECORD,,
55
+ tigrbl_kernel/webtransport_session_plan.py,sha256=9IeJMNqhnorv6L5EIA6Rc2nq21lBQv3DWQMO-GrTiZk,3213
56
+ tigrbl_kernel-0.4.5.dev8.dist-info/METADATA,sha256=GAUe1aS0NSTiUrbaphtpWt-rLYVLtOQQyZwCggdOi_4,28035
57
+ tigrbl_kernel-0.4.5.dev8.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
58
+ tigrbl_kernel-0.4.5.dev8.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
59
+ tigrbl_kernel-0.4.5.dev8.dist-info/licenses/NOTICE,sha256=EvJMTshzsWz43LiK-DeN2ZuLtrP49cxvlrFlJ8F_buc,221
60
+ tigrbl_kernel-0.4.5.dev8.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.4.1
2
+ Generator: poetry-core 2.5.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any