diff --git a/README.rst b/README.rst index efb93c5..cb3a328 100644 --- a/README.rst +++ b/README.rst @@ -66,14 +66,17 @@ Benefits Structure Events ---------------- -On the root node, ``subscribeToStructure`` tracks application lifecycle with three event types: +On the root node, ``subscribeToStructure`` tracks application lifecycle: - ``studio.api.structure.ADD`` (1) — An application appeared for the first time -- ``studio.api.structure.REMOVE`` (0) — An application went offline +- ``studio.api.structure.DISCONNECT`` (3) — An application went offline (may reconnect) - ``studio.api.structure.RECONNECT`` (2) — An application restarted (was seen before, went offline, came back) -On other nodes, ADD and REMOVE fire when children are added or removed at runtime. -RECONNECT only fires at the root level. +On other nodes, ``subscribeToStructure`` fires when children are added or removed +(e.g. components or operators added to a running application): + +- ``studio.api.structure.ADD`` (1) — A child node was added +- ``studio.api.structure.REMOVE`` (0) — A child node was removed When an app restarts, the client automatically restores value and event subscriptions, so user code does not need to re-subscribe. RECONNECT is informational — use it for @@ -89,7 +92,7 @@ logging or UI updates. node.subscribeToValues(v => console.log(`[${appName}] CPULoad: ${v}`)); }).catch(err => console.error(`Failed to find ${appName}.CPULoad:`, err)); } - if (change === studio.api.structure.REMOVE) { + if (change === studio.api.structure.DISCONNECT) { console.log(`App offline: ${appName}`); } if (change === studio.api.structure.RECONNECT) { @@ -656,9 +659,10 @@ node.subscribeToStructure(structureConsumer) - Usage - Subscribe to structure changes on this node. Each time a child is added or removed, - structureConsumer is called with the child name and change (ADD == 1, REMOVE == 0). - On the root node, RECONNECT (2) fires when a previously-seen application restarts. + Subscribe to structure changes on this node. + On the root node: ADD (1) when an app appears, DISCONNECT (3) when it goes offline, + RECONNECT (2) when it restarts. On other nodes: ADD (1) when a child is added, + REMOVE (0) when a child is removed. node.unsubscribeFromStructure(structureConsumer) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/index.js b/index.js index f7cc9cc..c89d6ac 100644 --- a/index.js +++ b/index.js @@ -456,7 +456,8 @@ studio.internal = (function(proto) { obj.structure = { REMOVE: 0, ADD: 1, - RECONNECT: 2 + RECONNECT: 2, + DISCONNECT: 3 }; const STRUCTURE_REQUEST_TIMEOUT_MS = 30000; @@ -803,7 +804,7 @@ studio.internal = (function(proto) { var everSeenApps = new Set(); var pendingFindWaiters = []; // for find() waiting on late apps var pendingFetches = []; - var connectionLocalApps = new Map(); // Maps AppConnection → local app name (direct mode) + var connectionLocalApps = new Map(); // Maps AppConnection → local app name var this_ = this; function isApplicationNode(node) { @@ -907,7 +908,7 @@ studio.internal = (function(proto) { function unannounceApp(appName) { if (!announcedApps.has(appName)) return; announcedApps.delete(appName); - notifyStructure(appName, obj.structure.REMOVE); + notifyStructure(appName, obj.structure.DISCONNECT); } function notifyApplications(connection) { @@ -924,8 +925,15 @@ studio.internal = (function(proto) { var primaryConn = appConnections[0]; var isProxyMode = primaryConn && primaryConn.supportsProxyProtocol(); + // Track which app this connection owns (needed for DISCONNECT on connection loss) + system.forEachChild(function(app) { + if (isApplicationNode(app)) { + connectionLocalApps.set(connection, app.name()); + } + }); + if (isProxyMode) { - // Proxy mode: only handle REMOVE here. ADD/RECONNECT is deferred to + // Proxy mode: handle server-side child REMOVE here. ADD/RECONNECT is deferred to // notifyApplications() after the proxy tunnel connects (via // tryConnectPendingSiblings → connectViaProxy), ensuring the sibling // is actually reachable before announcing it. @@ -934,14 +942,6 @@ studio.internal = (function(proto) { unannounceApp(appName); } }); - } else { - // Direct mode: each connection owns its local app. - // Connection lifecycle directly maps to app lifecycle. - system.forEachChild(function(app) { - if (isApplicationNode(app)) { - connectionLocalApps.set(connection, app.name()); - } - }); } resolve(system); @@ -953,7 +953,7 @@ studio.internal = (function(proto) { var appConnection = new obj.AppConnection(url, notificationListener, autoConnect); appConnections.push(appConnection); - // Direct mode lifecycle: connection close → REMOVE, reconnect → RECONNECT + // Direct mode lifecycle: connection close → DISCONNECT, reconnect → RECONNECT appConnection.onDisconnected = function() { var localApp = connectionLocalApps.get(appConnection); if (localApp) unannounceApp(localApp); @@ -2431,7 +2431,8 @@ studio.api = (function(internal) { * * @callback structureConsumer * @param {string} node name - * @param {number} change - ADD (1), REMOVE (0), or RECONNECT (2) from studio.api.structure + * @param {number} change - At root level: ADD (1), DISCONNECT (3), or RECONNECT (2). + * At other nodes: ADD (1) or REMOVE (0). See studio.api.structure. */ /** @@ -2547,7 +2548,7 @@ studio.api = (function(internal) { var findNodeCacheInvalidator = null; // Set after findNodeCache is created var system = new internal.SystemNode(studioURL, notificationListener, function(appName) { - // Called on app structure changes (ADD, REMOVE, or RECONNECT) + // Called on app structure changes (ADD, DISCONNECT, or RECONNECT) findNodeCacheInvalidator(appName); }); diff --git a/test/find-and-structure-events.test.js b/test/find-and-structure-events.test.js index ff2df8f..97db6af 100644 --- a/test/find-and-structure-events.test.js +++ b/test/find-and-structure-events.test.js @@ -1,25 +1,26 @@ /** - * find() wait semantics and RECONNECT structure event tests + * find() wait semantics and structure event constant tests * - * Unit tests for the new public API surfaces. Tests that require + * Unit tests for the public API surfaces. Tests that require * a live connection (find() timeout behavior, subscribeToStructure - * RECONNECT events) are covered by the component tests in the + * lifecycle events) are covered by the component tests in the * parent cdp monorepo. */ global.WebSocket = require('ws'); const studio = require('../index'); -describe('RECONNECT structure constant', () => { - test('studio.api.structure has ADD, REMOVE, and RECONNECT with correct values', () => { +describe('structure event constants', () => { + test('studio.api.structure has ADD, REMOVE, RECONNECT, and DISCONNECT with correct values', () => { expect(studio.api.structure.ADD).toBe(1); expect(studio.api.structure.REMOVE).toBe(0); expect(studio.api.structure.RECONNECT).toBe(2); + expect(studio.api.structure.DISCONNECT).toBe(3); }); - test('RECONNECT is distinct from ADD and REMOVE', () => { - const values = [studio.api.structure.ADD, studio.api.structure.REMOVE, studio.api.structure.RECONNECT]; - expect(new Set(values).size).toBe(3); + test('all structure constants are distinct', () => { + const values = [studio.api.structure.ADD, studio.api.structure.REMOVE, studio.api.structure.RECONNECT, studio.api.structure.DISCONNECT]; + expect(new Set(values).size).toBe(4); }); });