Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
AsyncQueue,
TestHttpHandlers,
TestHttpServer,
TestHttpServers,
} from 'launchdarkly-js-test-helpers';

import ElectronRequests from '../../src/platform/ElectronRequests';

describe('given a running HTTP server', () => {
let server: TestHttpServer;

beforeEach(async () => {
server = await TestHttpServers.start();
});

afterEach(async () => {
await server.closeAndWait();
});

it('forwards a streaming request and exposes the status, headers, and body chunks', async () => {
const chunks = new AsyncQueue<string>();
chunks.add('first');
server.byDefault(
TestHttpHandlers.chunkedStream(200, { 'content-type': 'text/event-stream' }, chunks),
);

const requests = new ElectronRequests();
const res = await requests.fetch(`${server.url}/stream`, {
method: 'REPORT',
headers: { authorization: 'sdk-key' },
body: '{"kind":"user"}',
streaming: true,
});

expect(res.status).toEqual(200);
const collected: Record<string, string> = {};
res.headers.forEach?.((value, key) => {
collected[key] = value;
});
expect(collected['content-type']).toEqual('text/event-stream');

const reader = res.body?.getReader();
const first = await reader?.read();
expect(first?.done).toBe(false);
expect(Buffer.from(first?.value ?? []).toString()).toEqual('first');

const received = await server.nextRequest();
expect(received.method.toUpperCase()).toEqual('REPORT');
expect(received.headers.authorization).toEqual('sdk-key');
expect(received.body).toEqual('{"kind":"user"}');
});

it('does not request compressed content for a streaming request', async () => {
const chunks = new AsyncQueue<string>();
server.byDefault(TestHttpHandlers.chunkedStream(200, {}, chunks));

const requests = new ElectronRequests();
await requests.fetch(server.url, { method: 'GET', streaming: true });

const received = await server.nextRequest();
expect(received.headers['accept-encoding']).toBeUndefined();
});

it('does not follow redirects for a streaming request', async () => {
server.byDefault(TestHttpHandlers.respond(301, { location: `${server.url}/other` }));

const requests = new ElectronRequests();
const res = await requests.fetch(server.url, { method: 'GET', streaming: true });

expect(res.status).toEqual(301);
expect(server.requestCount()).toEqual(1);
});

it('stops the stream when the signal aborts', async () => {
const chunks = new AsyncQueue<string>();
chunks.add('first');
server.byDefault(TestHttpHandlers.chunkedStream(200, {}, chunks));

const controller = new AbortController();
const requests = new ElectronRequests();
const res = await requests.fetch(server.url, {
method: 'GET',
streaming: true,
signal: controller.signal,
});
const reader = res.body?.getReader();
await reader?.read();
const pending = reader?.read();
controller.abort();
await expect(pending).rejects.toThrow();
});

it('rejects a streaming request when the signal is already aborted', async () => {
server.byDefault(TestHttpHandlers.respond(200));
const controller = new AbortController();
controller.abort();

const requests = new ElectronRequests();
await expect(
requests.fetch(server.url, { method: 'GET', streaming: true, signal: controller.signal }),
).rejects.toThrow();
});

it('streams SSE events through createEventSource', async () => {
const chunks = new AsyncQueue<string>();
chunks.add('data: hello\n\n');
server.byDefault(
TestHttpHandlers.chunkedStream(200, { 'content-type': 'text/event-stream' }, chunks),
);

const requests = new ElectronRequests();
const es = requests.createEventSource(`${server.url}/stream`, {
headers: {},
initialRetryDelayMillis: 100,
readTimeoutMillis: 5000,
retryResetIntervalMillis: 30_000,
errorFilter: () => false,
});
try {
const messages = new AsyncQueue<{ data?: string }>();
es.addEventListener('message', (event) => messages.add(event ?? {}));
const message = await messages.take();
expect(message.data).toEqual('hello');
} finally {
es.close();
}
});
});

describe('given a running HTTPS server with a self-signed certificate', () => {
let server: TestHttpServer;

beforeEach(async () => {
server = await TestHttpServers.startSecure();
server.byDefault(TestHttpHandlers.respond(200));
});

afterEach(async () => {
await server.closeAndWait();
});

it('rejects a streaming request when the certificate is not trusted', async () => {
// This SDK exposes no TLS options. Verification follows the platform default, so a
// self-signed certificate the machine does not trust must fail the request.
const requests = new ElectronRequests();
await expect(requests.fetch(server.url, { method: 'GET', streaming: true })).rejects.toThrow();
});
});
12 changes: 12 additions & 0 deletions packages/sdk/electron/__tests__/platform/HeaderWrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,16 @@ describe('given header values', () => {
}
expect(values).toEqual(['anything', 'some-value', 'a, b']);
});

