@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.
Files changed (148) hide show
  1. package/dist/interface/assets/{404-CJ99-HuF.js → 404-BAYYYl25.js} +1 -1
  2. package/dist/interface/assets/{About-B0RkqPCF.js → About-CF96M6HY.js} +1 -1
  3. package/dist/interface/assets/{Adminpanel-BzjRR4E-.js → Adminpanel-DOaJyAyb.js} +1 -1
  4. package/dist/interface/assets/{Automation-BxZOk7_I.js → Automation-CKXlldCc.js} +2 -2
  5. package/dist/interface/assets/{Automations-DCnYU4ec.js → Automations-Bly4du8g.js} +1 -1
  6. package/dist/interface/assets/{Camera-uZPZMDm6.js → Camera-B_iqc3Zu.js} +2 -2
  7. package/dist/interface/assets/{CameraConsole-C_TUwy4O.js → CameraConsole-DmkR-ATY.js} +1 -1
  8. package/dist/interface/assets/{Cameras-CCjDWbDk.js → Cameras-CBJqTSnN.js} +1 -1
  9. package/dist/interface/assets/{Camview-DGtE5Z3s.js → Camview-CF_Fa0E1.js} +1 -1
  10. package/dist/interface/assets/{Config-QQ7HALtt.js → Config-B10TESNa.js} +1 -1
  11. package/dist/interface/assets/{ConfigActionCameraControl-C7ZfjHnE.js → ConfigActionCameraControl-BWpRxipc.js} +1 -1
  12. package/dist/interface/assets/{ConfigActionDelay-C9zOKCqa.js → ConfigActionDelay-NBOa8ulm.js} +1 -1
  13. package/dist/interface/assets/{ConfigActionHttp-zeFP7jW9.js → ConfigActionHttp-CpeMles1.js} +1 -1
  14. package/dist/interface/assets/{ConfigActionImageInput-D19IiOQh.js → ConfigActionImageInput-He5suR1p.js} +1 -1
  15. package/dist/interface/assets/{ConfigActionNotification-vKL_FJi6.js → ConfigActionNotification-BrYGUn_B.js} +1 -1
  16. package/dist/interface/assets/{ConfigActionOutput-C9P9NVNR.js → ConfigActionOutput-Dy32ypu0.js} +1 -1
  17. package/dist/interface/assets/{ConfigActionPlugin-BfjaRyTe.js → ConfigActionPlugin-DZgDVj2J.js} +1 -1
  18. package/dist/interface/assets/{ConfigActionSensor-BGIRJ1VF.js → ConfigActionSensor-B9aJwfb2.js} +1 -1
  19. package/dist/interface/assets/{ConfigActionSnapshot-ClUBwM1R.js → ConfigActionSnapshot-Bisnu1Va.js} +1 -1
  20. package/dist/interface/assets/{ConfigActionVariable-COV3XNCP.js → ConfigActionVariable-BcQ-pUs-.js} +1 -1
  21. package/dist/interface/assets/{ConfigConditionIfElse-CRzCscc-.js → ConfigConditionIfElse-DKyGnLRO.js} +1 -1
  22. package/dist/interface/assets/{ConfigConditionSensorState-DZJkEOJX.js → ConfigConditionSensorState-mKh6F8lJ.js} +1 -1
  23. package/dist/interface/assets/{ConfigConditionSwitch-CY52JGdW.js → ConfigConditionSwitch-CSZTkyIE.js} +1 -1
  24. package/dist/interface/assets/{ConfigConditionTime-o7CY--td.js → ConfigConditionTime-C8_R0ITE.js} +1 -1
  25. package/dist/interface/assets/{ConfigTriggerDetection-DRM0HbdD.js → ConfigTriggerDetection-a2NV6Ke1.js} +1 -1
  26. package/dist/interface/assets/{ConfigTriggerGeofence-CJi5c7cN.js → ConfigTriggerGeofence-DnS6ONJC.js} +1 -1
  27. package/dist/interface/assets/{ConfigTriggerManual-B8iZq1a7.js → ConfigTriggerManual-CrKYDCVp.js} +1 -1
  28. package/dist/interface/assets/{ConfigTriggerSchedule-sG8s23os.js → ConfigTriggerSchedule-Dc6k-hJ2.js} +1 -1
  29. package/dist/interface/assets/{ConfigTriggerSensor-Dc9qP7jS.js → ConfigTriggerSensor-lmBAbpAF.js} +1 -1
  30. package/dist/interface/assets/{ConfigTriggerSystem-wZjLxEWq.js → ConfigTriggerSystem-BibBSkft.js} +1 -1
  31. package/dist/interface/assets/{ConfigTriggerWebhook-BpWO2kXx.js → ConfigTriggerWebhook-CcMI4u82.js} +1 -1
  32. package/dist/interface/assets/{Console-B79GknLp.js → Console-CiqXek8C.js} +1 -1
  33. package/dist/interface/assets/{CuiActionButton-DaeBGbij.js → CuiActionButton-DmV0jhr5.js} +1 -1
  34. package/dist/interface/assets/{CuiAvatar-CMR4MZZv.js → CuiAvatar-DMZqcLoE.js} +1 -1
  35. package/dist/interface/assets/{CuiBBoxPlayground-BJxrh8Hk.js → CuiBBoxPlayground-BakfNGCT.js} +1 -1
  36. package/dist/interface/assets/CuiBottomSheet-BJl4lLzC.js +1 -0
  37. package/dist/interface/assets/{CuiBottomSheet-DmanZWbm.js → CuiBottomSheet-CkJEMXQy.js} +1 -1
  38. package/dist/interface/assets/CuiCameraCard-jWlEnJAQ.js +1 -0
  39. package/dist/interface/assets/{CuiCameraPipCard-BvJJFMV2.js → CuiCameraPipCard-ClOJg9E-.js} +1 -1
  40. package/dist/interface/assets/CuiCameraRecordings-BhM5UivW.js +1 -0
  41. package/dist/interface/assets/{CuiCameraShares-BNyn9lMv.js → CuiCameraShares-CktUxrlR.js} +1 -1
  42. package/dist/interface/assets/{CuiCameraSnapshot-DzweaAbD.js → CuiCameraSnapshot-BE1xeM4q.js} +1 -1
  43. package/dist/interface/assets/{CuiCameraTable-CCu87mix.js → CuiCameraTable-D87v9LhC.js} +1 -1
  44. package/dist/interface/assets/{CuiDataTable-Q0cMKDSG.js → CuiDataTable-C5toN4L-.js} +1 -1
  45. package/dist/interface/assets/{CuiFloatingButton-CLb4OXWq.js → CuiFloatingButton-DvdpuxUr.js} +1 -1
  46. package/dist/interface/assets/{CuiImage-OMrwpQrB.js → CuiImage-C-DSHyf2.js} +1 -1
  47. package/dist/interface/assets/{CuiMenu-BXurM_rM.js → CuiMenu-CwumqO_L.js} +1 -1
  48. package/dist/interface/assets/{CuiPluginCard-o4XyKYKV.js → CuiPluginCard-DIqaLKcf.js} +2 -2
  49. package/dist/interface/assets/{CuiPluginOAuthButton-CT_taZrW.js → CuiPluginOAuthButton-CBTeQEb9.js} +2 -2
  50. package/dist/interface/assets/{CuiQRCode-Dv03yW5H.js → CuiQRCode-ChJuYXmm.js} +1 -1
  51. package/dist/interface/assets/{CuiRecordingsGrid-34f2-J9w.js → CuiRecordingsGrid-D1mYCCcE.js} +1 -1
  52. package/dist/interface/assets/{CuiSchema-DPegn_DL.js → CuiSchema-BIGweVJy.js} +1 -1
  53. package/dist/interface/assets/{CuiTopNavbarItem-D1yZXVK4.js → CuiTopNavbarItem-FXY4aBoC.js} +1 -1
  54. package/dist/interface/assets/{CuiTopbarSlot-D8e3DC_V.js → CuiTopbarSlot-DCeAqmkQ.js} +1 -1
  55. package/dist/interface/assets/{Faces-CNCw8AXF.js → Faces-B6JlzZ5i.js} +1 -1
  56. package/dist/interface/assets/{FirstSteps-Dcr9xd_H.js → FirstSteps-CH1iro2p.js} +1 -1
  57. package/dist/interface/assets/{Instances-DCrJqqdk.js → Instances-BNNA4Z6H.js} +1 -1
  58. package/dist/interface/assets/{Logs-9KBZQ4SH.js → Logs-BkF4EFHV.js} +1 -1
  59. package/dist/interface/assets/{Menu-D82CRc95.js → Menu-DYejNhsi.js} +1 -1
  60. package/dist/interface/assets/{OAuthDeviceFlow-C6sk2XbU.js → OAuthDeviceFlow-BVeSCJiJ.js} +1 -1
  61. package/dist/interface/assets/{Plugin-DY1-hTa-.js → Plugin-zvdoGW2r.js} +1 -1
  62. package/dist/interface/assets/{PluginConsole-D8K2fOf6.js → PluginConsole-CQA2A5Wc.js} +1 -1
  63. package/dist/interface/assets/{PluginSchema-JKttwvkD.js → PluginSchema-xof7AYIK.js} +1 -1
  64. package/dist/interface/assets/{Plugins-5y2A-S9R.js → Plugins-DAwDjr_I.js} +2 -2
  65. package/dist/interface/assets/Recordings-D7UhoAKi.js +1 -0
  66. package/dist/interface/assets/{Settings-BQSZwEux.js → Settings-CnU98cZv.js} +1 -1
  67. package/dist/interface/assets/{SettingsAccount-7HSGeh_G.js → SettingsAccount-BVIztMFL.js} +1 -1
  68. package/dist/interface/assets/SettingsAccount-BmuJLRLH.js +1 -0
  69. package/dist/interface/assets/{SettingsAppearance-DAtwb17b.js → SettingsAppearance-CW8mbN9Q.js} +1 -1
  70. package/dist/interface/assets/SettingsAppearance-CiVRn_RM.js +1 -0
  71. package/dist/interface/assets/{SettingsBackup-Bj2pUzxN.js → SettingsBackup-DDNiOgvA.js} +1 -1
  72. package/dist/interface/assets/{SettingsNotifications-Bk40tvnT.js → SettingsNotifications-DLMT1o4I.js} +1 -1
  73. package/dist/interface/assets/{SettingsRecordings-BfbVsR6a.js → SettingsRecordings-DirvHeKB.js} +1 -1
  74. package/dist/interface/assets/{SettingsRemote-CPq1K5z8.js → SettingsRemote-CKWsHa5R.js} +1 -1
  75. package/dist/interface/assets/{SettingsSystem-C9qSkCyX.js → SettingsSystem-D9ZC11Mh.js} +2 -2
  76. package/dist/interface/assets/{SettingsUsers-BpwM-HIU.js → SettingsUsers-pcJeXZKp.js} +1 -1
  77. package/dist/interface/assets/{Terminal-BB9MM3FG.js → Terminal-Cu3uC8EZ.js} +1 -1
  78. package/dist/interface/assets/{VariableInput-YR8KBCWL.js → VariableInput-Bwu9vChK.js} +1 -1
  79. package/dist/interface/assets/{VariableSuggestions-BF2MDSW8.js → VariableSuggestions-l2MLjZre.js} +1 -1
  80. package/dist/interface/assets/{VersionsHandler-D2lhMYGG.js → VersionsHandler-BTxJHwdS.js} +1 -1
  81. package/dist/interface/assets/{Workers-Cn-kFp5K.js → Workers-BsEDKzmG.js} +1 -1
  82. package/dist/interface/assets/{api-BSSGwZUW.js → api-C3OjKngj.js} +1 -1
  83. package/dist/interface/assets/api-CFbWrm1k.js +2 -0
  84. package/dist/interface/assets/{auth-DuxuZzUv.js → auth-C5IUhkWS.js} +1 -1
  85. package/dist/interface/assets/{automations-BjPRCiP3.js → automations-DD8bCSnT.js} +1 -1
  86. package/dist/interface/assets/{automations-D4BRxXp2.js → automations-DusabINi.js} +1 -1
  87. package/dist/interface/assets/{backup-B01_Kkb-.js → backup-oG81xIPA.js} +1 -1
  88. package/dist/interface/assets/{cameras-B4_XIEvq.js → cameras-CmFEgULx.js} +1 -1
  89. package/dist/interface/assets/{cameras.schema-BJtNPf50.js → cameras.schema-iBaEWm0Y.js} +1 -1
  90. package/dist/interface/assets/cloudHandoff-BzwN18OZ.js +1 -0
  91. package/dist/interface/assets/{deviceId-Csqx7wv1.js → deviceId-opaLeE0R.js} +1 -1
  92. package/dist/interface/assets/{hasPermission-C2RWjXuT.js → hasPermission-ChIspc-L.js} +1 -1
  93. package/dist/interface/assets/{instances-Etl_yihq.js → instances-ykXfoA2u.js} +1 -1
  94. package/dist/interface/assets/{locale-BslWmL0t.js → locale-BliTvqss.js} +1 -1
  95. package/dist/interface/assets/main-DiUHKjqR.js +3 -0
  96. package/dist/interface/assets/{notifications-SVoDimTP.js → notifications-BQyPgfnE.js} +1 -1
  97. package/dist/interface/assets/{plugins-VMgnZZzG.js → plugins-BFzZB-8S.js} +1 -1
  98. package/dist/interface/assets/{round-restart-alt-D3SOXMVf.js → round-restart-alt-C4TFl1Rk.js} +1 -1
  99. package/dist/interface/assets/{router-BqJLYjxq.js → router-BzaHrqQ7.js} +2 -2
  100. package/dist/interface/assets/{routerState-g4QLPAT2.js → routerState-UESa7YsN.js} +1 -1
  101. package/dist/interface/assets/{server-8uNYIjzr.js → server-7nJbrqvt.js} +1 -1
  102. package/dist/interface/assets/{shares-DsjePQGf.js → shares-BokQ1O4D.js} +1 -1
  103. package/dist/interface/assets/{theme-DN8GOT_w.js → theme-iSxYm9Ao.js} +1 -1
  104. package/dist/interface/assets/{types-B39ppbYi.js → types-CVp3a4-E.js} +1 -1
  105. package/dist/interface/assets/{ui-CEzY0rCW.js → ui-CVUMjabl.js} +1 -1
  106. package/dist/interface/assets/{useBiometricCredentials-BWuRf0H0.js → useBiometricCredentials-BTSuyr45.js} +1 -1
  107. package/dist/interface/assets/{useCameraOptions-DLmEgMil.js → useCameraOptions-CuIFFMce.js} +1 -1
  108. package/dist/interface/assets/{useCuiCameraDrawer-PokkyjqH.js → useCuiCameraDrawer-DHbL2uYT.js} +1 -1
  109. package/dist/interface/assets/{useCuiDialog-BEerzsp3.js → useCuiDialog-DmnaFxId.js} +1 -1
  110. package/dist/interface/assets/{useFileDownload-BzheFQ77.js → useFileDownload-D7uRiFqP.js} +1 -1
  111. package/dist/interface/assets/{useLogsSocket-CeW070HG.js → useLogsSocket-CA2uq1kZ.js} +1 -1
  112. package/dist/interface/assets/{useSharedComposables-B5-Uy9Br.js → useSharedComposables-Dsn2uiyW.js} +1 -1
  113. package/dist/interface/assets/{useStreamStatus-BPixdI1Y.js → useStreamStatus-Dun63PvC.js} +1 -1
  114. package/dist/interface/assets/{users.schema-D443yTkQ.js → users.schema-COvF8XP0.js} +1 -1
  115. package/dist/interface/assets/vendor-cameraui-B1JU4jwS.js +1978 -0
  116. package/dist/interface/assets/{vendor-core-DSNqTlQH.js → vendor-core-BxjZmyZH.js} +1 -1
  117. package/dist/interface/assets/{vendor-primevue-CWF_imvX.js → vendor-primevue-gmNTz6qq.js} +1 -1
  118. package/dist/interface/assets/{view-grid-BE5Jf7kM.js → view-grid-C3peaC6C.js} +1 -1
  119. package/dist/interface/index.html +40 -40
  120. package/dist/interface/sw.js +1 -1
  121. package/dist/server/package.json +3 -3
  122. package/dist/server/requirements.txt +1 -1
  123. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/channel.py +29 -23
  124. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/chunking.py +52 -5
  125. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/client.py +682 -231
  126. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/codec.py +277 -18
  127. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/handler.py +105 -17
  128. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/service.py +21 -7
  129. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/types.py +13 -0
  130. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/utils.py +50 -15
  131. package/dist/server/src/api/plugins/natsSubFirewall.d.ts +1 -0
  132. package/dist/server/src/api/plugins/natsSubFirewall.js +9 -1
  133. package/dist/server/src/api/plugins/natsSubFirewall.js.map +1 -1
  134. package/dist/server/src/api/plugins/proxy.plugin.js +3 -0
  135. package/dist/server/src/api/plugins/proxy.plugin.js.map +1 -1
  136. package/dist/server/src/rpc/server.js +6 -3
  137. package/dist/server/src/rpc/server.js.map +1 -1
  138. package/package.json +3 -3
  139. package/dist/interface/assets/CuiBottomSheet-3-36plJ0.js +0 -1
  140. package/dist/interface/assets/CuiCameraCard-DYkArUIE.js +0 -1
  141. package/dist/interface/assets/CuiCameraRecordings-qS2sljLl.js +0 -1
  142. package/dist/interface/assets/Recordings-Bdii8y2w.js +0 -1
  143. package/dist/interface/assets/SettingsAccount-pc1G5uya.js +0 -1
  144. package/dist/interface/assets/SettingsAppearance-CDlFqm-u.js +0 -1
  145. package/dist/interface/assets/api-CPonLnGH.js +0 -2
  146. package/dist/interface/assets/cloudHandoff-DR4OiIia.js +0 -1
  147. package/dist/interface/assets/main-QYfiMUtP.js +0 -3
  148. 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
- # OPT_DATETIME_AS_TIMESTAMP_EXT: Encode datetime as MessagePack timestamp extension
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
- data,
71
- option=ormsgpack.OPT_DATETIME_AS_TIMESTAMP_EXT | ormsgpack.OPT_NON_STR_KEYS,
72
- ext_hook=_ext_hook,
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 is_async_function(handler):
159
+ if handler_is_async:
149
160
  return await handler(*params)
150
- else:
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
- unsub_func = None
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
- await client.publish(response_subject, response)
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
- await client.publish(response_subject, done_response)
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
- await client.publish(response_subject, error_response)
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
- await client.publish(response_subject, response)
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
- await client.publish(response_subject, done_response)
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
- await client.publish(response_subject, error_response)
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
- await invoke_cleanup()
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 invoke_cleanup()
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.__initialized: bool = False
46
+ self.__nc: NATSClient | None = None
45
47
 
46
48
  def init(self, nc: NATSClient) -> None:
47
- """Initialize the service manager."""
48
- if self.__initialized:
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.__initialized = True
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 as RPC message
250
- decoded = decode(msg.data)
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."""