Skip to content

Commit 5561e9d

Browse files
authored
fix: support custom namespace prefix for electron IPC (#1253)
This PR will implement support for running multiple LDClients with the same mobile key. We do this by adding an optional `namespace` option that will prefix IPC namespace to guard against any collisions when running 2 clients at once. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes how IPC channel names are derived for the Electron SDK and updates renderer/main wiring; misconfiguration or mismatched namespaces could break renderer↔main communication for existing multi-client setups. > > **Overview** > Adds an optional `namespace` option to the Electron SDK to isolate IPC channel names so multiple clients can run in the same process even when sharing the same credential. > > Main-process `ElectronClient` now derives an IPC namespace via new `deriveNamespace()` and registers all IPC handlers under that derived value; renderer-side `ElectronRendererClient`/`createRendererClient` accept the same optional namespace to connect to the matching channels. > > Updates option validation and migration docs, and refactors/adds tests (including a new `ElectronIPC.test.ts` plus shared `createMockLogger`) to cover namespace derivation and the updated IPC wiring. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 793a40c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/launchdarkly/js-core/pull/1253" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
1 parent 7f5f468 commit 5561e9d

14 files changed

Lines changed: 205 additions & 396 deletions

File tree

packages/sdk/electron/__tests__/ElectronClient.ipcMain.test.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import type {
66
LDEvaluationDetail,
77
LDEvaluationDetailTyped,
88
LDIdentifyOptions,
9-
LDLogger,
109
} from '@launchdarkly/js-client-sdk-common';
1110

1211
import { ElectronClient } from '../src/ElectronClient';
13-
import { getIPCChannelName } from '../src/ElectronIPC';
12+
import { deriveNamespace, getIPCChannelName } from '../src/ElectronIPC';
1413
import ElectronCrypto from '../src/platform/ElectronCrypto';
1514
import ElectronEncoding from '../src/platform/ElectronEncoding';
1615
import ElectronInfo from '../src/platform/ElectronInfo';
16+
import { createMockLogger } from './testHelpers';
1717

1818
type MockIpcMain = IpcMain & {
1919
getHandler: (eventName: string) => Function | undefined;
@@ -63,7 +63,7 @@ const mockPort: MockPort = {
6363
};
6464

6565
const getEventName = (baseName: Parameters<typeof getIPCChannelName>[1]) =>
66-
getIPCChannelName(clientSideId, baseName);
66+
getIPCChannelName(deriveNamespace(clientSideId), baseName);
6767

6868
const DEFAULT_INITIAL_CONTEXT = { kind: 'user' as const, key: 'test-user' };
6969

@@ -72,12 +72,7 @@ beforeEach(() => {
7272
});
7373

7474
describe('given an initialized ElectronClient', () => {
75-
const logger: LDLogger = {
76-
debug: jest.fn(),
77-
info: jest.fn(),
78-
warn: jest.fn(),
79-
error: jest.fn(),
80-
};
75+
const logger = createMockLogger();
8176

8277
const client = new ElectronClient(clientSideId, DEFAULT_INITIAL_CONTEXT, {
8378
initialConnectionMode: 'offline',
@@ -514,12 +509,7 @@ describe('given an initialized ElectronClient', () => {
514509
});
515510

516511
describe('close()', () => {
517-
const logger: LDLogger = {
518-
debug: jest.fn(),
519-
info: jest.fn(),
520-
warn: jest.fn(),
521-
error: jest.fn(),
522-
};
512+
const logger = createMockLogger();
523513

524514
it('removes all ipcMain listeners and handlers for the client so channels are no longer registered', async () => {
525515
const client = new ElectronClient(clientSideId, DEFAULT_INITIAL_CONTEXT, {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { deriveNamespace, getIPCChannelName } from '../src/ElectronIPC';
2+
3+
it('derives namespace from credential alone', () => {
4+
expect(deriveNamespace('mob-abc-123')).toBe('mob-abc-123');
5+
});
6+
7+
it('derives namespace from credential with custom namespace', () => {
8+
expect(deriveNamespace('mob-abc-123', 'my-namespace')).toBe('my-namespace_mob-abc-123');
9+
});
10+
11+
it('produces different namespaces with and without custom namespace', () => {
12+
const credential = 'mob-abc-123';
13+
expect(deriveNamespace(credential)).not.toBe(deriveNamespace(credential, 'ns'));
14+
});
15+
16+
it('produces different namespaces for different custom namespaces', () => {
17+
const credential = 'mob-abc-123';
18+
expect(deriveNamespace(credential, 'ns-a')).not.toBe(deriveNamespace(credential, 'ns-b'));
19+
});
20+
21+
it('undefined namespace equals no namespace', () => {
22+
const credential = 'mob-abc-123';
23+
expect(deriveNamespace(credential, undefined)).toBe(deriveNamespace(credential));
24+
});
25+
26+
it('builds IPC channel names', () => {
27+
expect(getIPCChannelName('ns', 'allFlags')).toBe('ld:ns:allFlags');
28+
});

packages/sdk/electron/__tests__/bridge/LDClientBridge.test.ts

Lines changed: 20 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { ipcRenderer } from 'electron';
22

33
import '../../src/bridge';
44
import type { LDClientBridge } from '../../src/bridge/LDClientBridge';
5-
import type { LDContext, LDEvaluationDetail, LDEvaluationDetailTyped } from '../../src/index';
5+
import { deriveNamespace } from '../../src/ElectronIPC';
6+
import type { LDContext } from '../../src/index';
67

78
const clientSideId = 'client-side-id';
8-
let ldClientBridge: (clientSideId: string) => LDClientBridge;
9+
let ldClientBridge: (namespace: string) => LDClientBridge;
910

1011
jest.mock('electron', () => ({
1112
contextBridge: {
@@ -43,7 +44,7 @@ globalThis.MessageChannel = jest.fn().mockImplementation(() => ({
4344
port2: port2Mock,
4445
}));
4546

46-
const getEventName = (baseName: string) => `ld:${clientSideId}:${baseName}`;
47+
const getEventName = (baseName: string) => `ld:${deriveNamespace(clientSideId)}:${baseName}`;
4748

4849
beforeEach(() => {
4950
jest.clearAllMocks();
@@ -60,7 +61,7 @@ describe('given a registered LDClientBridge', () => {
6061
let bridge: LDClientBridge;
6162

6263
beforeEach(() => {
63-
bridge = ldClientBridge(clientSideId);
64+
bridge = ldClientBridge(deriveNamespace(clientSideId));
6465
});
6566

6667
it('passes allFlags() call through to ipcRenderer', () => {
@@ -73,37 +74,28 @@ describe('given a registered LDClientBridge', () => {
7374
expect(result).toEqual({ flag1: true });
7475
});
7576

76-
it('passes boolVariation() call through to ipcRenderer', () => {
77-
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(true);
78-
79-
const result = bridge.boolVariation('flag1', false);
80-
81-
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
82-
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
83-
1,
84-
getEventName('boolVariation'),
85-
'flag1',
86-
false,
87-
);
88-
expect(result).toEqual(true);
89-
});
90-
91-
it('passes boolVariationDetail() call through to ipcRenderer', () => {
92-
const expected: LDEvaluationDetailTyped<boolean> = {
93-
value: true,
94-
reason: { kind: 'RULE_MATCH' },
95-
};
96-
77+
it.each([
78+
['boolVariation', true, false],
79+
['boolVariationDetail', { value: true, reason: { kind: 'RULE_MATCH' } }, false],
80+
['numberVariation', 1234.5, 0],
81+
['numberVariationDetail', { value: 1234.5, reason: { kind: 'RULE_MATCH' } }, 0],
82+
['stringVariation', 'value', ''],
83+
['stringVariationDetail', { value: 'value', reason: { kind: 'RULE_MATCH' } }, ''],
84+
['jsonVariation', { key1: 'value1' }, {}],
85+
['jsonVariationDetail', { value: { key1: 'value1' }, reason: { kind: 'RULE_MATCH' } }, {}],
86+
['variation', true, false],
87+
['variationDetail', { value: true, reason: { kind: 'RULE_MATCH' } }, false],
88+
])('passes %s() call through to ipcRenderer', (method, expected, defaultValue) => {
9789
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected);
9890

99-
const result = bridge.boolVariationDetail('flag1', false);
91+
const result = (bridge as any)[method]('flag1', defaultValue);
10092

10193
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
10294
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
10395
1,
104-
getEventName('boolVariationDetail'),
96+
getEventName(method),
10597
'flag1',
106-
false,
98+
defaultValue,
10799
);
108100
expect(result).toEqual(expected);
109101
});
@@ -141,113 +133,6 @@ describe('given a registered LDClientBridge', () => {
141133
});
142134
});
143135

144-
it('passes jsonVariation() call through to ipcRenderer', () => {
145-
const expected = { key1: 'value1', key2: true };
146-
147-
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected);
148-
149-
const result = bridge.jsonVariation('flag1', {});
150-
151-
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
152-
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
153-
1,
154-
getEventName('jsonVariation'),
155-
'flag1',
156-
{},
157-
);
158-
expect(result).toEqual(expected);
159-
});
160-
161-
it('passes jsonVariationDetail() call through to ipcRenderer', () => {
162-
const expected: LDEvaluationDetailTyped<unknown> = {
163-
value: { key1: 'value1', key2: true },
164-
reason: { kind: 'RULE_MATCH' },
165-
};
166-
167-
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected);
168-
169-
const result = bridge.jsonVariationDetail('flag1', {});
170-
171-
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
172-
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
173-
1,
174-
getEventName('jsonVariationDetail'),
175-
'flag1',
176-
{},
177-
);
178-
expect(result).toEqual(expected);
179-
});
180-
181-
it('passes numberVariation() call through to ipcRenderer', () => {
182-
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(1234.5);
183-
184-
const result = bridge.numberVariation('flag1', 0);
185-
186-
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
187-
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
188-
1,
189-
getEventName('numberVariation'),
190-
'flag1',
191-
0,
192-
);
193-
expect(result).toEqual(1234.5);
194-
});
195-
196-
it('passes numberVariationDetail() call through to ipcRenderer', () => {
197-
const expected: LDEvaluationDetailTyped<number> = {
198-
value: 1234.5,
199-
reason: { kind: 'RULE_MATCH' },
200-
};
201-
202-
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected);
203-
204-
const result = bridge.numberVariationDetail('flag1', 0);
205-
206-
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
207-
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
208-
1,
209-
getEventName('numberVariationDetail'),
210-
'flag1',
211-
0,
212-
);
213-
expect(result).toEqual(expected);
214-
});
215-
216-
it('passes stringVariation() call through to ipcRenderer', () => {
217-
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce('value');
218-
219-
const result = bridge.stringVariation('flag1', '');
220-
221-
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
222-
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
223-
1,
224-
getEventName('stringVariation'),
225-
'flag1',
226-
'',
227-
);
228-
expect(result).toEqual('value');
229-
});
230-
231-
it('passes stringVariationDetail() call through to ipcRenderer', () => {
232-
const expected: LDEvaluationDetailTyped<string> = {
233-
value: 'value',
234-
reason: { kind: 'RULE_MATCH' },
235-
};
236-
237-
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected);
238-
239-
const result = bridge.stringVariationDetail('flag1', '');
240-
241-
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
242-
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
243-
1,
244-
getEventName('stringVariationDetail'),
245-
'flag1',
246-
'',
247-
);
248-
expect(result).toEqual(expected);
249-
});
250-
251136
it('passes track() call through to ipcRenderer', () => {
252137
bridge.track('event1', { key1: 'value1' }, 1234.5);
253138

@@ -261,41 +146,6 @@ describe('given a registered LDClientBridge', () => {
261146
);
262147
});
263148

264-
it('passes variation() call through to ipcRenderer', () => {
265-
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(true);
266-
267-
const result = bridge.variation('flag1', false);
268-
269-
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
270-
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
271-
1,
272-
getEventName('variation'),
273-
'flag1',
274-
false,
275-
);
276-
expect(result).toEqual(true);
277-
});
278-
279-
it('passes variationDetail() call through to ipcRenderer', () => {
280-
const expected: LDEvaluationDetail = {
281-
value: true,
282-
reason: { kind: 'RULE_MATCH' },
283-
};
284-
285-
(ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected);
286-
287-
const result = bridge.variationDetail('flag1', false);
288-
289-
expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1);
290-
expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith(
291-
1,
292-
getEventName('variationDetail'),
293-
'flag1',
294-
false,
295-
);
296-
expect(result).toEqual(expected);
297-
});
298-
299149
it('passes setConnectionMode() call through to ipcRenderer', async () => {
300150
await bridge.setConnectionMode('streaming');
301151

0 commit comments

Comments
 (0)