it('iterates each header with the value before the key', () => {
const collected: [string, string][] = [];
wrapper.forEach((value, key) => {
collected.push([value, key]);
});
expect(collected).toEqual([
['anything', 'accept'],
['some-value', 'some-header'],
['a, b', 'some-array'],
]);
});
});
5 changes: 3 additions & 2 deletions packages/sdk/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,14 @@
"electron": ">=34.5.6"
},
"dependencies": {
"@launchdarkly/js-client-sdk-common": "workspace:^",
"launchdarkly-eventsource": "2.2.0"
"@launchdarkly/eventsource": "workspace:^",
"@launchdarkly/js-client-sdk-common": "workspace:^"
},
"devDependencies": {
"@types/jest": "^29.4.0",
"electron": "^42.4.0",
"jest": "^29.5.0",
"launchdarkly-js-test-helpers": "^2.2.0",
"oxfmt": "0.63.0",
"oxlint": "1.78.0",
"ts-jest": "^29.0.5",
Expand Down
57 changes: 49 additions & 8 deletions packages/sdk/electron/src/platform/ElectronRequests.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import * as http from 'http';
import * as https from 'https';
// No types for the event source.
// @ts-ignore
import { EventSource as LDEventSource } from 'launchdarkly-eventsource';
import { promisify } from 'util';
import * as zlib from 'zlib';

import { EventSourceCapabilities, platform } from '@launchdarkly/js-client-sdk-common';
import { createEventSource } from '@launchdarkly/eventsource';
import { EventSourceCapabilities, internal, platform } from '@launchdarkly/js-client-sdk-common';

import ElectronResponse from './ElectronResponse';

Expand All @@ -20,14 +18,17 @@ export default class ElectronRequests implements platform.Requests {
}

async fetch(url: string, options: platform.Options = {}): Promise<platform.Response> {
if (options.streaming) {
return this._streamingFetch(url, options);
}
const isSecure = url.startsWith('https://');
const impl = isSecure ? https : http;

const headers = { ...options.headers };
let bodyData: string | Buffer | undefined = options.body;

// For get requests we are going to automatically support compressed responses.
// Note this does not affect SSE as the event source is not using this fetch implementation.
// Note this does not affect SSE as streaming requests take the branch above.
if (options.method?.toLowerCase() === 'get') {
headers['accept-encoding'] = 'gzip';
}
Expand Down Expand Up @@ -67,16 +68,56 @@ export default class ElectronRequests implements platform.Requests {
});
}

/**
* The transport for a streaming request. This SDK exposes no agent, proxy, or TLS options, so
* the running machine's own network configuration applies and TLS verification follows the
* platform default. It does not request compressed content, and it never follows a redirect.
* A redirect status resolves like any other non-200 response, and the caller decides whether
* to retry the original URL. It applies no read or socket timeout. The caller owns the read
* timeout and cancels through the abort signal.
*/
private _streamingFetch(url: string, options: platform.Options): Promise<platform.Response> {
const isSecure = url.startsWith('https://');
const impl = isSecure ? https : http;
const requestOptions: https.RequestOptions = {
method: options.method,
headers: options.headers,
};
return new Promise<platform.Response>((resolve, reject) => {
const req = impl.request(url, requestOptions, (res) =>
resolve(internal.createStreamingResponse(res)),
);
// An SSE consumer wants each chunk as soon as it arrives; do not batch small writes.
req.setNoDelay(true);
const { signal } = options;
if (signal) {
const abort = () => req.destroy(new Error('The stream request was aborted'));
if (signal.aborted) {
abort();
} else {
signal.addEventListener('abort', abort, { once: true });
}
}
// This listener stays attached after resolve. A later socket error then becomes a harmless
// no-op reject instead of an unhandled 'error' event that would crash the process
req.on('error', reject);
if (options.body !== undefined) {
req.write(options.body);
}
req.end();
});
}

createEventSource(
url: string,
eventSourceInitDict: platform.EventSourceInitDict,
): platform.EventSource {
const expandedOptions = {
return createEventSource(url, {
...eventSourceInitDict,
maxBackoffMillis: 30 * 1000,
jitterRatio: 0.5,
};
return new LDEventSource(url, expandedOptions);
fetch: (fetchUrl, init) => this.fetch(fetchUrl, { ...init, streaming: true }),
});
}

getEventSourceCapabilities(): EventSourceCapabilities {
Expand Down
11 changes: 11 additions & 0 deletions packages/sdk/electron/src/platform/HeaderWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ export default class HeaderWrapper implements platform.Headers {
}
}

/**
* Executes the callback once for each header, with the value first. The order matches the
* fetch `Headers.forEach` signature. Multi-value headers are joined with a comma, and
* headers without a value are skipped, like `entries`.
*/
forEach(callback: (value: string, key: string) => void): void {
for (const [key, value] of this.entries()) {
callback(value, key);
}
}

has(name: string): boolean {
return Object.prototype.hasOwnProperty.call(this._headers, name);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,18 @@ it('reports presence with has()', () => {
expect(wrapper.has('content-type')).toBe(true);
expect(wrapper.has('missing')).toBe(false);
});

it('iterates each header with the value before the key', () => {
const wrapper = new HeaderWrapper({
accept: 'anything',
'some-array': ['a', 'b'],
});
const collected: [string, string][] = [];
wrapper.forEach((value, key) => {
collected.push([value, key]);
});
expect(collected).toEqual([
['anything', 'accept'],
['a, b', 'some-array'],
]);
});
Loading
Loading