@camera.ui/server 0.0.104 → 0.0.106
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/interface/assets/{404-CJ99-HuF.js → 404-BAYYYl25.js} +1 -1
- package/dist/interface/assets/{About-B0RkqPCF.js → About-CF96M6HY.js} +1 -1
- package/dist/interface/assets/{Adminpanel-BzjRR4E-.js → Adminpanel-DOaJyAyb.js} +1 -1
- package/dist/interface/assets/{Automation-BxZOk7_I.js → Automation-CKXlldCc.js} +2 -2
- package/dist/interface/assets/{Automations-DCnYU4ec.js → Automations-Bly4du8g.js} +1 -1
- package/dist/interface/assets/{Camera-uZPZMDm6.js → Camera-B_iqc3Zu.js} +2 -2
- package/dist/interface/assets/{CameraConsole-C_TUwy4O.js → CameraConsole-DmkR-ATY.js} +1 -1
- package/dist/interface/assets/{Cameras-CCjDWbDk.js → Cameras-CBJqTSnN.js} +1 -1
- package/dist/interface/assets/{Camview-DGtE5Z3s.js → Camview-CF_Fa0E1.js} +1 -1
- package/dist/interface/assets/{Config-QQ7HALtt.js → Config-B10TESNa.js} +1 -1
- package/dist/interface/assets/{ConfigActionCameraControl-C7ZfjHnE.js → ConfigActionCameraControl-BWpRxipc.js} +1 -1
- package/dist/interface/assets/{ConfigActionDelay-C9zOKCqa.js → ConfigActionDelay-NBOa8ulm.js} +1 -1
- package/dist/interface/assets/{ConfigActionHttp-zeFP7jW9.js → ConfigActionHttp-CpeMles1.js} +1 -1
- package/dist/interface/assets/{ConfigActionImageInput-D19IiOQh.js → ConfigActionImageInput-He5suR1p.js} +1 -1
- package/dist/interface/assets/{ConfigActionNotification-vKL_FJi6.js → ConfigActionNotification-BrYGUn_B.js} +1 -1
- package/dist/interface/assets/{ConfigActionOutput-C9P9NVNR.js → ConfigActionOutput-Dy32ypu0.js} +1 -1
- package/dist/interface/assets/{ConfigActionPlugin-BfjaRyTe.js → ConfigActionPlugin-DZgDVj2J.js} +1 -1
- package/dist/interface/assets/{ConfigActionSensor-BGIRJ1VF.js → ConfigActionSensor-B9aJwfb2.js} +1 -1
- package/dist/interface/assets/{ConfigActionSnapshot-ClUBwM1R.js → ConfigActionSnapshot-Bisnu1Va.js} +1 -1
- package/dist/interface/assets/{ConfigActionVariable-COV3XNCP.js → ConfigActionVariable-BcQ-pUs-.js} +1 -1
- package/dist/interface/assets/{ConfigConditionIfElse-CRzCscc-.js → ConfigConditionIfElse-DKyGnLRO.js} +1 -1
- package/dist/interface/assets/{ConfigConditionSensorState-DZJkEOJX.js → ConfigConditionSensorState-mKh6F8lJ.js} +1 -1
- package/dist/interface/assets/{ConfigConditionSwitch-CY52JGdW.js → ConfigConditionSwitch-CSZTkyIE.js} +1 -1
- package/dist/interface/assets/{ConfigConditionTime-o7CY--td.js → ConfigConditionTime-C8_R0ITE.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerDetection-DRM0HbdD.js → ConfigTriggerDetection-a2NV6Ke1.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerGeofence-CJi5c7cN.js → ConfigTriggerGeofence-DnS6ONJC.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerManual-B8iZq1a7.js → ConfigTriggerManual-CrKYDCVp.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerSchedule-sG8s23os.js → ConfigTriggerSchedule-Dc6k-hJ2.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerSensor-Dc9qP7jS.js → ConfigTriggerSensor-lmBAbpAF.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerSystem-wZjLxEWq.js → ConfigTriggerSystem-BibBSkft.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerWebhook-BpWO2kXx.js → ConfigTriggerWebhook-CcMI4u82.js} +1 -1
- package/dist/interface/assets/{Console-B79GknLp.js → Console-CiqXek8C.js} +1 -1
- package/dist/interface/assets/{CuiActionButton-DaeBGbij.js → CuiActionButton-DmV0jhr5.js} +1 -1
- package/dist/interface/assets/{CuiAvatar-CMR4MZZv.js → CuiAvatar-DMZqcLoE.js} +1 -1
- package/dist/interface/assets/{CuiBBoxPlayground-BJxrh8Hk.js → CuiBBoxPlayground-BakfNGCT.js} +1 -1
- package/dist/interface/assets/CuiBottomSheet-BJl4lLzC.js +1 -0
- package/dist/interface/assets/{CuiBottomSheet-DmanZWbm.js → CuiBottomSheet-CkJEMXQy.js} +1 -1
- package/dist/interface/assets/CuiCameraCard-jWlEnJAQ.js +1 -0
- package/dist/interface/assets/{CuiCameraPipCard-BvJJFMV2.js → CuiCameraPipCard-ClOJg9E-.js} +1 -1
- package/dist/interface/assets/CuiCameraRecordings-BhM5UivW.js +1 -0
- package/dist/interface/assets/{CuiCameraShares-BNyn9lMv.js → CuiCameraShares-CktUxrlR.js} +1 -1
- package/dist/interface/assets/{CuiCameraSnapshot-DzweaAbD.js → CuiCameraSnapshot-BE1xeM4q.js} +1 -1
- package/dist/interface/assets/{CuiCameraTable-CCu87mix.js → CuiCameraTable-D87v9LhC.js} +1 -1
- package/dist/interface/assets/{CuiDataTable-Q0cMKDSG.js → CuiDataTable-C5toN4L-.js} +1 -1
- package/dist/interface/assets/{CuiFloatingButton-CLb4OXWq.js → CuiFloatingButton-DvdpuxUr.js} +1 -1
- package/dist/interface/assets/{CuiImage-OMrwpQrB.js → CuiImage-C-DSHyf2.js} +1 -1
- package/dist/interface/assets/{CuiMenu-BXurM_rM.js → CuiMenu-CwumqO_L.js} +1 -1
- package/dist/interface/assets/{CuiPluginCard-o4XyKYKV.js → CuiPluginCard-DIqaLKcf.js} +2 -2
- package/dist/interface/assets/{CuiPluginOAuthButton-CT_taZrW.js → CuiPluginOAuthButton-CBTeQEb9.js} +2 -2
- package/dist/interface/assets/{CuiQRCode-Dv03yW5H.js → CuiQRCode-ChJuYXmm.js} +1 -1
- package/dist/interface/assets/{CuiRecordingsGrid-34f2-J9w.js → CuiRecordingsGrid-D1mYCCcE.js} +1 -1
- package/dist/interface/assets/{CuiSchema-DPegn_DL.js → CuiSchema-BIGweVJy.js} +1 -1
- package/dist/interface/assets/{CuiTopNavbarItem-D1yZXVK4.js → CuiTopNavbarItem-FXY4aBoC.js} +1 -1
- package/dist/interface/assets/{CuiTopbarSlot-D8e3DC_V.js → CuiTopbarSlot-DCeAqmkQ.js} +1 -1
- package/dist/interface/assets/{Faces-CNCw8AXF.js → Faces-B6JlzZ5i.js} +1 -1
- package/dist/interface/assets/{FirstSteps-Dcr9xd_H.js → FirstSteps-CH1iro2p.js} +1 -1
- package/dist/interface/assets/{Instances-DCrJqqdk.js → Instances-BNNA4Z6H.js} +1 -1
- package/dist/interface/assets/{Logs-9KBZQ4SH.js → Logs-BkF4EFHV.js} +1 -1
- package/dist/interface/assets/{Menu-D82CRc95.js → Menu-DYejNhsi.js} +1 -1
- package/dist/interface/assets/{OAuthDeviceFlow-C6sk2XbU.js → OAuthDeviceFlow-BVeSCJiJ.js} +1 -1
- package/dist/interface/assets/{Plugin-DY1-hTa-.js → Plugin-zvdoGW2r.js} +1 -1
- package/dist/interface/assets/{PluginConsole-D8K2fOf6.js → PluginConsole-CQA2A5Wc.js} +1 -1
- package/dist/interface/assets/{PluginSchema-JKttwvkD.js → PluginSchema-xof7AYIK.js} +1 -1
- package/dist/interface/assets/{Plugins-5y2A-S9R.js → Plugins-DAwDjr_I.js} +2 -2
- package/dist/interface/assets/Recordings-D7UhoAKi.js +1 -0
- package/dist/interface/assets/{Settings-BQSZwEux.js → Settings-CnU98cZv.js} +1 -1
- package/dist/interface/assets/{SettingsAccount-7HSGeh_G.js → SettingsAccount-BVIztMFL.js} +1 -1
- package/dist/interface/assets/SettingsAccount-BmuJLRLH.js +1 -0
- package/dist/interface/assets/{SettingsAppearance-DAtwb17b.js → SettingsAppearance-CW8mbN9Q.js} +1 -1
- package/dist/interface/assets/SettingsAppearance-CiVRn_RM.js +1 -0
- package/dist/interface/assets/{SettingsBackup-Bj2pUzxN.js → SettingsBackup-DDNiOgvA.js} +1 -1
- package/dist/interface/assets/{SettingsNotifications-Bk40tvnT.js → SettingsNotifications-DLMT1o4I.js} +1 -1
- package/dist/interface/assets/{SettingsRecordings-BfbVsR6a.js → SettingsRecordings-DirvHeKB.js} +1 -1
- package/dist/interface/assets/{SettingsRemote-CPq1K5z8.js → SettingsRemote-CKWsHa5R.js} +1 -1
- package/dist/interface/assets/{SettingsSystem-C9qSkCyX.js → SettingsSystem-D9ZC11Mh.js} +2 -2
- package/dist/interface/assets/{SettingsUsers-BpwM-HIU.js → SettingsUsers-pcJeXZKp.js} +1 -1
- package/dist/interface/assets/{Terminal-BB9MM3FG.js → Terminal-Cu3uC8EZ.js} +1 -1
- package/dist/interface/assets/{VariableInput-YR8KBCWL.js → VariableInput-Bwu9vChK.js} +1 -1
- package/dist/interface/assets/{VariableSuggestions-BF2MDSW8.js → VariableSuggestions-l2MLjZre.js} +1 -1
- package/dist/interface/assets/{VersionsHandler-D2lhMYGG.js → VersionsHandler-BTxJHwdS.js} +1 -1
- package/dist/interface/assets/{Workers-Cn-kFp5K.js → Workers-BsEDKzmG.js} +1 -1
- package/dist/interface/assets/{api-BSSGwZUW.js → api-C3OjKngj.js} +1 -1
- package/dist/interface/assets/api-CFbWrm1k.js +2 -0
- package/dist/interface/assets/{auth-DuxuZzUv.js → auth-C5IUhkWS.js} +1 -1
- package/dist/interface/assets/{automations-BjPRCiP3.js → automations-DD8bCSnT.js} +1 -1
- package/dist/interface/assets/{automations-D4BRxXp2.js → automations-DusabINi.js} +1 -1
- package/dist/interface/assets/{backup-B01_Kkb-.js → backup-oG81xIPA.js} +1 -1
- package/dist/interface/assets/{cameras-B4_XIEvq.js → cameras-CmFEgULx.js} +1 -1
- package/dist/interface/assets/{cameras.schema-BJtNPf50.js → cameras.schema-iBaEWm0Y.js} +1 -1
- package/dist/interface/assets/cloudHandoff-BzwN18OZ.js +1 -0
- package/dist/interface/assets/{deviceId-Csqx7wv1.js → deviceId-opaLeE0R.js} +1 -1
- package/dist/interface/assets/{hasPermission-C2RWjXuT.js → hasPermission-ChIspc-L.js} +1 -1
- package/dist/interface/assets/{instances-Etl_yihq.js → instances-ykXfoA2u.js} +1 -1
- package/dist/interface/assets/{locale-BslWmL0t.js → locale-BliTvqss.js} +1 -1
- package/dist/interface/assets/main-DiUHKjqR.js +3 -0
- package/dist/interface/assets/{notifications-SVoDimTP.js → notifications-BQyPgfnE.js} +1 -1
- package/dist/interface/assets/{plugins-VMgnZZzG.js → plugins-BFzZB-8S.js} +1 -1
- package/dist/interface/assets/{round-restart-alt-D3SOXMVf.js → round-restart-alt-C4TFl1Rk.js} +1 -1
- package/dist/interface/assets/{router-BqJLYjxq.js → router-BzaHrqQ7.js} +2 -2
- package/dist/interface/assets/{routerState-g4QLPAT2.js → routerState-UESa7YsN.js} +1 -1
- package/dist/interface/assets/{server-8uNYIjzr.js → server-7nJbrqvt.js} +1 -1
- package/dist/interface/assets/{shares-DsjePQGf.js → shares-BokQ1O4D.js} +1 -1
- package/dist/interface/assets/{theme-DN8GOT_w.js → theme-iSxYm9Ao.js} +1 -1
- package/dist/interface/assets/{types-B39ppbYi.js → types-CVp3a4-E.js} +1 -1
- package/dist/interface/assets/{ui-CEzY0rCW.js → ui-CVUMjabl.js} +1 -1
- package/dist/interface/assets/{useBiometricCredentials-BWuRf0H0.js → useBiometricCredentials-BTSuyr45.js} +1 -1
- package/dist/interface/assets/{useCameraOptions-DLmEgMil.js → useCameraOptions-CuIFFMce.js} +1 -1
- package/dist/interface/assets/{useCuiCameraDrawer-PokkyjqH.js → useCuiCameraDrawer-DHbL2uYT.js} +1 -1
- package/dist/interface/assets/{useCuiDialog-BEerzsp3.js → useCuiDialog-DmnaFxId.js} +1 -1
- package/dist/interface/assets/{useFileDownload-BzheFQ77.js → useFileDownload-D7uRiFqP.js} +1 -1
- package/dist/interface/assets/{useLogsSocket-CeW070HG.js → useLogsSocket-CA2uq1kZ.js} +1 -1
- package/dist/interface/assets/{useSharedComposables-B5-Uy9Br.js → useSharedComposables-Dsn2uiyW.js} +1 -1
- package/dist/interface/assets/{useStreamStatus-BPixdI1Y.js → useStreamStatus-Dun63PvC.js} +1 -1
- package/dist/interface/assets/{users.schema-D443yTkQ.js → users.schema-COvF8XP0.js} +1 -1
- package/dist/interface/assets/vendor-cameraui-B1JU4jwS.js +1978 -0
- package/dist/interface/assets/{vendor-core-DSNqTlQH.js → vendor-core-BxjZmyZH.js} +1 -1
- package/dist/interface/assets/{vendor-primevue-CWF_imvX.js → vendor-primevue-gmNTz6qq.js} +1 -1
- package/dist/interface/assets/{view-grid-BE5Jf7kM.js → view-grid-C3peaC6C.js} +1 -1
- package/dist/interface/index.html +40 -40
- package/dist/interface/sw.js +1 -1
- package/dist/server/package.json +3 -3
- package/dist/server/requirements.txt +1 -1
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/channel.py +29 -23
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/chunking.py +52 -5
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/client.py +682 -231
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/codec.py +277 -18
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/handler.py +105 -17
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/service.py +21 -7
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/types.py +13 -0
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/utils.py +50 -15
- package/dist/server/src/api/plugins/natsSubFirewall.d.ts +1 -0
- package/dist/server/src/api/plugins/natsSubFirewall.js +9 -1
- package/dist/server/src/api/plugins/natsSubFirewall.js.map +1 -1
- package/dist/server/src/api/plugins/proxy.plugin.js +3 -0
- package/dist/server/src/api/plugins/proxy.plugin.js.map +1 -1
- package/dist/server/src/rpc/server.js +6 -3
- package/dist/server/src/rpc/server.js.map +1 -1
- package/package.json +3 -3
- package/dist/interface/assets/CuiBottomSheet-3-36plJ0.js +0 -1
- package/dist/interface/assets/CuiCameraCard-DYkArUIE.js +0 -1
- package/dist/interface/assets/CuiCameraRecordings-qS2sljLl.js +0 -1
- package/dist/interface/assets/Recordings-Bdii8y2w.js +0 -1
- package/dist/interface/assets/SettingsAccount-pc1G5uya.js +0 -1
- package/dist/interface/assets/SettingsAppearance-CDlFqm-u.js +0 -1
- package/dist/interface/assets/api-CPonLnGH.js +0 -2
- package/dist/interface/assets/cloudHandoff-DR4OiIia.js +0 -1
- package/dist/interface/assets/main-QYfiMUtP.js +0 -3
- package/dist/interface/assets/vendor-cameraui-D9I8uPRL.js +0 -1978
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
"""MessagePack encoding/decoding using ormsgpack for better performance."""
|
|
2
2
|
|
|
3
|
+
import struct
|
|
3
4
|
from datetime import date, datetime, time
|
|
4
5
|
from enum import Enum
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
import ormsgpack
|
|
8
9
|
|
|
10
|
+
# Precomputed option masks — avoids re-evaluating the flag OR on every call.
|
|
11
|
+
# OPT_DATETIME_AS_TIMESTAMP_EXT: (De)serialize datetime as MessagePack timestamp extension
|
|
12
|
+
# OPT_NON_STR_KEYS: Allow non-string dict keys
|
|
13
|
+
# OPT_PASSTHROUGH_ENUM: Pass enums to default handler
|
|
14
|
+
_PACK_OPTS: int = (
|
|
15
|
+
ormsgpack.OPT_NON_STR_KEYS | ormsgpack.OPT_DATETIME_AS_TIMESTAMP_EXT | ormsgpack.OPT_PASSTHROUGH_ENUM
|
|
16
|
+
)
|
|
17
|
+
_UNPACK_OPTS: int = ormsgpack.OPT_DATETIME_AS_TIMESTAMP_EXT | ormsgpack.OPT_NON_STR_KEYS
|
|
18
|
+
|
|
9
19
|
|
|
10
20
|
def _default_handler(obj: Any) -> Any:
|
|
11
21
|
"""Default handler for custom types."""
|
|
@@ -33,16 +43,7 @@ def encode(data: Any) -> bytes:
|
|
|
33
43
|
MessagePack encoded binary data
|
|
34
44
|
"""
|
|
35
45
|
# Use ormsgpack with proper options for cross-language compatibility
|
|
36
|
-
|
|
37
|
-
# OPT_NON_STR_KEYS: Allow non-string dict keys
|
|
38
|
-
# OPT_PASSTHROUGH_ENUM: Pass enums to default handler
|
|
39
|
-
return ormsgpack.packb(
|
|
40
|
-
data,
|
|
41
|
-
default=_default_handler,
|
|
42
|
-
option=ormsgpack.OPT_NON_STR_KEYS
|
|
43
|
-
| ormsgpack.OPT_DATETIME_AS_TIMESTAMP_EXT
|
|
44
|
-
| ormsgpack.OPT_PASSTHROUGH_ENUM,
|
|
45
|
-
)
|
|
46
|
+
return ormsgpack.packb(data, default=_default_handler, option=_PACK_OPTS)
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
def _ext_hook(_code: int, _data: bytes) -> Any:
|
|
@@ -62,12 +63,270 @@ def decode(data: bytes | bytearray | memoryview) -> Any:
|
|
|
62
63
|
Returns:
|
|
63
64
|
Decoded data
|
|
64
65
|
"""
|
|
65
|
-
# Use ormsgpack with proper options
|
|
66
|
-
# OPT_DATETIME_AS_TIMESTAMP_EXT: Deserialize timestamp extension objects to datetime
|
|
67
|
-
# OPT_NON_STR_KEYS: Allow non-string dict keys
|
|
68
66
|
# ext_hook: Handle extension types from Node.js msgpackr
|
|
69
|
-
return ormsgpack.unpackb(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
return ormsgpack.unpackb(data, option=_UNPACK_OPTS, ext_hook=_ext_hook)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# see node/src/codec.ts
|
|
71
|
+
|
|
72
|
+
BINARY_EXTRACT_THRESHOLD = 16384
|
|
73
|
+
"""Minimum byte length for a binary value to be extracted out-of-band."""
|
|
74
|
+
|
|
75
|
+
_MAGIC = b"CUIB"
|
|
76
|
+
_HEADER_SIZE = 8 # magic + u32 LE envLen
|
|
77
|
+
_PLACEHOLDER_KEY = "__cui_bin__"
|
|
78
|
+
|
|
79
|
+
_ENV_LEN_STRUCT = struct.Struct("<I")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _as_extractable_segment(value: Any) -> bytes | bytearray | memoryview | None:
|
|
83
|
+
"""Return the segment buffer for extractable binaries, None otherwise."""
|
|
84
|
+
if isinstance(value, (bytes, bytearray)):
|
|
85
|
+
return value if len(value) >= BINARY_EXTRACT_THRESHOLD else None
|
|
86
|
+
if isinstance(value, memoryview):
|
|
87
|
+
if value.nbytes < BINARY_EXTRACT_THRESHOLD:
|
|
88
|
+
return None
|
|
89
|
+
# join()/len math below need a flat byte layout.
|
|
90
|
+
return value if value.c_contiguous else memoryview(value.tobytes())
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_binaries(value: Any, segments: list[bytes | bytearray | memoryview]) -> Any:
|
|
95
|
+
"""Depth-first copy-on-write transform: extracted binaries become
|
|
96
|
+
placeholder maps, untouched subtrees keep their identity (no copy for
|
|
97
|
+
binary-free messages). Only dicts, lists and tuples are traversed —
|
|
98
|
+
other class instances are left to msgpack.
|
|
99
|
+
"""
|
|
100
|
+
segment = _as_extractable_segment(value)
|
|
101
|
+
if segment is not None:
|
|
102
|
+
index = len(segments)
|
|
103
|
+
segments.append(segment)
|
|
104
|
+
return {
|
|
105
|
+
_PLACEHOLDER_KEY: index,
|
|
106
|
+
"l": segment.nbytes if isinstance(segment, memoryview) else len(segment),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if isinstance(value, dict):
|
|
110
|
+
dict_copy: dict[Any, Any] | None = None
|
|
111
|
+
for key, item in value.items(): # pyright: ignore[reportUnknownVariableType]
|
|
112
|
+
# Fast skip for common scalar leaves (exact types only —
|
|
113
|
+
# subclasses take the full path below).
|
|
114
|
+
cls = item.__class__
|
|
115
|
+
if cls is str or cls is int or cls is float or cls is bool or item is None:
|
|
116
|
+
continue
|
|
117
|
+
transformed = _extract_binaries(item, segments)
|
|
118
|
+
if transformed is not item:
|
|
119
|
+
if dict_copy is None:
|
|
120
|
+
dict_copy = dict(value) # pyright: ignore[reportUnknownArgumentType]
|
|
121
|
+
dict_copy[key] = transformed
|
|
122
|
+
return dict_copy if dict_copy is not None else value
|
|
123
|
+
|
|
124
|
+
if isinstance(value, (list, tuple)):
|
|
125
|
+
list_copy: list[Any] | None = None
|
|
126
|
+
for i, item in enumerate(value): # pyright: ignore[reportUnknownVariableType,reportUnknownArgumentType]
|
|
127
|
+
cls = item.__class__
|
|
128
|
+
if cls is str or cls is int or cls is float or cls is bool or item is None:
|
|
129
|
+
continue
|
|
130
|
+
transformed = _extract_binaries(item, segments)
|
|
131
|
+
if transformed is not item:
|
|
132
|
+
if list_copy is None:
|
|
133
|
+
list_copy = list(value) # pyright: ignore[reportUnknownArgumentType]
|
|
134
|
+
list_copy[i] = transformed
|
|
135
|
+
return list_copy if list_copy is not None else value
|
|
136
|
+
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_binary_placeholder(value: Any) -> bool:
|
|
141
|
+
"""Strict placeholder check. A user map that happens to carry the
|
|
142
|
+
__cui_bin__ key but has extra keys, a missing "l" or non-integer values
|
|
143
|
+
is NOT treated as a placeholder and passes through untouched.
|
|
144
|
+
"""
|
|
145
|
+
if type(value) is not dict or len(value) != 2: # pyright: ignore[reportUnknownArgumentType]
|
|
146
|
+
return False
|
|
147
|
+
index = value.get(_PLACEHOLDER_KEY)
|
|
148
|
+
length = value.get("l")
|
|
149
|
+
# type() is int: bools are ints in Python but not valid placeholders.
|
|
150
|
+
return type(index) is int and index >= 0 and type(length) is int and length >= 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _RestoreState:
|
|
154
|
+
"""Mutable cursor for the sequential restore pass."""
|
|
155
|
+
|
|
156
|
+
__slots__ = ("next", "offset", "out_of_order")
|
|
157
|
+
|
|
158
|
+
def __init__(self, offset: int) -> None:
|
|
159
|
+
self.offset = offset # start offset of the next expected segment
|
|
160
|
+
self.next = 0 # next expected segment index
|
|
161
|
+
self.out_of_order = False # placeholder index arrived out of order
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _restore_sequential(value: Any, data: memoryview, state: _RestoreState) -> Any:
|
|
165
|
+
"""Single-pass restore for the common case: placeholder indices appear in
|
|
166
|
+
increasing order during envelope traversal (an encoder that assigns
|
|
167
|
+
indices and serializes in one walk always produces this). Offsets are
|
|
168
|
+
then running prefix sums — no length-collection pass needed. Mutates the
|
|
169
|
+
freshly decoded envelope in place; on the first out-of-order index the
|
|
170
|
+
remaining placeholders are left untouched for the fallback pass.
|
|
171
|
+
|
|
172
|
+
Decoded envelopes only ever contain plain dicts/lists, so exact type
|
|
173
|
+
checks are safe; the placeholder check is _is_binary_placeholder inlined
|
|
174
|
+
for the hot path.
|
|
175
|
+
"""
|
|
176
|
+
if type(value) is dict:
|
|
177
|
+
if len(value) == 2:
|
|
178
|
+
index = value.get(_PLACEHOLDER_KEY)
|
|
179
|
+
length = value.get("l")
|
|
180
|
+
# type() is int: bools are ints in Python but not valid placeholders.
|
|
181
|
+
if type(index) is int and index >= 0 and type(length) is int and length >= 0:
|
|
182
|
+
if state.out_of_order or index != state.next:
|
|
183
|
+
state.out_of_order = True
|
|
184
|
+
return value
|
|
185
|
+
end = state.offset + length
|
|
186
|
+
if end > data.nbytes:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"Invalid CUIB frame: segment {state.next} exceeds payload size {data.nbytes}"
|
|
189
|
+
)
|
|
190
|
+
view = data[state.offset : end]
|
|
191
|
+
state.offset = end
|
|
192
|
+
state.next += 1
|
|
193
|
+
return view
|
|
194
|
+
for key, item in value.items():
|
|
195
|
+
if type(item) is dict or type(item) is list:
|
|
196
|
+
value[key] = _restore_sequential(item, data, state)
|
|
197
|
+
return value
|
|
198
|
+
if type(value) is list:
|
|
199
|
+
for i, item in enumerate(value):
|
|
200
|
+
if type(item) is dict or type(item) is list:
|
|
201
|
+
value[i] = _restore_sequential(item, data, state)
|
|
202
|
+
return value
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _collect_segment_lengths(value: Any, lengths: dict[int, int]) -> None:
|
|
207
|
+
"""Fallback pass 1: record remaining placeholders' lengths by index."""
|
|
208
|
+
if _is_binary_placeholder(value):
|
|
209
|
+
lengths[value[_PLACEHOLDER_KEY]] = value["l"]
|
|
210
|
+
return
|
|
211
|
+
if isinstance(value, list):
|
|
212
|
+
for item in value: # pyright: ignore[reportUnknownVariableType]
|
|
213
|
+
_collect_segment_lengths(item, lengths)
|
|
214
|
+
return
|
|
215
|
+
if isinstance(value, dict):
|
|
216
|
+
for item in value.values(): # pyright: ignore[reportUnknownVariableType]
|
|
217
|
+
_collect_segment_lengths(item, lengths)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _restore_by_index(value: Any, views: dict[int, memoryview]) -> Any:
|
|
221
|
+
"""Fallback pass 2: swap remaining placeholders for their segment views."""
|
|
222
|
+
if _is_binary_placeholder(value):
|
|
223
|
+
return views[value[_PLACEHOLDER_KEY]]
|
|
224
|
+
if isinstance(value, list):
|
|
225
|
+
for i, item in enumerate(value): # pyright: ignore[reportUnknownVariableType,reportUnknownArgumentType]
|
|
226
|
+
value[i] = _restore_by_index(item, views)
|
|
227
|
+
return value
|
|
228
|
+
if isinstance(value, dict):
|
|
229
|
+
for key, item in value.items(): # pyright: ignore[reportUnknownVariableType]
|
|
230
|
+
value[key] = _restore_by_index(item, views)
|
|
231
|
+
return value
|
|
232
|
+
return value
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _restore_remaining(envelope: Any, data: memoryview, state: _RestoreState) -> Any:
|
|
236
|
+
"""Out-of-order fallback: _restore_sequential already consumed the
|
|
237
|
+
in-order prefix (segments 0..state.next-1); place the remaining segments
|
|
238
|
+
via an explicit index -> length table. Segments always lie back-to-back
|
|
239
|
+
in index order, whatever order their placeholders appear in.
|
|
240
|
+
"""
|
|
241
|
+
lengths: dict[int, int] = {}
|
|
242
|
+
_collect_segment_lengths(envelope, lengths)
|
|
243
|
+
|
|
244
|
+
views: dict[int, memoryview] = {}
|
|
245
|
+
offset = state.offset
|
|
246
|
+
for i in range(state.next, max(lengths) + 1 if lengths else state.next):
|
|
247
|
+
length = lengths.get(i)
|
|
248
|
+
if length is None:
|
|
249
|
+
raise ValueError(f"Invalid CUIB frame: missing placeholder for segment {i}")
|
|
250
|
+
end = offset + length
|
|
251
|
+
if end > data.nbytes:
|
|
252
|
+
raise ValueError(f"Invalid CUIB frame: segment {i} exceeds payload size {data.nbytes}")
|
|
253
|
+
views[i] = data[offset:end]
|
|
254
|
+
offset = end
|
|
255
|
+
state.offset = offset
|
|
256
|
+
|
|
257
|
+
return _restore_by_index(envelope, views)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def encode_message(data: Any) -> bytes:
|
|
261
|
+
"""
|
|
262
|
+
Encode a message for the wire.
|
|
263
|
+
|
|
264
|
+
Large binaries (bytes/bytearray/memoryview with byte length >=
|
|
265
|
+
BINARY_EXTRACT_THRESHOLD) are extracted into out-of-band segments after
|
|
266
|
+
the msgpack envelope; binary-free messages stay byte-identical to
|
|
267
|
+
encode(). The input structure is never mutated (copy-on-write).
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
data: Any serializable data
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Wire-encoded binary data
|
|
274
|
+
"""
|
|
275
|
+
segments: list[bytes | bytearray | memoryview] = []
|
|
276
|
+
transformed = _extract_binaries(data, segments)
|
|
277
|
+
|
|
278
|
+
if not segments:
|
|
279
|
+
return encode(data)
|
|
280
|
+
|
|
281
|
+
envelope = encode(transformed)
|
|
282
|
+
# One join copy assembles the full frame — acceptable even for large
|
|
283
|
+
# frames, and cheaper than incremental writes from Python.
|
|
284
|
+
return b"".join((_MAGIC, _ENV_LEN_STRUCT.pack(len(envelope)), envelope, *segments))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def decode_message(data: bytes | bytearray | memoryview) -> Any:
|
|
288
|
+
"""
|
|
289
|
+
Decode a wire message.
|
|
290
|
+
|
|
291
|
+
Payloads without the CUIB magic are plain msgpack. For framed payloads
|
|
292
|
+
the placeholder maps are replaced by zero-copy memoryview slices into
|
|
293
|
+
``data`` — binary values >= BINARY_EXTRACT_THRESHOLD (1 KiB) therefore
|
|
294
|
+
arrive as ``memoryview``, not ``bytes``. Consumers speaking the buffer
|
|
295
|
+
protocol (numpy, Rust bindings, ``len()``/slicing) handle views
|
|
296
|
+
directly; call ``bytes(view)`` where a real bytes object is required or
|
|
297
|
+
the data must outlive the receive buffer.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
data: Wire-encoded binary data (one complete message)
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Decoded data
|
|
304
|
+
"""
|
|
305
|
+
# Fast path for plain msgpack payloads: bytes/bytearray get a cheap
|
|
306
|
+
# startswith check without allocating a memoryview.
|
|
307
|
+
if isinstance(data, memoryview):
|
|
308
|
+
if data.nbytes < _HEADER_SIZE or data[:4] != _MAGIC:
|
|
309
|
+
return decode(data)
|
|
310
|
+
view = data
|
|
311
|
+
else:
|
|
312
|
+
if len(data) < _HEADER_SIZE or not data.startswith(_MAGIC):
|
|
313
|
+
return decode(data)
|
|
314
|
+
view = memoryview(data)
|
|
315
|
+
total = view.nbytes
|
|
316
|
+
|
|
317
|
+
env_len: int = _ENV_LEN_STRUCT.unpack_from(view, 4)[0]
|
|
318
|
+
segment_base = _HEADER_SIZE + env_len
|
|
319
|
+
if segment_base > total:
|
|
320
|
+
raise ValueError(f"Invalid CUIB frame: envelope length {env_len} exceeds payload size {total}")
|
|
321
|
+
|
|
322
|
+
envelope = decode(view[_HEADER_SIZE:segment_base])
|
|
323
|
+
|
|
324
|
+
state = _RestoreState(segment_base)
|
|
325
|
+
envelope = _restore_sequential(envelope, view, state)
|
|
326
|
+
if state.out_of_order:
|
|
327
|
+
envelope = _restore_remaining(envelope, view, state)
|
|
328
|
+
|
|
329
|
+
if state.offset != total:
|
|
330
|
+
raise ValueError(f"Invalid CUIB frame: expected payload size {state.offset}, got {total}")
|
|
331
|
+
|
|
332
|
+
return envelope
|
|
@@ -138,19 +138,27 @@ async def handle_normal_rpc(
|
|
|
138
138
|
handler: Callable[..., Any],
|
|
139
139
|
params: Any,
|
|
140
140
|
io_pool: ThreadPoolExecutor,
|
|
141
|
+
handler_is_async: bool | None = None,
|
|
141
142
|
) -> Any:
|
|
142
|
-
"""Handle normal RPC call - common logic for client and service.
|
|
143
|
+
"""Handle normal RPC call - common logic for client and service.
|
|
144
|
+
|
|
145
|
+
``handler_is_async`` lets callers on hot paths pass the async-ness
|
|
146
|
+
determined once at registration time instead of re-inspecting per call.
|
|
147
|
+
Sync handlers run inline on the event loop — the thread-pool hop costs
|
|
148
|
+
far more than the short callbacks used in this system, and inline
|
|
149
|
+
execution preserves per-subscription ordering.
|
|
150
|
+
"""
|
|
143
151
|
# Ensure params is a list
|
|
144
152
|
if not isinstance(params, list):
|
|
145
153
|
params = [params] if params is not None else []
|
|
146
154
|
|
|
155
|
+
if handler_is_async is None:
|
|
156
|
+
handler_is_async = is_async_function(handler)
|
|
157
|
+
|
|
147
158
|
# Call the handler
|
|
148
|
-
if
|
|
159
|
+
if handler_is_async:
|
|
149
160
|
return await handler(*params)
|
|
150
|
-
|
|
151
|
-
loop = asyncio.get_event_loop()
|
|
152
|
-
func = partial(handler, *params)
|
|
153
|
-
return await loop.run_in_executor(io_pool, func)
|
|
161
|
+
return handler(*params)
|
|
154
162
|
|
|
155
163
|
|
|
156
164
|
def format_error_dict(e: Exception) -> RPCError:
|
|
@@ -170,6 +178,7 @@ async def handle_pull_iterator_request(
|
|
|
170
178
|
iterator_id: str,
|
|
171
179
|
client: RPCClient,
|
|
172
180
|
io_pool: ThreadPoolExecutor,
|
|
181
|
+
on_finished: Callable[[], Any] | None = None,
|
|
173
182
|
) -> Callable[[], Coroutine[Any, Any, None]]:
|
|
174
183
|
"""Handle pull-based iterator request."""
|
|
175
184
|
generator: AsyncGenerator[Any, None]
|
|
@@ -229,7 +238,28 @@ async def handle_pull_iterator_request(
|
|
|
229
238
|
|
|
230
239
|
# Track if iterator is active
|
|
231
240
|
active = True
|
|
232
|
-
|
|
241
|
+
finished = False
|
|
242
|
+
unsub_func: Callable[[], Coroutine[Any, Any, None]] | None = None
|
|
243
|
+
|
|
244
|
+
def finish() -> None:
|
|
245
|
+
"""Natural end (done/cancel/error): drop the request subscription and
|
|
246
|
+
let the owner remove its cleanup-map entry via on_finished. Without
|
|
247
|
+
this, every finished session leaves its `_rpc.iterator.*.request`
|
|
248
|
+
subscription and cleanup closure behind until the whole client
|
|
249
|
+
disconnects. The unsubscribe is scheduled as a task —
|
|
250
|
+
Subscription.unsubscribe() cancels the message task this handler runs
|
|
251
|
+
in, so it must not be awaited from here.
|
|
252
|
+
"""
|
|
253
|
+
nonlocal active, finished
|
|
254
|
+
if finished:
|
|
255
|
+
return
|
|
256
|
+
finished = True
|
|
257
|
+
active = False
|
|
258
|
+
if on_finished is not None:
|
|
259
|
+
with contextlib.suppress(Exception):
|
|
260
|
+
on_finished()
|
|
261
|
+
if unsub_func is not None:
|
|
262
|
+
asyncio.create_task(unsub_func())
|
|
233
263
|
|
|
234
264
|
# Subscribe to iterator requests
|
|
235
265
|
async def handle_pull_request(msg: PullIteratorRequest) -> None:
|
|
@@ -248,7 +278,10 @@ async def handle_pull_iterator_request(
|
|
|
248
278
|
"id": iterator_id,
|
|
249
279
|
"type": "done",
|
|
250
280
|
}
|
|
251
|
-
|
|
281
|
+
try:
|
|
282
|
+
await client.publish(response_subject, response)
|
|
283
|
+
finally:
|
|
284
|
+
finish()
|
|
252
285
|
elif msg.get("type") == "next":
|
|
253
286
|
try:
|
|
254
287
|
value = await generator.__anext__()
|
|
@@ -264,7 +297,10 @@ async def handle_pull_iterator_request(
|
|
|
264
297
|
"id": iterator_id,
|
|
265
298
|
"type": "done",
|
|
266
299
|
}
|
|
267
|
-
|
|
300
|
+
try:
|
|
301
|
+
await client.publish(response_subject, done_response)
|
|
302
|
+
finally:
|
|
303
|
+
finish()
|
|
268
304
|
|
|
269
305
|
except Exception as e:
|
|
270
306
|
active = False
|
|
@@ -273,14 +309,18 @@ async def handle_pull_iterator_request(
|
|
|
273
309
|
"type": "error",
|
|
274
310
|
"error": format_error_dict(e),
|
|
275
311
|
}
|
|
276
|
-
|
|
312
|
+
try:
|
|
313
|
+
await client.publish(response_subject, error_response)
|
|
314
|
+
finally:
|
|
315
|
+
finish()
|
|
277
316
|
|
|
278
317
|
unsub_func = await client.subscribe(request_subject, handle_pull_request)
|
|
279
318
|
|
|
280
319
|
# Return cleanup function
|
|
281
320
|
async def cleanup() -> None:
|
|
282
|
-
nonlocal active
|
|
321
|
+
nonlocal active, finished
|
|
283
322
|
active = False
|
|
323
|
+
finished = True
|
|
284
324
|
if unsub_func is not None:
|
|
285
325
|
await unsub_func()
|
|
286
326
|
# Ensure generator is closed
|
|
@@ -330,6 +370,7 @@ async def handle_pull_callback_request(
|
|
|
330
370
|
oneway_methods: list[str],
|
|
331
371
|
client: RPCClient,
|
|
332
372
|
io_pool: ThreadPoolExecutor,
|
|
373
|
+
on_finished: Callable[[], Any] | None = None,
|
|
333
374
|
) -> Callable[[], Coroutine[Any, Any, None]]:
|
|
334
375
|
"""Handle a pull-iterator-with-callbacks request.
|
|
335
376
|
|
|
@@ -398,8 +439,27 @@ async def handle_pull_callback_request(
|
|
|
398
439
|
response_subject = f"_rpc.iterator.{iterator_id}.response"
|
|
399
440
|
|
|
400
441
|
active = True
|
|
442
|
+
finished = False
|
|
401
443
|
unsub_func: Callable[[], Coroutine[Any, Any, None]] | None = None
|
|
402
444
|
|
|
445
|
+
def finish() -> None:
|
|
446
|
+
"""Natural end: see handle_pull_iterator_request — drop the request
|
|
447
|
+
subscription and the owner's cleanup-map entry per finished session.
|
|
448
|
+
Unsubscribe is scheduled as a task because Subscription.unsubscribe()
|
|
449
|
+
cancels the message task this handler runs in.
|
|
450
|
+
"""
|
|
451
|
+
nonlocal active, finished
|
|
452
|
+
if finished:
|
|
453
|
+
return
|
|
454
|
+
finished = True
|
|
455
|
+
active = False
|
|
456
|
+
invoker.active = False
|
|
457
|
+
if on_finished is not None:
|
|
458
|
+
with contextlib.suppress(Exception):
|
|
459
|
+
on_finished()
|
|
460
|
+
if unsub_func is not None:
|
|
461
|
+
asyncio.create_task(unsub_func())
|
|
462
|
+
|
|
403
463
|
async def handle_pull_request(msg: PullIteratorRequest) -> None:
|
|
404
464
|
nonlocal active
|
|
405
465
|
|
|
@@ -416,7 +476,10 @@ async def handle_pull_callback_request(
|
|
|
416
476
|
"id": iterator_id,
|
|
417
477
|
"type": "done",
|
|
418
478
|
}
|
|
419
|
-
|
|
479
|
+
try:
|
|
480
|
+
await client.publish(response_subject, response)
|
|
481
|
+
finally:
|
|
482
|
+
finish()
|
|
420
483
|
elif msg.get("type") == "next":
|
|
421
484
|
try:
|
|
422
485
|
# Drive the generator to its next yield. The yielded
|
|
@@ -434,7 +497,10 @@ async def handle_pull_callback_request(
|
|
|
434
497
|
"id": iterator_id,
|
|
435
498
|
"type": "done",
|
|
436
499
|
}
|
|
437
|
-
|
|
500
|
+
try:
|
|
501
|
+
await client.publish(response_subject, done_response)
|
|
502
|
+
finally:
|
|
503
|
+
finish()
|
|
438
504
|
|
|
439
505
|
except Exception as e:
|
|
440
506
|
active = False
|
|
@@ -444,13 +510,17 @@ async def handle_pull_callback_request(
|
|
|
444
510
|
"type": "error",
|
|
445
511
|
"error": format_error_dict(e),
|
|
446
512
|
}
|
|
447
|
-
|
|
513
|
+
try:
|
|
514
|
+
await client.publish(response_subject, error_response)
|
|
515
|
+
finally:
|
|
516
|
+
finish()
|
|
448
517
|
|
|
449
518
|
unsub_func = await client.subscribe(request_subject, handle_pull_request)
|
|
450
519
|
|
|
451
520
|
async def cleanup() -> None:
|
|
452
|
-
nonlocal active
|
|
521
|
+
nonlocal active, finished
|
|
453
522
|
active = False
|
|
523
|
+
finished = True
|
|
454
524
|
invoker.active = False
|
|
455
525
|
if unsub_func is not None:
|
|
456
526
|
await unsub_func()
|
|
@@ -468,6 +538,7 @@ async def handle_callback_request(
|
|
|
468
538
|
request_id: str,
|
|
469
539
|
client: RPCClient,
|
|
470
540
|
io_pool: ThreadPoolExecutor,
|
|
541
|
+
on_finished: Callable[[], Any] | None = None,
|
|
471
542
|
) -> Callable[[], Coroutine[Any, Any, None]]:
|
|
472
543
|
"""Handle callback subscription request."""
|
|
473
544
|
|
|
@@ -510,9 +581,26 @@ async def handle_callback_request(
|
|
|
510
581
|
else:
|
|
511
582
|
handler_cleanup()
|
|
512
583
|
|
|
584
|
+
# Handler cleanup must run exactly once — the cancel message and a later
|
|
585
|
+
# register_handler/disconnect cleanup would otherwise both invoke it.
|
|
586
|
+
cleaned_up = False
|
|
587
|
+
|
|
588
|
+
async def run_handler_cleanup() -> None:
|
|
589
|
+
nonlocal cleaned_up
|
|
590
|
+
if cleaned_up:
|
|
591
|
+
return
|
|
592
|
+
cleaned_up = True
|
|
593
|
+
await invoke_cleanup()
|
|
594
|
+
|
|
513
595
|
# Subscribe to cancel subject
|
|
514
596
|
async def on_cancel(_msg: Any) -> None:
|
|
515
|
-
|
|
597
|
+
if on_finished is not None:
|
|
598
|
+
with contextlib.suppress(Exception):
|
|
599
|
+
on_finished()
|
|
600
|
+
await run_handler_cleanup()
|
|
601
|
+
# Unsubscribe outside this callback's own message task —
|
|
602
|
+
# Subscription.unsubscribe() cancels the task it is called from.
|
|
603
|
+
asyncio.create_task(cancel_unsub())
|
|
516
604
|
|
|
517
605
|
cancel_unsub = await client.subscribe(
|
|
518
606
|
f"{callback_subject}.cancel",
|
|
@@ -522,6 +610,6 @@ async def handle_callback_request(
|
|
|
522
610
|
# Return combined cleanup
|
|
523
611
|
async def cleanup() -> None:
|
|
524
612
|
await cancel_unsub()
|
|
525
|
-
await
|
|
613
|
+
await run_handler_cleanup()
|
|
526
614
|
|
|
527
615
|
return cleanup
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import contextlib
|
|
5
5
|
import json
|
|
6
6
|
from collections.abc import Awaitable, Callable, Coroutine
|
|
7
|
+
from functools import partial
|
|
7
8
|
from typing import TYPE_CHECKING, Any, cast
|
|
8
9
|
|
|
9
10
|
from nats.aio.client import Client as NATSClient
|
|
@@ -19,7 +20,7 @@ from nats.micro.service import (
|
|
|
19
20
|
Service as NATSService,
|
|
20
21
|
)
|
|
21
22
|
|
|
22
|
-
from .codec import decode, encode
|
|
23
|
+
from .codec import decode, decode_message, encode
|
|
23
24
|
from .decorators import extract_nested_methods_with_decorators
|
|
24
25
|
from .handler import (
|
|
25
26
|
format_error_dict,
|
|
@@ -29,6 +30,7 @@ from .handler import (
|
|
|
29
30
|
)
|
|
30
31
|
from .svcm import Svcm
|
|
31
32
|
from .types import ErrorCode, RPCClientOptions
|
|
33
|
+
from .utils import is_async_function
|
|
32
34
|
|
|
33
35
|
if TYPE_CHECKING:
|
|
34
36
|
from .client import RPCClient
|
|
@@ -41,14 +43,20 @@ class RPCService:
|
|
|
41
43
|
self.__client: RPCClient = client
|
|
42
44
|
self.__svc: Svcm | None = None
|
|
43
45
|
self.__services: list[NATSService] = []
|
|
44
|
-
self.
|
|
46
|
+
self.__nc: NATSClient | None = None
|
|
45
47
|
|
|
46
48
|
def init(self, nc: NATSClient) -> None:
|
|
47
|
-
"""Initialize the service manager.
|
|
48
|
-
|
|
49
|
+
"""Initialize the service manager.
|
|
50
|
+
|
|
51
|
+
Re-initializes when the underlying connection changed (suspend() +
|
|
52
|
+
connect() creates a fresh NATS connection) — a Svcm bound to the old,
|
|
53
|
+
closed connection would break service registration after every
|
|
54
|
+
reconnect cycle.
|
|
55
|
+
"""
|
|
56
|
+
if self.__nc is nc:
|
|
49
57
|
return
|
|
50
58
|
|
|
51
|
-
self.
|
|
59
|
+
self.__nc = nc
|
|
52
60
|
self.__svc = Svcm(nc)
|
|
53
61
|
self.__services = []
|
|
54
62
|
|
|
@@ -113,6 +121,9 @@ class RPCService:
|
|
|
113
121
|
|
|
114
122
|
# Add endpoint with auto-encoding/decoding
|
|
115
123
|
def make_endpoint_handler(h: Callable[..., Any]) -> Callable[[Request], Awaitable[None]]:
|
|
124
|
+
# Determined once per endpoint instead of per message.
|
|
125
|
+
h_is_async = is_async_function(h)
|
|
126
|
+
|
|
116
127
|
async def handle_rpc_message(rpc_msg: dict[str, Any], original_msg: Request) -> None:
|
|
117
128
|
"""Handle RPC protocol message."""
|
|
118
129
|
response = {"id": rpc_msg["id"]}
|
|
@@ -162,6 +173,7 @@ class RPCService:
|
|
|
162
173
|
iterator_id,
|
|
163
174
|
service_client,
|
|
164
175
|
service_client.io_pool,
|
|
176
|
+
on_finished=partial(pull_iterator_cleanups.pop, iterator_id, None),
|
|
165
177
|
)
|
|
166
178
|
|
|
167
179
|
# Store cleanup function for later
|
|
@@ -177,6 +189,7 @@ class RPCService:
|
|
|
177
189
|
h,
|
|
178
190
|
rpc_msg.get("params", []),
|
|
179
191
|
service_client.io_pool,
|
|
192
|
+
h_is_async,
|
|
180
193
|
)
|
|
181
194
|
response["result"] = result
|
|
182
195
|
|
|
@@ -246,8 +259,9 @@ class RPCService:
|
|
|
246
259
|
}
|
|
247
260
|
)
|
|
248
261
|
else:
|
|
249
|
-
# Regular message - decode
|
|
250
|
-
|
|
262
|
+
# Regular message - decode wire message (zero-copy
|
|
263
|
+
# binary views into msg.data)
|
|
264
|
+
decoded = decode_message(msg.data)
|
|
251
265
|
await handle_rpc_message(decoded, msg)
|
|
252
266
|
except Exception as e:
|
|
253
267
|
# Send error response
|
|
@@ -198,6 +198,12 @@ class RPCClientOptions(TypedDict):
|
|
|
198
198
|
name: str
|
|
199
199
|
"""Client name for identification"""
|
|
200
200
|
|
|
201
|
+
conn_id: NotRequired[str]
|
|
202
|
+
"""Connection id used as the reply prefix of every call id. When set, the
|
|
203
|
+
muxed reply inbox subscribes ``rpc.reply.<conn_id>.>`` (server-side
|
|
204
|
+
firewalls allowlist exactly that wildcard for browser clients). When
|
|
205
|
+
omitted, a random local prefix is generated instead."""
|
|
206
|
+
|
|
201
207
|
auth: NotRequired[RPCAuthOptions]
|
|
202
208
|
"""Authentication credentials with 'user' and 'pass' keys"""
|
|
203
209
|
|
|
@@ -251,6 +257,13 @@ class RPCMessage(TypedDict):
|
|
|
251
257
|
error: NotRequired[RPCError | None]
|
|
252
258
|
"""Optional error (unused in requests)"""
|
|
253
259
|
|
|
260
|
+
__discover: NotRequired[bool]
|
|
261
|
+
"""Method-discovery request (internal metadata for proxies). When True,
|
|
262
|
+
the responder attaches the namespace's ``__methods`` list to the response.
|
|
263
|
+
Proxies set this only while their method cache is empty. Lives on the
|
|
264
|
+
envelope (not in params) so it never leaks into handler arguments; old
|
|
265
|
+
responders ignore it."""
|
|
266
|
+
|
|
254
267
|
|
|
255
268
|
class RPCResponse(TypedDict):
|
|
256
269
|
"""RPC response message format."""
|