hypha-rpc 0.21.25 → 0.21.28

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.
@@ -86,7 +86,7 @@
86
86
  <div class='footer quiet pad2 space-top1 center small'>
87
87
  Code coverage generated by
88
88
  <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
89
- at 2026-02-26T12:04:54.838Z
89
+ at 2026-02-27T13:29:51.171Z
90
90
  </div>
91
91
  <script src="prettify.js"></script>
92
92
  <script>
@@ -3281,25 +3281,25 @@ async function _connectToServerHTTP(config) {
3281
3281
  // Auto-refresh workspace manager proxy after reconnection.
3282
3282
  // See websocket-client.js for detailed explanation.
3283
3283
  let isInitialRefresh = true;
3284
- rpc.on("manager_refreshed", async ({ manager: internalManager }) => {
3284
+ rpc.on("manager_refreshed", async () => {
3285
3285
  if (isInitialRefresh) {
3286
3286
  isInitialRefresh = false;
3287
3287
  return;
3288
3288
  }
3289
3289
  try {
3290
- const freshWm = internalManager;
3291
- for (const key of Object.keys(freshWm)) {
3292
- if (typeof freshWm[key] === "function") {
3293
- wm[key] = freshWm[key];
3290
+ const newTarget = `*/${rpc._connection.manager_id}`;
3291
+ for (const key of Object.keys(wm)) {
3292
+ if (typeof wm[key] === "function" && wm[key].__rpc_object__) {
3293
+ wm[key].__rpc_object__._rtarget = newTarget;
3294
3294
  }
3295
3295
  }
3296
3296
  console.info(
3297
- "Workspace manager proxy refreshed after reconnection (new manager_id:",
3297
+ "Workspace manager proxy retargeted after reconnection (new manager_id:",
3298
3298
  rpc._connection?.manager_id + ")",
3299
3299
  );
3300
3300
  } catch (err) {
3301
3301
  console.warn(
3302
- "Failed to refresh workspace manager after reconnection:",
3302
+ "Failed to retarget workspace manager after reconnection:",
3303
3303
  err,
3304
3304
  );
3305
3305
  }
@@ -3829,6 +3829,8 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
3829
3829
  };
3830
3830
  // Index: target_id -> Set of top-level session keys for fast cleanup
3831
3831
  this._targetIdIndex = {};
3832
+ // Index: allowed_caller -> Set of _rintf service IDs for lifecycle cleanup
3833
+ this._rintfCallerIndex = {};
3832
3834
  // Track last known manager_id for stale call rejection on reconnection
3833
3835
  this._last_manager_id = null;
3834
3836
 
@@ -4471,23 +4473,55 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
4471
4473
  this._fire("disconnected");
4472
4474
  }
4473
4475
 
4476
+ /**
4477
+ * Unregister a single _rintf service and remove it from the caller index.
4478
+ * Safe to call even if the service was already removed.
4479
+ */
4480
+ _unregisterRintfService(serviceId, allowedCaller) {
4481
+ if (this._services[serviceId]) {
4482
+ delete this._services[serviceId];
4483
+ }
4484
+ if (allowedCaller && this._rintfCallerIndex[allowedCaller]) {
4485
+ this._rintfCallerIndex[allowedCaller].delete(serviceId);
4486
+ if (this._rintfCallerIndex[allowedCaller].size === 0) {
4487
+ delete this._rintfCallerIndex[allowedCaller];
4488
+ }
4489
+ }
4490
+ }
4491
+
4492
+ /**
4493
+ * Remove all _rintf services whose allowed caller is the given client.
4494
+ * Passive lifecycle cleanup: when a client disconnects, any _rintf
4495
+ * callbacks that only it could invoke become dead resources.
4496
+ * @returns {number} Number of _rintf services cleaned up
4497
+ */
4498
+ _cleanupRintfForCaller(clientId) {
4499
+ const serviceIds = this._rintfCallerIndex[clientId];
4500
+ if (!serviceIds) return 0;
4501
+ delete this._rintfCallerIndex[clientId];
4502
+ let cleaned = 0;
4503
+ for (const sid of serviceIds) {
4504
+ if (this._services[sid]) {
4505
+ delete this._services[sid];
4506
+ cleaned++;
4507
+ }
4508
+ }
4509
+ return cleaned;
4510
+ }
4511
+
4474
4512
  async _handleClientDisconnected(clientId) {
4475
4513
  try {
4476
- // console.debug(`Handling disconnection for client: ${clientId}`);
4477
-
4478
4514
  // Clean up all sessions for the disconnected client
4479
4515
  const sessionsCleaned = this._cleanupSessionsForClient(clientId);
4480
4516
 
4481
- if (sessionsCleaned > 0) {
4482
- // console.debug(
4483
- // `Cleaned up ${sessionsCleaned} sessions for disconnected client: ${clientId}`,
4484
- // );
4485
- }
4517
+ // Clean up _rintf services whose allowed caller is the disconnected client
4518
+ const rintfCleaned = this._cleanupRintfForCaller(clientId);
4486
4519
 
4487
4520
  // Fire an event to notify about the client disconnection
4488
4521
  this._fire("remote_client_disconnected", {
4489
4522
  client_id: clientId,
4490
4523
  sessions_cleaned: sessionsCleaned,
4524
+ rintf_cleaned: rintfCleaned,
4491
4525
  });
4492
4526
  } catch (e) {
4493
4527
  console.error(
@@ -4649,6 +4683,7 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
4649
4683
  }
4650
4684
 
4651
4685
  this._targetIdIndex = {};
4686
+ this._rintfCallerIndex = {};
4652
4687
  } catch (e) {
4653
4688
  console.error(`Error during cleanup on disconnect: ${e}`);
4654
4689
  }
@@ -4821,6 +4856,7 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
4821
4856
  visibility,
4822
4857
  authorized_workspaces,
4823
4858
  trusted_keys,
4859
+ rintf_allowed_caller,
4824
4860
  ) {
4825
4861
  if (typeof aObject === "function") {
4826
4862
  // mark the method as a remote method that requires context
@@ -4834,6 +4870,7 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
4834
4870
  visibility: visibility,
4835
4871
  authorized_workspaces: authorized_workspaces,
4836
4872
  trusted_keys: trusted_keys,
4873
+ rintf_allowed_caller: rintf_allowed_caller,
4837
4874
  });
4838
4875
  } else if (aObject instanceof Array || aObject instanceof Object) {
4839
4876
  for (let key of Object.keys(aObject)) {
@@ -4867,6 +4904,7 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
4867
4904
  visibility,
4868
4905
  authorized_workspaces,
4869
4906
  trusted_keys,
4907
+ rintf_allowed_caller,
4870
4908
  );
4871
4909
  }
4872
4910
  }
@@ -4951,6 +4989,7 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
4951
4989
  trusted_keys.add(keyHex);
4952
4990
  }
4953
4991
  }
4992
+ const rintf_allowed_caller = api.config._rintf_allowed_caller || null;
4954
4993
  this._annotate_service_methods(
4955
4994
  api,
4956
4995
  api["id"],
@@ -4959,6 +4998,7 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
4959
4998
  visibility,
4960
4999
  authorized_workspaces,
4961
5000
  trusted_keys,
5001
+ rintf_allowed_caller,
4962
5002
  );
4963
5003
 
4964
5004
  if (this._services[api.id]) {
@@ -5078,6 +5118,13 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
5078
5118
  // Auto-detect _rintf services (local-only, never registered with server)
5079
5119
  if (service_id.startsWith("_rintf_")) {
5080
5120
  notify = false;
5121
+ // Also clean up from the caller index
5122
+ for (const [caller, sids] of Object.entries(this._rintfCallerIndex)) {
5123
+ sids.delete(service_id);
5124
+ if (sids.size === 0) {
5125
+ delete this._rintfCallerIndex[caller];
5126
+ }
5127
+ }
5081
5128
  }
5082
5129
  if (notify) {
5083
5130
  const manager = await this.get_manager_service({
@@ -5425,6 +5472,7 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
5425
5472
 
5426
5473
  // Clear the target_id index since all sessions are removed
5427
5474
  this._targetIdIndex = {};
5475
+ this._rintfCallerIndex = {};
5428
5476
 
5429
5477
  // console.debug(`Force cleaning up ${cleaned_count} sessions`);
5430
5478
  }
@@ -5671,6 +5719,10 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
5671
5719
  function remote_method() {
5672
5720
  return new Promise(async (resolve, reject) => {
5673
5721
  try {
5722
+ // Read target_id from encoded_method at call time (not captured
5723
+ // at generation time) so that _rtarget can be updated after
5724
+ // reconnection without regenerating the method closures.
5725
+ const target_id = encoded_method._rtarget;
5674
5726
  let local_session_id = (0,_utils_index_js__WEBPACK_IMPORTED_MODULE_0__.randId)();
5675
5727
  if (local_parent) {
5676
5728
  // Store the children session under the parent
@@ -6118,6 +6170,25 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
6118
6170
  remote_client_id === this._connection.manager_id
6119
6171
  ) {
6120
6172
  // Access granted
6173
+ }
6174
+ // Allow _rintf callbacks from the specific client they were sent to
6175
+ else if (
6176
+ this._method_annotations.get(method).rintf_allowed_caller
6177
+ ) {
6178
+ const allowed =
6179
+ this._method_annotations.get(method).rintf_allowed_caller;
6180
+ const caller = data.from || "";
6181
+ if (caller !== allowed) {
6182
+ throw new Error(
6183
+ "Permission denied for _rintf callback " +
6184
+ method_name +
6185
+ ", caller " +
6186
+ caller +
6187
+ " is not the allowed caller " +
6188
+ allowed,
6189
+ );
6190
+ }
6191
+ // Access granted — caller matches the _rintf target
6121
6192
  } else {
6122
6193
  throw new Error(
6123
6194
  "Permission denied for invoking protected method " +
@@ -6613,13 +6684,45 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
6613
6684
  )
6614
6685
  ) {
6615
6686
  const serviceId = `_rintf_${(0,_utils_index_js__WEBPACK_IMPORTED_MODULE_0__.randId)()}`;
6687
+ // Resolve the allowed caller from the session's target_id.
6688
+ // Only the client this _rintf is being sent to can call it back.
6689
+ let allowedCaller = null;
6690
+ if (session_id) {
6691
+ const topKey = session_id.split(".")[0];
6692
+ const sessionStore = this._object_store[topKey];
6693
+ if (sessionStore && sessionStore.target_id) {
6694
+ allowedCaller = sessionStore.target_id;
6695
+ }
6696
+ }
6616
6697
  const serviceApi = { id: serviceId };
6698
+ if (allowedCaller) {
6699
+ serviceApi.config = {
6700
+ visibility: "protected",
6701
+ _rintf_allowed_caller: allowedCaller,
6702
+ };
6703
+ }
6704
+
6705
+ // Add _dispose method for active lifecycle management.
6706
+ // The remote side (allowed caller) can call _dispose() to
6707
+ // actively unregister this _rintf service when it's done.
6708
+ const self = this;
6709
+ serviceApi._dispose = () => {
6710
+ self._unregisterRintfService(serviceId, allowedCaller);
6711
+ };
6712
+
6617
6713
  for (const k of Object.keys(aObject)) {
6618
6714
  if (!k.startsWith("_") && typeof aObject[k] === "function") {
6619
6715
  serviceApi[k] = aObject[k];
6620
6716
  }
6621
6717
  }
6622
6718
  this.add_service(serviceApi, true);
6719
+ // Track in caller index for passive cleanup on disconnect
6720
+ if (allowedCaller) {
6721
+ if (!this._rintfCallerIndex[allowedCaller]) {
6722
+ this._rintfCallerIndex[allowedCaller] = new Set();
6723
+ }
6724
+ this._rintfCallerIndex[allowedCaller].add(serviceId);
6725
+ }
6623
6726
  // Store service_id back on the original object so the caller
6624
6727
  // can later call rpc.unregister_service(serviceId) to clean up.
6625
6728
  aObject._rintf_service_id = serviceId;
@@ -6632,6 +6735,12 @@ class RPC extends _utils_index_js__WEBPACK_IMPORTED_MODULE_0__.MessageEmitter {
6632
6735
  local_workspace,
6633
6736
  );
6634
6737
  }
6738
+ // Encode _dispose so the remote side can call it
6739
+ bObject._dispose = await this._encode(
6740
+ serviceApi._dispose,
6741
+ session_id,
6742
+ local_workspace,
6743
+ );
6635
6744
  bObject._rintf_service_id = serviceId;
6636
6745
  return bObject;
6637
6746
  }
@@ -10861,50 +10970,34 @@ async function connectToServer(config) {
10861
10970
  wm.rpc = rpc;
10862
10971
 
10863
10972
  // Auto-refresh workspace manager proxy after reconnection.
10864
- // When the server restarts, it assigns a new manager_id. The wm proxy
10865
- // returned to the caller has methods bound to the old manager_id.
10866
- //
10867
- // The RPC layer fires "manager_refreshed" IMMEDIATELY after getting the
10868
- // fresh manager service before service re-registration. This minimizes
10869
- // the window where stale methods exist (~100ms instead of ~2-3s).
10870
- //
10871
- // Combined with the RPC layer's immediate rejection of pending calls to
10872
- // the old manager_id, recovery is near-instant.
10973
+ // When the server restarts, it assigns a new manager_id. The remote
10974
+ // methods on wm have their target baked into __rpc_object__._rtarget.
10975
+ // Since remote_method() reads _rtarget at call time (not generation
10976
+ // time), we just update _rtarget on every existing method — no need
10977
+ // to regenerate methods or copy from a fresh proxy. This preserves
10978
+ // locally-overridden methods (registerService, unregisterService, etc.)
10979
+ // that would otherwise be lost by wholesale function copying.
10873
10980
  let isInitialRefresh = true;
10874
- rpc.on("manager_refreshed", async ({ manager: internalManager }) => {
10981
+ rpc.on("manager_refreshed", async () => {
10875
10982
  if (isInitialRefresh) {
10876
10983
  isInitialRefresh = false;
10877
10984
  return; // Skip the first event (initial connection, wm is already fresh)
10878
10985
  }
10879
10986
  try {
10880
- let freshWm;
10881
- if (config.kwargs_expansion) {
10882
- // kwargs_expansion changes the method signatures, so we need to
10883
- // fetch a new manager with matching config
10884
- freshWm = await rpc.get_manager_service({
10885
- timeout: config.method_timeout || 30,
10886
- case_conversion: "camel",
10887
- kwargs_expansion: config.kwargs_expansion,
10888
- });
10889
- } else {
10890
- // The internal manager already uses case_conversion: "camel",
10891
- // so we can copy directly without an extra RPC call
10892
- freshWm = internalManager;
10893
- }
10894
- // Copy all function properties from fresh wm onto existing wm object.
10895
- // This preserves the caller's reference while updating method targets.
10896
- for (const key of Object.keys(freshWm)) {
10897
- if (typeof freshWm[key] === "function") {
10898
- wm[key] = freshWm[key];
10987
+ const newTarget = `*/${rpc._connection.manager_id}`;
10988
+ // Retarget all remote methods on the wm proxy to the new manager
10989
+ for (const key of Object.keys(wm)) {
10990
+ if (typeof wm[key] === "function" && wm[key].__rpc_object__) {
10991
+ wm[key].__rpc_object__._rtarget = newTarget;
10899
10992
  }
10900
10993
  }
10901
10994
  console.info(
10902
- "Workspace manager proxy refreshed after reconnection (new manager_id:",
10995
+ "Workspace manager proxy retargeted after reconnection (new manager_id:",
10903
10996
  rpc._connection?.manager_id + ")",
10904
10997
  );
10905
10998
  } catch (err) {
10906
10999
  console.warn(
10907
- "Failed to refresh workspace manager after reconnection:",
11000
+ "Failed to retarget workspace manager after reconnection:",
10908
11001
  err,
10909
11002
  );
10910
11003
  }