diff --git a/packages/shared/eventsource-node/__tests__/EventSource.eventEmitter.test.ts b/packages/shared/eventsource-node/__tests__/EventSource.eventEmitter.test.ts new file mode 100644 index 0000000000..1511fd1d90 --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/EventSource.eventEmitter.test.ts @@ -0,0 +1,234 @@ +import { EventSource } from '../src/EventSource'; +import { deliberatelyUnusedPort } from './helpers'; + +const unusedUrl = `http://localhost:${deliberatelyUnusedPort}`; + +// Matches the existing style elsewhere in this suite for a synchronous, no-network test: a +// default onerror keeps the pending (and never-to-succeed) connection attempt from crashing the +// test if it happens to fail asynchronously mid-test. +function newEventSource(): EventSource { + const es = new EventSource(unusedUrl); + es.onerror = () => {}; + return es; +} + +it('delivers a dispatched event to a listener registered with on()', () => { + const es = newEventSource(); + const received: unknown[] = []; + es.on('greeting', (e) => received.push(e)); + es.emit('greeting', 'hi'); + expect(received).toEqual(['hi']); + es.close(); +}); + +it('addListener is an alias for on()', () => { + const es = newEventSource(); + const received: unknown[] = []; + es.addListener('greeting', (e) => received.push(e)); + es.emit('greeting', 'hi'); + expect(received).toEqual(['hi']); + es.close(); +}); + +it('once() removes the listener after its first invocation', () => { + const es = newEventSource(); + const received: unknown[] = []; + es.once('greeting', (e) => received.push(e)); + es.emit('greeting', 'first'); + es.emit('greeting', 'second'); + expect(received).toEqual(['first']); + es.close(); +}); + +it('removeListener stops delivery to that listener', () => { + const es = newEventSource(); + const received: unknown[] = []; + const listener = (e: unknown): void => { + received.push(e); + }; + es.on('greeting', listener); + es.removeListener('greeting', listener); + es.emit('greeting', 'hi'); + expect(received).toEqual([]); + es.close(); +}); + +it('off is an alias for removeListener', () => { + const es = newEventSource(); + const received: unknown[] = []; + const listener = (e: unknown): void => { + received.push(e); + }; + es.on('greeting', listener); + es.off('greeting', listener); + es.emit('greeting', 'hi'); + expect(received).toEqual([]); + es.close(); +}); + +it('removeAllListeners(type) clears every listener for that type only', () => { + const es = newEventSource(); + const greetings: unknown[] = []; + const messages: unknown[] = []; + es.on('greeting', (e) => greetings.push(e)); + es.on('message', (e) => messages.push(e)); + es.removeAllListeners('greeting'); + es.emit('greeting', 'hi'); + es.emit('message', 'hello'); + expect(greetings).toEqual([]); + expect(messages).toEqual(['hello']); + es.close(); +}); + +it('removeAllListeners() with no type clears every type', () => { + const es = newEventSource(); + const greetings: unknown[] = []; + es.on('greeting', (e) => greetings.push(e)); + es.removeAllListeners(); + es.emit('greeting', 'hi'); + expect(greetings).toEqual([]); + es.close(); +}); + +it('prependListener registers ahead of an already-registered listener', () => { + const es = newEventSource(); + const order: string[] = []; + es.on('greeting', () => order.push('first')); + es.prependListener('greeting', () => order.push('second')); + es.emit('greeting'); + expect(order).toEqual(['second', 'first']); + es.close(); +}); + +it('prependOnceListener registers ahead and self-removes after one invocation', () => { + const es = newEventSource(); + const order: string[] = []; + es.on('greeting', () => order.push('normal')); + es.prependOnceListener('greeting', () => order.push('once')); + es.emit('greeting'); + es.emit('greeting'); + expect(order).toEqual(['once', 'normal', 'normal']); + es.close(); +}); + +it('listeners() returns the registered listeners for a type in order', () => { + const es = newEventSource(); + const a = (): void => {}; + const b = (): void => {}; + es.on('greeting', a); + es.on('greeting', b); + expect(es.listeners('greeting')).toEqual([a, b]); + es.close(); +}); + +it('rawListeners() returns the registered listeners for a type', () => { + const es = newEventSource(); + const a = (): void => {}; + es.on('greeting', a); + expect(es.rawListeners('greeting')).toEqual([a]); + es.close(); +}); + +it('listenerCount() counts listeners for a type', () => { + const es = newEventSource(); + expect(es.listenerCount('greeting')).toEqual(0); + es.on('greeting', () => {}); + es.on('greeting', () => {}); + expect(es.listenerCount('greeting')).toEqual(2); + es.close(); +}); + +it('eventNames() lists only types with at least one listener', () => { + // newEventSource() presets onerror, which would make 'error' a second, unrelated entry here -- + // this test needs a genuinely empty registry, so it closes immediately instead. + const es = new EventSource(unusedUrl); + es.close(); + expect(es.eventNames()).toEqual([]); + es.on('greeting', () => {}); + expect(es.eventNames()).toEqual(['greeting']); +}); + +it('setMaxListeners and getMaxListeners behave like a real EventEmitter', () => { + // This class extends Node's EventEmitter directly, matching the package it was ported from, so + // these are the real EventEmitter methods rather than package-specific no-ops. + const es = newEventSource(); + expect(es.setMaxListeners(1)).toBe(es); + expect(es.getMaxListeners()).toEqual(1); + es.close(); +}); + +it('on/once/removeListener/off/addListener/prependListener/prependOnceListener/removeAllListeners return this', () => { + const es = newEventSource(); + const noop = (): void => {}; + expect(es.on('greeting', noop)).toBe(es); + expect(es.once('greeting', noop)).toBe(es); + expect(es.removeListener('greeting', noop)).toBe(es); + expect(es.off('greeting', noop)).toBe(es); + expect(es.addListener('greeting', noop)).toBe(es); + expect(es.prependListener('greeting', noop)).toBe(es); + expect(es.prependOnceListener('greeting', noop)).toBe(es); + expect(es.removeAllListeners()).toBe(es); + es.close(); +}); + +it('onmessage and on("message", ...) share the same underlying registration', () => { + const es = newEventSource(); + const received: string[] = []; + es.onmessage = (e: { data: string }) => received.push(`slot:${e.data}`); + es.on('message', (e: { data: string }) => received.push(`on:${e.data}`)); + expect(es.listenerCount('message')).toEqual(2); + es.emit('message', { data: 'hi' }); + expect(received).toEqual(['slot:hi', 'on:hi']); + es.close(); +}); + +it('reassigning onmessage wipes any listener previously registered for the type', () => { + // Matches the package this was ported from exactly: the on* setter is `this.removeAllListeners + // (method); this.addEventListener(method, listener)`, so it wipes every listener for the type, + // not just its own slot -- including one added directly through `.on()`. This is not full W3C + // `on` semantics (assigning `window.onerror` does not remove `addEventListener` listeners + // there). + const es = newEventSource(); + const received: string[] = []; + const viaOn = (e: { data: string }): void => { + received.push(`on:${e.data}`); + }; + es.onmessage = () => received.push('first-slot'); + es.on('message', viaOn); + es.onmessage = (e: { data: string }) => received.push(`second-slot:${e.data}`); + expect(es.listenerCount('message')).toEqual(1); + es.emit('message', { data: 'hi' }); + expect(received).toEqual(['second-slot:hi']); + es.close(); +}); + +it('removeListener on the current onmessage listener clears the slot too', () => { + const es = newEventSource(); + const listener = (): void => {}; + es.onmessage = listener; + es.removeListener('message', listener); + expect(es.onmessage).toBeUndefined(); + expect(es.listenerCount('message')).toEqual(0); + es.close(); +}); + +it('throws when an error event is dispatched with no error listener registered', () => { + const es = new EventSource(unusedUrl); + // Closing first discards the pending connection attempt, so nothing but this test's own + // deliberate emit() call can dispatch an 'error' event during the test. + es.close(); + // No onerror, no addEventListener('error', ...), no on('error', ...) was ever set. This class + // extends Node's EventEmitter directly, matching the package it was ported from, and inherits + // its behavior of throwing synchronously when 'error' has no listener -- every internal SDK call + // site sets `.onerror` immediately after construction, so this is not reached in practice there. + expect(() => es.emit('error', { message: 'boom' })).toThrow(); +}); + +it('still delivers to a real listener registered for error', () => { + const es = new EventSource(unusedUrl); + es.close(); + const received: unknown[] = []; + es.on('error', (e) => received.push(e)); + es.emit('error', { message: 'boom' }); + expect(received).toEqual([{ message: 'boom' }]); +}); diff --git a/packages/shared/eventsource-node/__tests__/EventSource.events.test.ts b/packages/shared/eventsource-node/__tests__/EventSource.events.test.ts new file mode 100644 index 0000000000..fca2ba29bb --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/EventSource.events.test.ts @@ -0,0 +1,209 @@ +import { AsyncQueue, sleepAsync } from 'launchdarkly-js-test-helpers'; + +import { MessageEvent, OpenEvent } from '../src/types'; +import { + expectNothingReceived, + startErrorQueue, + startMessageQueue, + waitForOpenEvent, + withEventSource, + withServer, + writeEvents, +} from './helpers'; + +it('calls onopen when the connection is established', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([])); + await withEventSource(server.url, undefined, async (es) => { + const opened = new AsyncQueue(); + es.onopen = (e) => opened.add(e); + const e = await opened.take(); + expect(e.type).toEqual('open'); + }); + }); +}); + +it('emits the open event with response headers', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([], { 'X-LD-EnvId': '12345' })); + await withEventSource(server.url, undefined, async (es) => { + const e = await waitForOpenEvent(es); + expect(e.type).toEqual('open'); + expect(e.headers?.['x-ld-envid']).toEqual('12345'); + }); + }); +}); + +it('supplies the correct origin', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: hello\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + const messages = startMessageQueue(es); + const m = await messages.take(); + expect(m.origin).toEqual(server.url); + }); + }); +}); + +it('does not double reconnect when the connection is closed by the server', async () => { + await withServer(async (server) => { + let numConnections = 0; + server.byDefault((req, res) => { + numConnections += 1; + // End the first connection - only one reconnect is expected. + if (numConnections === 1) { + res.end(); + } else { + writeEvents([])(req, res); + } + }); + + await withEventSource(server.url, { initialRetryDelayMillis: 50 }, async () => { + await server.nextRequest(); + await server.nextRequest(); + + await sleepAsync(300); + expect(server.requests.isEmpty()).toBe(true); + }); + }); +}); + +it('does not emit an error when the connection is closed by the client', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([])); + await withEventSource(server.url, undefined, async (es) => { + const errors = startErrorQueue(es); + await waitForOpenEvent(es); + es.close(); + await expectNothingReceived(errors); + }); + }); +}); + +it('populates lastEventId when the last event has an associated id', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['id: 123\ndata: hello\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + const messages = startMessageQueue(es); + const m = await messages.take(); + expect(m.lastEventId).toEqual('123'); + }); + }); +}); + +it('carries lastEventId forward when a later event has no associated id', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['id: 123\ndata: Hello\n\n', 'data: World\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + const messages = startMessageQueue(es); + expect((await messages.take()).lastEventId).toEqual('123'); + expect((await messages.take()).lastEventId).toEqual('123'); + }); + }); +}); + +it('ignores an event id that contains a null character', async () => { + const nul = String.fromCharCode(0); + await withServer(async (server) => { + server.byDefault(writeEvents([`id: 12${nul}3\ndata: hello\n\n`])); + await withEventSource(server.url, undefined, async (es) => { + const messages = startMessageQueue(es); + const m = await messages.take(); + expect(m.lastEventId).toEqual(''); + }); + }); +}); + +it('populates messages with enumerable properties so they can be inspected', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: World\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + const messages = startMessageQueue(es); + const m = await messages.take(); + expect(Object.keys(m)).toEqual(expect.arrayContaining(['type', 'data'])); + // Own, enumerable and non-writable, same as every platform's own event construction + // promises -- checked here against a real delivered event. + // `type` is the property that construction special-cases; `data` covers the rest. + expect(Object.getOwnPropertyDescriptor(m, 'type')).toEqual({ + value: 'message', + writable: false, + enumerable: true, + configurable: false, + }); + expect(Object.getOwnPropertyDescriptor(m, 'data')).toEqual({ + value: 'World', + writable: false, + enumerable: true, + configurable: false, + }); + }); + }); +}); + +it('throws when the dispatched event type is unspecified, empty, or null', async () => { + await withServer(async (server) => { + await withEventSource(server.url, undefined, async (es) => { + expect(() => es.dispatchEvent({})).toThrow(); + expect(() => es.dispatchEvent({ type: undefined })).toThrow(); + expect(() => es.dispatchEvent({ type: '' })).toThrow(); + expect(() => es.dispatchEvent({ type: null as unknown as undefined })).toThrow(); + }); + }); +}); + +it('delivers a dispatched event without a payload', async () => { + await withServer(async (server) => { + await withEventSource(server.url, undefined, async (es) => { + const messages = new AsyncQueue(); + es.addEventListener('greeting', (m) => messages.add(m)); + es.dispatchEvent({ type: 'greeting' }); + await messages.take(); + }); + }); +}); + +it('delivers a dispatched event with a payload', async () => { + await withServer(async (server) => { + await withEventSource(server.url, undefined, async (es) => { + const messages = new AsyncQueue<{ data: string }>(); + es.addEventListener('greeting', (m) => messages.add(m)); + es.dispatchEvent({ type: 'greeting', detail: { data: 'Hello' } }); + const m = await messages.take(); + expect(m.data).toEqual('Hello'); + }); + }); +}); + +it('allows removal of event listeners', async () => { + await withServer(async (server) => { + server.byDefault( + writeEvents(['event: greeting\ndata: Hello\n\n', 'event: greeting\ndata: World\n\n']), + ); + await withEventSource(server.url, undefined, async (es) => { + const messages1 = new AsyncQueue(); + const messages2 = new AsyncQueue(); + function add1(m: MessageEvent) { + messages1.add(m); + } + function add2(m: MessageEvent) { + messages2.add(m); + } + es.addEventListener('greeting', add1); + es.addEventListener('greeting', add2); + es.removeEventListener('greeting', add1); + + await messages2.take(); + await expectNothingReceived(messages1); + }); + }); +}); + +it('returns the originally registered function from the on* accessors', async () => { + await withServer(async (server) => { + await withEventSource(server.url, undefined, async (es) => { + const handler = () => {}; + es.onmessage = handler; + expect(es.onmessage).toBe(handler); + }); + }); +}); diff --git a/packages/shared/eventsource-node/__tests__/EventSource.object.test.ts b/packages/shared/eventsource-node/__tests__/EventSource.object.test.ts new file mode 100644 index 0000000000..41e4b707c6 --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/EventSource.object.test.ts @@ -0,0 +1,136 @@ +import { EventSource } from '../src/EventSource'; +import { + deliberatelyUnusedPort, + waitForOpenEvent, + startErrorQueue, + withEventSource, + withServer, + writeEvents, +} from './helpers'; + +const unusedUrl = `http://localhost:${deliberatelyUnusedPort}`; + +it('has readyState constants on the constructor', () => { + expect(EventSource.CONNECTING).toEqual(0); + expect(EventSource.OPEN).toEqual(1); + expect(EventSource.CLOSED).toEqual(2); +}); + +it('has readyState constants on instances', () => { + const es = new EventSource(unusedUrl); + es.onerror = () => {}; + expect(es.CONNECTING).toEqual(EventSource.CONNECTING); + expect(es.OPEN).toEqual(EventSource.OPEN); + expect(es.CLOSED).toEqual(EventSource.CLOSED); + es.close(); +}); + +it('is CONNECTING before the connection has been established', () => { + const es = new EventSource(unusedUrl); + es.onerror = () => {}; + expect(es.readyState).toEqual(EventSource.CONNECTING); + es.close(); +}); + +it('is CONNECTING when the server has closed the connection', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([])); + await withEventSource(server.url, { initialRetryDelayMillis: 10 }, async (es) => { + const errors = startErrorQueue(es); + await waitForOpenEvent(es); + server.close(); + await errors.take(); + expect(es.readyState).toEqual(EventSource.CONNECTING); + }); + }); +}); + +it('is OPEN when the connection has been established', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([])); + await withEventSource(server.url, undefined, async (es) => { + await waitForOpenEvent(es); + expect(es.readyState).toEqual(EventSource.OPEN); + }); + }); +}); + +it('is CLOSED after the connection has been closed', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([])); + await withEventSource(server.url, undefined, async (es) => { + await waitForOpenEvent(es); + es.close(); + expect(es.readyState).toEqual(EventSource.CLOSED); + }); + }); +}); + +it('has a close method that returns undefined', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([])); + await withEventSource(server.url, undefined, async (es) => { + await waitForOpenEvent(es); + expect(es.close()).toBeUndefined(); + }); + }); +}); + +it('has close as a prototype method', () => { + expect(typeof EventSource.prototype.close).toEqual('function'); +}); + +it('exposes the original request url', () => { + const es = new EventSource(unusedUrl); + es.onerror = () => {}; + es.close(); + expect(es.url).toEqual(unusedUrl); +}); + +it('declares support for custom options', () => { + expect(EventSource.supportedOptions.errorFilter).toBe(true); + expect(EventSource.supportedOptions.headers).toBe(true); + expect(EventSource.supportedOptions.https).toBe(true); + expect(EventSource.supportedOptions.initialRetryDelayMillis).toBe(true); + expect(EventSource.supportedOptions.jitterRatio).toBe(true); + expect(EventSource.supportedOptions.maxBackoffMillis).toBe(true); + expect(EventSource.supportedOptions.method).toBe(true); + expect(EventSource.supportedOptions.proxy).toBe(true); + expect(EventSource.supportedOptions.retryResetIntervalMillis).toBe(true); + expect(EventSource.supportedOptions.skipDefaultHeaders).toBe(true); +}); + +it('does not expose configured headers as an enumerable property', () => { + const es = new EventSource(unusedUrl, { + headers: { authorization: 'sdk-key-should-not-leak' }, + }); + es.onerror = () => {}; + expect(JSON.stringify(es)).not.toContain('sdk-key-should-not-leak'); + expect(Object.keys(es)).not.toEqual(expect.arrayContaining(['_config', '_req'])); + es.close(); +}); + +it('does not allow supportedOptions to be modified', () => { + // The original JavaScript suite asserted this by assigning to the property, which silently + // failed in sloppy mode. Compiled TypeScript is always strict mode, where the same assignment + // throws, so the property descriptor is checked directly instead. + expect(Object.getOwnPropertyDescriptor(EventSource.supportedOptions, 'headers')).toEqual({ + value: true, + writable: false, + enumerable: true, + configurable: false, + }); +}); + +it('does not expose the config or the request as own properties at all', () => { + const es = new EventSource(unusedUrl, { + headers: { authorization: 'sdk-key-should-not-leak' }, + }); + es.onerror = () => {}; + // `config` and `req` are closure variables, exactly as in the package this was ported from, + // never assigned to `this` -- so headers can't reach util.inspect/JSON.stringify output, and + // there isn't even a non-enumerable property for either to accidentally become enumerable later. + expect(Object.getOwnPropertyDescriptor(es, '_config')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(es, '_req')).toBeUndefined(); + es.close(); +}); diff --git a/packages/shared/eventsource-node/__tests__/EventSource.parser.test.ts b/packages/shared/eventsource-node/__tests__/EventSource.parser.test.ts new file mode 100644 index 0000000000..98e974d7ff --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/EventSource.parser.test.ts @@ -0,0 +1,261 @@ +import { AsyncQueue } from 'launchdarkly-js-test-helpers'; + +import { MessageEvent } from '../src/types'; +import { shouldReceiveMessages, withEventSource, withServer, writeEvents } from './helpers'; + +// Escapes are used for the non-ASCII test data so the file stays ASCII-only. +const euroAndTofu = '\u20ac\u8c46\u8151'; // "euro sign" + "tofu" in Chinese +const chineseSentence = '\u6211\u73fe\u5728\u90fd\u770b\u5be6\u6cc1\u4e0d\u73a9\u904a\u6232'; +const oSlash = '\u00f8'; +const cyrillicE = '\u0435'; // outside the range fetch()'s Headers allows, but not a NUL byte +const nul = '\u0000'; + +it('parses multibyte characters', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([`id: 1\ndata: ${euroAndTofu}\n\n`])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: euroAndTofu }]); + }); + }); +}); + +it('parses empty lines with multibyte characters', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([`\n\n\n\nid: 1\ndata: ${chineseSentence}\n\n`])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: chineseSentence }]); + }); + }); +}); + +it('parses one one-line message in one chunk', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: Hello\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Hello' }]); + }); + }); +}); + +it('ignores byte-order mark', async () => { + await withServer(async (server) => { + server.byDefault((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write(String.fromCharCode(0xfeff)); + res.write('data: foo\n\n'); + res.end(); + }); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'foo' }]); + }); + }); +}); + +it('parses one one-line message in two chunks', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: Hel', 'lo\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Hello' }]); + }); + }); +}); + +it('parses two one-line messages in one chunk', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: Hello\n\n', 'data: World\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Hello' }, { data: 'World' }]); + }); + }); +}); + +it('parses one two-line message in one chunk', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: Hello\ndata:World\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Hello\nWorld' }]); + }); + }); +}); + +it('parses chopped up unicode data', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(`data: Aslak\n\ndata: Helles${oSlash}y\n\n`.split(''))); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Aslak' }, { data: `Helles${oSlash}y` }]); + }); + }); +}); + +it('parses really chopped up unicode data', async () => { + const content = `Aslak Helles${oSlash}y is the original author`; + await withServer(async (server) => { + server.byDefault((req, res) => { + const msg = Buffer.from(`data: ${content}\n\n`); + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + // Slice in the middle of the two-byte UTF-8 sequence for the o-slash, making sure that one + // data chunk contains the first byte and the second chunk gets the other. + res.write(msg.subarray(0, 19), 'binary', () => { + res.write(msg.subarray(19)); + }); + }); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: content }]); + }); + }); +}); + +it('accepts CRLF as separator', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(`data: Aslak\r\n\r\ndata: Helles${oSlash}y\r\n\r\n`.split(''))); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Aslak' }, { data: `Helles${oSlash}y` }]); + }); + }); +}); + +it('accepts CR as separator', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(`data: Aslak\r\rdata: Helles${oSlash}y\r\r`.split(''))); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Aslak' }, { data: `Helles${oSlash}y` }]); + }); + }); +}); + +it('ignores comments', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: Hello\n\n:nothing to see here\n\ndata: World\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Hello' }, { data: 'World' }]); + }); + }); +}); + +it('ignores empty comments', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: Hello\n\n:\n\ndata: World\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Hello' }, { data: 'World' }]); + }); + }); +}); + +it('does not ignore multiline strings', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: line one\ndata:\ndata: line two\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'line one\n\nline two' }]); + }); + }); +}); + +it('does not ignore multiline strings even in data beginning', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data:\ndata:line one\ndata: line two\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: '\nline one\nline two' }]); + }); + }); +}); + +it('treats field name without colon as a field with an empty value', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data\n\ndata\ndata\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: '' }, { data: '\n' }]); + }); + }); +}); + +it('causes entire event to be ignored for empty event field', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['event:\n\ndata: Hello\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + const emitted = new AsyncQueue(); + const originalEmit = es.emit.bind(es); + es.emit = (type: string, payload?: any): boolean => { + emitted.add(type); + return originalEmit(type, payload); + }; + await shouldReceiveMessages(es, [{ data: 'Hello' }]); + while (!emitted.isEmpty()) { + // oxlint-disable-next-line no-await-in-loop + const e = await emitted.take(); + expect(['open', 'message', 'closed']).toContain(e); + } + }); + }); +}); + +it('parses relatively huge messages efficiently', async () => { + await withServer(async (server) => { + const longMessageContent = new Array(100000).join('a'); + server.byDefault(writeEvents([`data: ${longMessageContent}\n\n`])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: longMessageContent }]); + }); + }); +}, 1000); + +it('parses a relatively huge message across many chunks efficiently', async () => { + await withServer(async (server) => { + const longMessageContent = new Array(100000).join('a'); + const longMessage = `data: ${longMessageContent}\n\n`; + // Split the message into chunks of 10 characters. + const longMessageChunks = longMessage.match(/[\s\S]{1,10}/g) as string[]; + server.byDefault(writeEvents(longMessageChunks)); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: longMessageContent }]); + }); + }); +}, 1000); + +it('parses two messages spanning 3 chunks with a shared chunk', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: Hel', 'lo\n\ndata:', 'World\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + await shouldReceiveMessages(es, [{ data: 'Hello' }, { data: 'World' }]); + }); + }); +}); + +it('delivers a message with an explicit event type', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['event: greeting\ndata: Hello\n\n'])); + await withEventSource(server.url, undefined, async (es) => { + const messages = new AsyncQueue(); + es.addEventListener('greeting', (m) => messages.add(m)); + const m = await messages.take(); + expect(m.data).toEqual('Hello'); + }); + }); +}); + +it( + 'accepts an id value with a character outside the HTTP header-value range, matching the ' + + 'original js-eventsource package', + async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([`id: h${cyrillicE}llo\ndata: Hello\n\n`])); + await withEventSource(server.url, undefined, async (es) => { + const messages = new AsyncQueue(); + es.addEventListener('message', (m) => messages.add(m)); + const m = await messages.take(); + expect(m.lastEventId).toEqual(`h${cyrillicE}llo`); + }); + }); + }, +); + +it('ignores an id value containing a NUL byte', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents([`id: h${nul}llo\ndata: Hello\n\n`])); + await withEventSource(server.url, undefined, async (es) => { + const messages = new AsyncQueue(); + es.addEventListener('message', (m) => messages.add(m)); + const m = await messages.take(); + expect(m.lastEventId).toEqual(''); + }); + }); +}); diff --git a/packages/shared/eventsource-node/__tests__/EventSource.proxy.test.ts b/packages/shared/eventsource-node/__tests__/EventSource.proxy.test.ts new file mode 100644 index 0000000000..baa69eeebf --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/EventSource.proxy.test.ts @@ -0,0 +1,61 @@ +import * as http from 'http'; + +import { + shouldReceiveMessages, + startErrorQueue, + withEventSource, + withProxy, + withSecureProxy, + withServer, + writeEvents, +} from './helpers'; + +it('proxies http to http requests', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: World\n\n'])); + await withProxy(async (proxy) => { + await withEventSource(server.url, { proxy: proxy.url }, async (es) => { + await shouldReceiveMessages(es, [{ data: 'World' }]); + expect(proxy.requestCount()).toBeGreaterThan(0); + }); + }); + }); +}); + +it('proxies https to http requests', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: World\n\n'])); + await withSecureProxy(async (proxy) => { + await withEventSource( + server.url, + // The proxy presents a self-signed certificate, so validation must be turned off here. + { proxy: proxy.url, https: { rejectUnauthorized: false } }, + async (es) => { + await shouldReceiveMessages(es, [{ data: 'World' }]); + expect(proxy.requestCount()).toBeGreaterThan(0); + }, + ); + }); + }); +}); + +// This stands in for the dropped tunneling-agent test: passing a caller-supplied http.Agent is +// the same code path EventSource.ts uses to proxy via http.Agent-based tunneling. +it('uses a caller-supplied agent for the connection', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: World\n\n'])); + const agent = new http.Agent({ keepAlive: false }); + const createConnection = jest.spyOn(agent, 'createConnection'); + try { + await withEventSource(server.url, { agent }, async (es) => { + const errors = startErrorQueue(es); + await shouldReceiveMessages(es, [{ data: 'World' }]); + expect(createConnection).toHaveBeenCalled(); + expect(createConnection.mock.calls[0][0].port).toEqual(server.port); + expect(errors.isEmpty()).toBe(true); + }); + } finally { + agent.destroy(); + } + }); +}); diff --git a/packages/shared/eventsource-node/__tests__/EventSource.readTimeout.test.ts b/packages/shared/eventsource-node/__tests__/EventSource.readTimeout.test.ts new file mode 100644 index 0000000000..b04c1e24bf --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/EventSource.readTimeout.test.ts @@ -0,0 +1,72 @@ +import * as http from 'http'; +import { AsyncQueue } from 'launchdarkly-js-test-helpers'; + +import { ErrorEvent, MessageEvent } from '../src/types'; +import { withEventSource, withServer } from './helpers'; + +const briefDelay = 1; + +function makeStreamHandler(timeBetweenEvents: number) { + let requestCount = 0; + return (req: unknown, res: http.ServerResponse) => { + requestCount += 1; + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + const eventPrefix = `request-${requestCount}`; + res.write(''); // turns on chunking + res.write(`data: ${eventPrefix}-event-1\n\n`); + setTimeout(() => { + if (res.writableEnded) { + // don't try to write any more if the connection has already been closed + return; + } + res.write(`data: ${eventPrefix}-event-2\n\n`); + }, timeBetweenEvents); + }; +} + +it('drops the connection if the read timeout elapses', async () => { + const readTimeout = 50; + const timeBetweenEvents = 100; + await withServer(async (server) => { + server.byDefault(makeStreamHandler(timeBetweenEvents)); + const opts = { initialRetryDelayMillis: briefDelay, readTimeoutMillis: readTimeout }; + await withEventSource(server.url, opts, async (es) => { + const received = new AsyncQueue(); + es.onmessage = (e) => received.add(e); + es.onerror = (e) => received.add(e as ErrorEvent); + + const m1 = (await received.take()) as MessageEvent; + expect(m1.type).toEqual('message'); + expect(m1.data).toEqual('request-1-event-1'); + + const err = (await received.take()) as ErrorEvent; + expect(err.type).toEqual('error'); + expect(err.message).toMatch(/^Read timeout/); + + const m2 = (await received.take()) as MessageEvent; + expect(m2.type).toEqual('message'); + expect(m2.data).toEqual('request-2-event-1'); + }); + }); +}); + +it('does not drop the connection if the read timeout does not elapse', async () => { + const readTimeout = 100; + const timeBetweenEvents = 50; + await withServer(async (server) => { + server.byDefault(makeStreamHandler(timeBetweenEvents)); + const opts = { initialRetryDelayMillis: briefDelay, readTimeoutMillis: readTimeout }; + await withEventSource(server.url, opts, async (es) => { + const received = new AsyncQueue(); + es.onmessage = (e) => received.add(e); + es.onerror = (e) => received.add(e as ErrorEvent); + + const m1 = (await received.take()) as MessageEvent; + expect(m1.data).toEqual('request-1-event-1'); + + // Receiving event 2 with the same request prefix proves the connection was not dropped. + const m2 = (await received.take()) as MessageEvent; + expect(m2.data).toEqual('request-1-event-2'); + }); + }); +}); diff --git a/packages/shared/eventsource-node/__tests__/EventSource.reconnection.test.ts b/packages/shared/eventsource-node/__tests__/EventSource.reconnection.test.ts new file mode 100644 index 0000000000..82d18c21aa --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/EventSource.reconnection.test.ts @@ -0,0 +1,197 @@ +import { + AsyncQueue, + TestHttpHandlers, + TestHttpRequest, + withCloseable, +} from 'launchdarkly-js-test-helpers'; + +import { EventSource } from '../src/EventSource'; +import { NodeEventSourceInitDict } from '../src/types'; +import { + expectInRange, + expectNothingReceived, + initiallyDownServerPort, + shouldReceiveMessages, + startErrorQueue, + startMessageQueue, + withEventSource, + withServer, + withServerOnPort, + writeEvents, +} from './helpers'; + +const briefDelay = 1; +const delayOpts: Partial = { initialRetryDelayMillis: briefDelay }; + +async function shouldReconnectAndGetMessage( + port: number, + es: EventSource, +): Promise { + let request: TestHttpRequest | undefined; + await withServerOnPort(port, async (server) => { + server.byDefault(writeEvents(['data: got it\n\n'])); + await shouldReceiveMessages(es, [{ data: 'got it' }]); + request = await server.nextRequest(); + }); + return request as TestHttpRequest; +} + +async function shouldNotReconnect(port: number, es: EventSource): Promise { + await withServerOnPort(port, async (server) => { + server.byDefault(writeEvents(['data: got it\n\n'])); + const messages = startMessageQueue(es); + await expectNothingReceived(messages); + }); +} + +it('reconnects when the server is down', async () => { + await withCloseable( + new EventSource(`http://localhost:${initiallyDownServerPort}`, delayOpts), + async (es) => { + const errors = startErrorQueue(es); + await errors.take(); + await shouldReconnectAndGetMessage(initiallyDownServerPort, es); + }, + ); +}); + +it('reconnects when the server goes down after connecting', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: hello\n\n'])); + await withEventSource(server.url, delayOpts, async (es) => { + await shouldReceiveMessages(es, [{ data: 'hello' }]); + await server.closeAndWait(); + await shouldReconnectAndGetMessage(server.port, es); + }); + }); +}); + +it('reconnects when the server responds with a 500', async () => { + await withServer(async (server) => { + server.byDefault(TestHttpHandlers.respond(500)); + await withEventSource(server.url, delayOpts, async (es) => { + const errors = startErrorQueue(es); + await errors.take(); + await server.closeAndWait(); + await shouldReconnectAndGetMessage(server.port, es); + }); + }); +}); + +it('stops reconnecting when the event source is closed', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: hello\n\n'])); + await withEventSource(server.url, delayOpts, async (es) => { + const errors = startErrorQueue(es); + await shouldReceiveMessages(es, [{ data: 'hello' }]); + await server.closeAndWait(); + await errors.take(); + + // We received an error because the remote connection was closed. We close es, so we do not + // want es to reconnect. + es.close(); + + await shouldNotReconnect(server.port, es); + }); + }); +}); + +it('does not reconnect when the server responds with a non-200, non-500 status', async () => { + await withServer(async (server) => { + server.byDefault(TestHttpHandlers.respond(204)); + await withEventSource(server.url, delayOpts, async (es) => { + const errors = startErrorQueue(es); + await errors.take(); + await server.closeAndWait(); + await shouldNotReconnect(server.port, es); + }); + }); +}); + +it('reconnects for a non-200, non-500 status if errorFilter says so', async () => { + await withServer(async (server) => { + server.byDefault(TestHttpHandlers.respond(204)); + const opts = { ...delayOpts, errorFilter: (err: { status?: number }) => err.status === 204 }; + await withEventSource(server.url, opts, async (es) => { + const errors = startErrorQueue(es); + await errors.take(); + await server.closeAndWait(); + await shouldReconnectAndGetMessage(server.port, es); + }); + }); +}); + +it('sends the Last-Event-ID header when the server previously sent an event id', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['id: 10\ndata: Hello\n\n'])); + await withEventSource(server.url, delayOpts, async (es) => { + const messages = startMessageQueue(es); + await messages.take(); + await server.closeAndWait(); + const req = await shouldReconnectAndGetMessage(server.port, es); + expect(req.headers['last-event-id']).toEqual('10'); + }); + }); +}); + +it('does not send the Last-Event-ID header when the server never sent an event id', async () => { + await withServer(async (server) => { + server.byDefault(writeEvents(['data: hello\n\n'])); + await withEventSource(server.url, delayOpts, async (es) => { + await shouldReceiveMessages(es, [{ data: 'hello' }]); + await server.closeAndWait(); + const req = await shouldReconnectAndGetMessage(server.port, es); + expect(req.headers['last-event-id']).toBeUndefined(); + }); + }); +}); + +async function verifyDelays( + options: Partial, + count: number, + assertion: (delays: number[]) => void, +): Promise { + await withServer(async (server) => { + server.byDefault(TestHttpHandlers.respond(500)); + await withEventSource(server.url, options, async (es) => { + const delays = new AsyncQueue(); + es.onretrying = (event) => delays.add(event.delayMillis); + const allDelays: number[] = []; + while (allDelays.length < count) { + // oxlint-disable-next-line no-await-in-loop + allDelays.push(await delays.take()); + } + assertion(allDelays); + }); + }); +} + +it('uses a constant retry delay by default', async () => { + const delay = 5; + await verifyDelays({ initialRetryDelayMillis: delay }, 3, (delays) => { + expect(delays).toEqual([delay, delay, delay]); + }); +}); + +it('can use backoff with a maximum', async () => { + const delay = 5; + const max = 31; + await verifyDelays({ initialRetryDelayMillis: delay, maxBackoffMillis: max }, 4, (delays) => { + expect(delays).toEqual([delay, delay * 2, delay * 4, max]); + }); +}); + +it('can use backoff with jitter', async () => { + const delay = 5; + const max = 31; + await verifyDelays( + { initialRetryDelayMillis: delay, maxBackoffMillis: max, jitterRatio: 0.5 }, + 3, + (delays) => { + expect(delays.length).toEqual(3); + expectInRange(delays[0], delay / 2, delay); + expectInRange(delays[1], delay, delay * 2); + expectInRange(delays[2], delay * 2, delay * 4); + }, + ); +}); diff --git a/packages/shared/eventsource-node/__tests__/EventSource.request.test.ts b/packages/shared/eventsource-node/__tests__/EventSource.request.test.ts new file mode 100644 index 0000000000..e2824c6bf7 --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/EventSource.request.test.ts @@ -0,0 +1,176 @@ +import { TestHttpHandlers, TestHttpHeaders } from 'launchdarkly-js-test-helpers'; + +import { + shouldReceiveMessages, + startErrorQueue, + withEventSource, + withSecureServer, + withServer, + writeEvents, +} from './helpers'; + +function stripIrrelevantHeaders(headers: TestHttpHeaders): TestHttpHeaders { + const h = { ...headers }; + delete h.connection; + delete h.host; + return h; +} + +it('passes cache-control: no-cache to the server', async () => { + await withServer(async (server) => { + await withEventSource(server.url, undefined, async () => { + const req = await server.nextRequest(); + expect(req.headers['cache-control']).toEqual('no-cache'); + }); + }); +}); + +it('sets request headers', async () => { + await withServer(async (server) => { + const headers = { + 'User-Agent': 'test', + Cookie: 'test=test', + 'Last-Event-ID': '99', + }; + await withEventSource(server.url, { headers }, async () => { + const req = await server.nextRequest(); + expect(stripIrrelevantHeaders(req.headers)).toEqual({ + accept: 'text/event-stream', + 'cache-control': 'no-cache', + 'user-agent': 'test', + cookie: 'test=test', + 'last-event-id': '99', + }); + }); + }); +}); + +it('can omit default headers', async () => { + await withServer(async (server) => { + const headers = { + 'User-Agent': 'test', + Cookie: 'test=test', + 'Last-Event-ID': '99', + }; + await withEventSource(server.url, { headers, skipDefaultHeaders: true }, async () => { + const req = await server.nextRequest(); + expect(stripIrrelevantHeaders(req.headers)).toEqual({ + 'user-agent': 'test', + cookie: 'test=test', + 'last-event-id': '99', + }); + }); + }); +}); + +it('uses the GET method by default', async () => { + await withServer(async (server) => { + await withEventSource(server.url, undefined, async () => { + const req = await server.nextRequest(); + expect(req.method).toEqual('get'); + }); + }); +}); + +it('can specify HTTP method and body', async () => { + const content = '{ "test": true }'; + await withServer(async (server) => { + await withEventSource(server.url, { method: 'POST', body: content }, async () => { + const req = await server.nextRequest(); + expect(req.method).toEqual('post'); + expect(req.body).toEqual(content); + }); + }); +}); + +it('sends the Last-Event-ID header when one was specified in the constructor', async () => { + await withServer(async (server) => { + await withEventSource(server.url, { headers: { 'Last-Event-ID': '9' } }, async () => { + const req = await server.nextRequest(); + expect(req.headers['last-event-id']).toEqual('9'); + }); + }); +}); + +describe.each([301, 307])('given a %s redirect response', (status) => { + it('follows the redirect', async () => { + const redirectSuffix = '/foobar'; + await withServer(async (server) => { + server.forMethodAndPath( + 'get', + '/', + TestHttpHandlers.respond(status, { + Connection: 'Close', + Location: server.url + redirectSuffix, + }), + ); + server.forMethodAndPath('get', redirectSuffix, writeEvents(['data: hello\n\n'])); + + await withEventSource(server.url, undefined, async () => { + const request1 = await server.nextRequest(); + expect(request1.path).toEqual('/'); + + const request2 = await server.nextRequest(); + expect(request2.path).toEqual(redirectSuffix); + }); + }); + }); + + it('emits an error event when the Location header is missing', async () => { + await withServer(async (server) => { + server.byDefault(TestHttpHandlers.respond(status, { Connection: 'Close' })); + await withEventSource(server.url, undefined, async (es) => { + const errors = startErrorQueue(es); + const err = await errors.take(); + expect(err.status).toEqual(status); + }); + }); + }); +}); + +describe.each([401, 403])('given a %s response', (status) => { + it('emits an error event with the status and headers', async () => { + await withServer(async (server) => { + server.byDefault(TestHttpHandlers.respond(status)); + await withEventSource(server.url, undefined, async (es) => { + const errors = startErrorQueue(es); + const err = await errors.take(); + expect(err.status).toEqual(status); + expect(err.headers).not.toBeUndefined(); + }); + }); + }); +}); + +it('uses https for https urls', async () => { + await withSecureServer(async (server) => { + server.byDefault(writeEvents(['data: hello\n\n'])); + await withEventSource(server.url, { https: { rejectUnauthorized: false } }, async (es) => { + await shouldReceiveMessages(es, [{ data: 'hello' }]); + }); + }); +}); + +it('merges https options into the request', async () => { + await withSecureServer(async (server) => { + server.byDefault(writeEvents(['data: hello\n\n'])); + await withEventSource( + server.url, + { https: { rejectUnauthorized: true, ca: server.certificate } }, + async (es) => { + await shouldReceiveMessages(es, [{ data: 'hello' }]); + }, + ); + }); +}); + +it('rejects an unverifiable certificate when no TLS options are supplied', async () => { + await withSecureServer(async (server) => { + server.byDefault(writeEvents(['data: hello\n\n'])); + await withEventSource(server.url, {}, async (es) => { + const errors = startErrorQueue(es); + const err = await errors.take(); + expect(err.message).toMatch(/self.signed|certificate/i); + }); + }); +}); diff --git a/packages/shared/eventsource-node/__tests__/helpers.ts b/packages/shared/eventsource-node/__tests__/helpers.ts new file mode 100644 index 0000000000..2bd943e082 --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/helpers.ts @@ -0,0 +1,107 @@ +import { + AsyncQueue, + sleepAsync, + TestHttpHandlers, + TestHttpHeaders, + TestHttpServer, + TestHttpServers, + withCloseable, +} from 'launchdarkly-js-test-helpers'; + +import { EventSource } from '../src/EventSource'; +import { ErrorEvent, MessageEvent, NodeEventSourceInitDict, OpenEvent } from '../src/types'; + +export const deliberatelyUnusedPort = 44444; + +// Distinct from deliberatelyUnusedPort: this one is expected to have a server bound to it +// transiently, by the one test that exercises reconnection into a server that starts late. +// Keeping it separate means deliberatelyUnusedPort's "nothing is ever listening here" assumption, +// relied on by other test files, stays true for the whole suite. +export const initiallyDownServerPort = 44445; + +export async function withServer(action: (server: TestHttpServer) => Promise): Promise { + await withCloseable(TestHttpServers.start, action); +} + +export async function withSecureServer( + action: (server: TestHttpServer) => Promise, +): Promise { + await withCloseable(TestHttpServers.startSecure, action); +} + +export async function withServerOnPort( + port: number, + action: (server: TestHttpServer) => Promise, +): Promise { + await withCloseable(() => TestHttpServers.start({}, port), action); +} + +export async function withProxy(action: (proxy: TestHttpServer) => Promise): Promise { + await withCloseable(TestHttpServers.startProxy, action); +} + +export async function withSecureProxy( + action: (proxy: TestHttpServer) => Promise, +): Promise { + await withCloseable(TestHttpServers.startSecureProxy, action); +} + +export async function withEventSource( + url: string, + opts: Partial | undefined, + action: (es: EventSource) => Promise, +): Promise { + const es = new EventSource(url, opts); + // Set a default error handler to avoid unhandled errors, since in most of these tests the + // EventSource is likely to get an error we don't care about while the test is being torn down. + es.onerror = () => {}; + await withCloseable(es, action); +} + +export async function waitForOpenEvent(es: EventSource): Promise { + const opened = new AsyncQueue(); + es.onopen = (e) => opened.add(e); + return opened.take(); +} + +export function startErrorQueue(es: EventSource): AsyncQueue { + const errors = new AsyncQueue(); + es.onerror = (e) => errors.add(e as ErrorEvent); + return errors; +} + +export function startMessageQueue(es: EventSource): AsyncQueue { + const messages = new AsyncQueue(); + es.onmessage = (m) => messages.add(m); + return messages; +} + +export async function shouldReceiveMessages( + es: EventSource, + expected: { data: string; type?: string }[], +): Promise { + const queue = startMessageQueue(es); + for (const e of expected) { + // oxlint-disable-next-line no-await-in-loop + const actual = await queue.take(); + expect(actual.data).toEqual(e.data); + expect(actual.type).toEqual(e.type ?? 'message'); + } +} + +export async function expectNothingReceived(q: AsyncQueue): Promise { + await sleepAsync(100); + expect(q.isEmpty()).toBe(true); +} + +export function writeEvents(chunks: string[], headers: TestHttpHeaders = {}) { + const resHeaders = { ...headers, 'Content-Type': 'text/event-stream' }; + const q = new AsyncQueue(); + chunks.forEach((chunk) => q.add(chunk)); + return TestHttpHandlers.chunkedStream(200, resHeaders, q); +} + +export function expectInRange(value: number, min: number, max: number): void { + expect(value).toBeGreaterThanOrEqual(min); + expect(value).toBeLessThanOrEqual(max); +} diff --git a/packages/shared/eventsource-node/__tests__/typeCompatibility.test.ts b/packages/shared/eventsource-node/__tests__/typeCompatibility.test.ts new file mode 100644 index 0000000000..da0c2fa82b --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/typeCompatibility.test.ts @@ -0,0 +1,66 @@ +import type { + EventSource as PlatformEventSource, + EventSourceInitDict as PlatformEventSourceInitDict, +} from '@launchdarkly/js-sdk-common'; + +import { EventSource } from '../src/EventSource'; +import { SupportedOptionName } from '../src/types'; + +/** + * The package's types now derive from the platform types in `@launchdarkly/js-sdk-common` + * (a regular dependency), so basic assignability is enforced by the compiler at the declaration + * sites in `src/types.ts` and `src/EventSource.ts`. This file keeps two remaining signals: + * + * 1. The allow-list check below. Because `EventSourceInitDict` inherits the platform init dict, + * a NEW platform option flows into this package's option type automatically -- meaning the + * type would claim support the implementation may not have. The check compares the platform's + * option names against this package's own `SupportedOptionName` list plus a small allow-list, + * so adding a platform option fails compilation here until someone makes a conscious decision: + * implement it (and extend `SupportedOptionName`), or record it as unsupported. + * 2. A runtime smoke test mirroring how the SDK platform adapters construct the EventSource. + */ + +/** + * Platform options that are real, supported options but are deliberately absent from the runtime + * `EventSource.supportedOptions` feature-detection list (`body`, `readTimeoutMillis`), plus the + * one platform option this package does not support at all: `urlBuilder`, which lets the platform + * recompute the URL before each reconnect. That one is a real, tracked gap rather than a + * by-design omission: `StreamingFDv2Base` already relies on `urlBuilder` to refresh its `basis` + * query param on reconnect, so FDv2 consumers of this package reconnect with a stale URL until it + * is added. + */ +type AllowListedPlatformOptions = 'body' | 'readTimeoutMillis' | 'urlBuilder'; +type UnaccountedPlatformOptions = Exclude< + keyof PlatformEventSourceInitDict, + SupportedOptionName | AllowListedPlatformOptions +>; +// oxlint-disable-next-line no-unused-vars +const unaccountedOptionsCheck: UnaccountedPlatformOptions extends never ? true : false = true; + +it('satisfies the LaunchDarkly platform EventSource interface', () => { + const initDict: PlatformEventSourceInitDict = { + headers: { authorization: 'sdk-key' }, + errorFilter: () => true, + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }; + // Mirrors what the SDK platform adapters do: spread the platform init dict and add extra + // implementation-specific options. + const expandedOptions = { + ...initDict, + agent: undefined, + https: undefined, + maxBackoffMillis: 30 * 1000, + jitterRatio: 0.5, + }; + const es: PlatformEventSource = new EventSource('http://localhost:44444', expandedOptions); + es.onclose = () => {}; + es.onerror = () => {}; + es.onopen = () => {}; + es.onretrying = () => {}; + es.addEventListener('put', () => {}); + es.close(); + // The real assertion is the type-level check above; this just gives jest something to execute. + expect(es).toBeDefined(); +}); diff --git a/packages/shared/eventsource-node/src/EventSource.ts b/packages/shared/eventsource-node/src/EventSource.ts new file mode 100644 index 0000000000..f8d2c121ab --- /dev/null +++ b/packages/shared/eventsource-node/src/EventSource.ts @@ -0,0 +1,758 @@ +/* This file is a line-by-line port of `eventsource.js` from the `js-eventsource` package. The port + * keeps the declaration order, the closure structure, and the names of that file. You can read the + * two files side by side. Comments that the original contains stay verbatim. The lint suppressions + * that follow make the port possible: + * - `no-use-before-define`: the original uses function hoisting for its mutually recursive closures. + * - `no-this-alias`: the original writes `var self = this`, because its inner closures are plain + * functions. + * - `no-param-reassign`: the original assigns a new value to the `url` parameter on redirect. + * - `no-underscore-dangle`: the original names internal members `_emit`, `_close`, and `_listener`. + */ +/* oxlint-disable no-use-before-define */ +/* oxlint-disable no-param-reassign */ +/* oxlint-disable no-underscore-dangle */ +/* oxlint-disable typescript/no-this-alias */ + +import { EventEmitter } from 'events'; +import * as http from 'http'; +import * as https from 'https'; +import { parse, URL } from 'url'; +import * as util from 'util'; + +import type { EventSource as PlatformEventSource } from '@launchdarkly/js-sdk-common'; + +import CalculateCapacity from './capacity'; +import * as retryDelay from './retryDelay'; +import { + ClosedEvent, + ErrorEvent, + EventSourceListener, + MessageEvent as MessageEventPayload, + NodeEventSourceInitDict, + OpenEvent, + RetryingEvent, + SupportedOptionName, +} from './types'; + +const httpsOptions = [ + 'pfx', + 'key', + 'passphrase', + 'cert', + 'ca', + 'ciphers', + 'rejectUnauthorized', + 'secureProtocol', + 'servername', + 'checkServerIdentity', +]; + +const bom = [239, 187, 191]; +const colon = 58; +const space = 32; +const lineFeed = 10; +const carriageReturn = 13; + +const MAX_OVER_ALLOCATION = 1024 * 1024; // 1 MiB + +function hasBom(buf: Buffer): boolean { + return bom.every((charCode, index) => buf[index] === charCode); +} + +/** + * Wrap a callback to ensure it can only be called once. + */ +function once void>(cb: T): (...args: Parameters) => void { + let called = false; + return (...params: Parameters) => { + if (!called) { + called = true; + cb(...params); + } + }; +} + +/** + * The public instance shape of a Node `EventSource`. + * + * Extends the platform `EventSource` interface from `@launchdarkly/js-sdk-common`, so the + * compiler enforces the platform contract directly. The `on*` members are redeclared below with + * this package's more specific event payload types; each stays assignable to its platform + * counterpart. + * + * `_close` is an internal implementation detail (see `EventSourceImpl` below). It is not part of + * the public contract. This interface declares it only because the `close()` prototype method must + * call it through a typed member. + */ +interface EventSourceInstance extends EventEmitter, PlatformEventSource { + readonly CONNECTING: 0; + readonly OPEN: 1; + readonly CLOSED: 2; + readonly readyState: number; + readonly url: string; + reconnectInterval: number; + onopen: ((e: OpenEvent) => void) | undefined; + onend: ((e?: ErrorEvent) => void) | undefined; + onerror: ((e?: ErrorEvent) => void) | undefined; + onmessage: ((e: MessageEventPayload) => void) | undefined; + onretrying: ((e: RetryingEvent) => void) | undefined; + onclosed: ((e: ClosedEvent) => void) | undefined; + /** + * This implementation never invokes `onclose`. It emits a `closed` event instead. The member + * exists so that code written against the platform EventSource interface compiles. + */ + onclose: (() => void) | undefined; + addEventListener(type: string, listener: EventSourceListener): void; + removeEventListener(type: string, listener: EventSourceListener): void; + dispatchEvent(event: { type?: string; detail?: any }): boolean; + close(): void; + /** @internal */ + _close(): void; +} + +export interface EventSourceConstructor { + new (url: string, eventSourceInitDict?: Partial): EventSourceInstance; + readonly CONNECTING: 0; + readonly OPEN: 1; + readonly CLOSED: 2; + readonly supportedOptions: Readonly>; + readonly prototype: EventSourceInstance; +} + +/** + * Creates a new EventSource object + * + * This is a constructor function, not a class. The instance state (`readyState`, `config`, `req`, + * `lastEventId`, ...) and the private functions (`connect`, `failed`, `scheduleReconnect`, ...) + * are closures, as in the original. This also follows the monorepo convention: prefer closures and + * factory functions over classes for public code. The constructor inherits from Node's + * `EventEmitter` through `util.inherits`, as in the original. As a result, `.on`, `.once`, + * `.listenerCount`, and the other emitter methods come from `EventEmitter`. Only `retry-delay.js` + * and `capacity.js` live in their own modules, because the original also kept them separate. + * + * @param url the URL to which to connect + * @param eventSourceInitDict extra init params. See README for details. + */ +function EventSourceImpl( + this: EventSourceInstance, + url: string, + eventSourceInitDict?: Partial, +): void { + let readyState: number = EventSource.CONNECTING; + const config = (eventSourceInitDict ?? {}) as NodeEventSourceInitDict; + + Object.defineProperty(this, 'readyState', { + get() { + return readyState; + }, + }); + + Object.defineProperty(this, 'url', { + get() { + return url; + }, + }); + + const self = this; + self.reconnectInterval = 1000; + + let req: http.ClientRequest | undefined; + let lastEventId = ''; + if (config.headers && config.headers['Last-Event-ID']) { + lastEventId = config.headers['Last-Event-ID'] as string; + } + + let discardTrailingNewline = false; + let data = ''; + let eventName: string | undefined; + let eventId: string | undefined; + + let reconnectUrl: string | null = null; + // The original calls this function with `new`. JavaScript ignores `new` on a function that + // returns an object. TypeScript does not permit `new` on a plain function, so this call is + // direct. + const retryDelayStrategy = retryDelay.RetryDelayStrategy( + config.initialRetryDelayMillis !== null && config.initialRetryDelayMillis !== undefined + ? config.initialRetryDelayMillis + : 1000, + config.retryResetIntervalMillis, + config.maxBackoffMillis ? retryDelay.defaultBackoff(config.maxBackoffMillis) : null, + config.jitterRatio ? retryDelay.defaultJitter(config.jitterRatio) : null, + ); + + const streamOriginUrl = new URL(url).origin; + + let reconnectTimer: ReturnType | undefined; + + function makeRequestUrlAndOptions(): { url: string | null; options: any } { + // Returns { url, options }; url is null/undefined if the URL properties are in options + let actualUrl: string | null = url; + const options: any = { headers: {} }; + if (!config.skipDefaultHeaders) { + options.headers['Cache-Control'] = 'no-cache'; + options.headers.Accept = 'text/event-stream'; + } + if (lastEventId) options.headers['Last-Event-ID'] = lastEventId; + if (config.headers) { + // The original iterates with `for (var key in config.headers)` and a `hasOwnProperty` + // guard. Object.keys() gives the same enumeration without the prototype-chain hazard. + Object.keys(config.headers).forEach((key) => { + options.headers[key] = config.headers[key]; + }); + } + + // The original forces `options.rejectUnauthorized` to false here unless a legacy top-level + // `rejectUnauthorized` option is set, which disables certificate validation by default. The + // port removes that: validation follows Node's own default, and `https.rejectUnauthorized` + // is the only way to change it. + + // If specify http proxy, make the request to sent to the proxy server, + // and include the original url in path and Host headers + if (config.proxy) { + actualUrl = null; + const parsedUrl = parse(url); + const proxy = parse(config.proxy); + options.protocol = proxy.protocol === 'https:' ? 'https:' : 'http:'; + options.path = url; + options.headers.Host = parsedUrl.host; + options.hostname = proxy.hostname; + options.host = proxy.host; + options.port = proxy.port; + // The original reads `proxy.username`/`proxy.password` here to build `options.auth`. + // `url.parse` puts credentials in `auth` and never sets those two fields, so that branch + // never runs, and the port omits it. Support for proxy credentials would be a behavior + // change, which is out of scope for the port. + } + + // When running in Node, proxies can also be specified as an agent + if (config.agent) { + options.agent = config.agent; + } + + // If https options are specified, merge them into the request options + if (config.https) { + Object.keys(config.https).forEach((optName) => { + if (httpsOptions.indexOf(optName) === -1) { + return; // The original uses `continue` here. + } + + const option = (config.https as unknown as Record)[optName]; + if (option !== undefined) { + (options as unknown as Record)[optName] = option; + } + }); + } + + if (config.method) { + options.method = config.method; + } + + return { url: actualUrl, options }; + } + + function defaultErrorFilter(error: ErrorEvent): boolean { + if (error.status) { + const s = error.status; + return s === 500 || s === 502 || s === 503 || s === 504; + } + return true; // always return I/O errors + } + + function failed(error?: Partial): void { + if (readyState === EventSource.CLOSED) { + return; + } + const errorEvent = error + ? Event('error', error) + : Event('end', { message: 'the request completed unexpectedly' }); + const shouldRetry = (config.errorFilter || defaultErrorFilter)( + errorEvent as unknown as ErrorEvent, + ); + if (shouldRetry) { + readyState = EventSource.CONNECTING; + _emit(errorEvent); + scheduleReconnect(); + } else { + _emit(errorEvent); + readyState = EventSource.CLOSED; + _emit(Event('closed')); + } + } + + function scheduleReconnect(): void { + if (readyState !== EventSource.CONNECTING) return; + const delay = retryDelayStrategy.nextRetryDelay(new Date().getTime()); + + // The url may have been changed by a temporary redirect. If that's the case, revert it now. + if (reconnectUrl) { + url = reconnectUrl; + reconnectUrl = null; + } + + const event = Event('retrying') as { type: string; delayMillis?: number }; + event.delayMillis = delay; + _emit(event); + + clearTimeout(reconnectTimer); + + reconnectTimer = setTimeout(() => { + if (readyState !== EventSource.CONNECTING) return; + connect(); + }, delay); + } + + function destroyRequest(): void { + // The original also aborts an `xhr` property that only exists under browser bundling; this + // package is Node-only, so the port omits it. + req?.destroy(); + } + + function connect(): void { + const urlAndOptions = makeRequestUrlAndOptions(); + const isSecure = + urlAndOptions.options.protocol === 'https:' || + (urlAndOptions.url && urlAndOptions.url.startsWith('https:')); + + // Each request should be able to fail at most once. + const failOnce = once(failed); + + const callback = function callback(res: http.IncomingMessage): void { + // Handle HTTP redirects + if (res.statusCode === 301 || res.statusCode === 307) { + if (!res.headers.location) { + // Server sent redirect response without Location header. + failOnce({ + status: res.statusCode, + headers: res.headers as Record, + message: res.statusMessage, + }); + return; + } + if (res.statusCode === 307) reconnectUrl = url; + url = res.headers.location; + process.nextTick(connect); // don't go through the scheduleReconnect logic since this isn't an error + return; + } + + // Handle HTTP errors + if (res.statusCode !== 200) { + failOnce({ + status: res.statusCode, + headers: res.headers as Record, + message: res.statusMessage, + }); + return; + } + + data = ''; + eventName = ''; + eventId = undefined; + + readyState = EventSource.OPEN; + res.on('close', () => { + res.removeAllListeners('close'); + res.removeAllListeners('end'); + failOnce(); + }); + + res.on('end', () => { + res.removeAllListeners('close'); + res.removeAllListeners('end'); + failOnce(); + }); + _emit(Event('open', { headers: res.headers })); + + // text/event-stream parser adapted from webkit's + // Source/WebCore/page/EventSource.cpp + let isFirst = true; + let buf: Buffer | undefined; + let startingPos = 0; + let startingFieldLength = -1; + let sizeUsed = 0; + + res.on('data', (chunk: Buffer) => { + if (!buf) { + buf = chunk; + if (isFirst && hasBom(buf)) { + buf = buf.slice(bom.length); + sizeUsed -= bom.length; + } + } else { + // allocate new buffer + const [resize, newCapacity] = CalculateCapacity( + buf.length, + chunk.length + sizeUsed, + MAX_OVER_ALLOCATION, + ); + if (resize) { + const newBuffer = Buffer.alloc(newCapacity); + buf.copy(newBuffer, 0, 0, sizeUsed); + buf = newBuffer; + } + + chunk.copy(buf, sizeUsed); + } + + sizeUsed += chunk.length; + isFirst = false; + let pos = 0; + const length = sizeUsed; + + while (pos < length) { + if (discardTrailingNewline) { + if (buf[pos] === lineFeed) { + pos += 1; + } + discardTrailingNewline = false; + } + + let lineLength = -1; + let fieldLength = startingFieldLength; + let c; + + for (let i = startingPos; lineLength < 0 && i < length; i += 1) { + c = buf[i]; + if (c === colon) { + if (fieldLength < 0) { + fieldLength = i - pos; + } + } else if (c === carriageReturn) { + discardTrailingNewline = true; + lineLength = i - pos; + } else if (c === lineFeed) { + lineLength = i - pos; + } + } + + if (lineLength < 0) { + startingPos = length - pos; + startingFieldLength = fieldLength; + break; + } else { + startingPos = 0; + startingFieldLength = -1; + } + + parseEventStreamLine(buf, pos, fieldLength, lineLength); + + pos += lineLength + 1; + } + + if (pos === length) { + buf = undefined; + sizeUsed = 0; + } else if (pos > 0) { + buf = buf.slice(pos); + sizeUsed -= pos; + } + }); + }; + + const api = (isSecure ? https : http) as typeof http; + req = urlAndOptions.url + ? api.request(urlAndOptions.url, urlAndOptions.options, callback) + : api.request(urlAndOptions.options, callback); + + if (config.readTimeoutMillis) { + req.setTimeout(config.readTimeoutMillis); + } + + if (config.body) { + req.write(config.body); + } + + req.on('error', (err) => { + failOnce({ message: err.message }); + }); + + req.on('timeout', () => { + failOnce({ + message: `Read timeout, received no data in ${config.readTimeoutMillis}ms, assuming connection is dead`, + }); + // Timeout doesn't mean that the request is cancelled, just that it has elapsed the timeout. + destroyRequest(); + }); + + if (req.setNoDelay) req.setNoDelay(true); + req.end(); + } + + connect(); + + function _emit(event?: { type: string }): void { + if (event) { + self.emit(event.type, event); + } + } + + this._close = function _close(): void { + clearTimeout(reconnectTimer); + + if (readyState === EventSource.CLOSED) return; + readyState = EventSource.CLOSED; + + destroyRequest(); + + _emit(Event('closed')); + }; + + function receivedEvent(event: MessageEventPayload): void { + retryDelayStrategy.setGoodSince(new Date().getTime()); + _emit(event); + } + + function parseEventStreamLine( + buf: Buffer, + pos: number, + fieldLength: number, + lineLength: number, + ): void { + if (lineLength === 0) { + if (data.length > 0) { + const type = eventName || 'message'; + if (eventId !== undefined) { + lastEventId = eventId; + } + const event = MessageEvent(type, { + data: data.slice(0, -1), // remove trailing newline + lastEventId, + origin: streamOriginUrl, + }); + data = ''; + eventId = undefined; + receivedEvent(event); + } + eventName = undefined; + } else { + const noValue = fieldLength < 0; + let step = 0; + const field = buf.slice(pos, pos + (noValue ? lineLength : fieldLength)).toString(); + + if (noValue) { + step = lineLength; + } else if (buf[pos + fieldLength + 1] !== space) { + step = fieldLength + 1; + } else { + step = fieldLength + 2; + } + pos += step; + + const valueLength = lineLength - step; + const value = buf.slice(pos, pos + valueLength).toString(); + + if (field === 'data') { + data += `${value}\n`; + } else if (field === 'event') { + eventName = value; + } else if (field === 'id') { + // The original writes this character as a NUL escape sequence in a string literal. + // String.fromCharCode(0) is the same character. The escape form is avoided here because + // some tools corrupt it. + if (!value.includes(String.fromCharCode(0))) { + eventId = value; + } + } else if (field === 'retry') { + const retry = parseInt(value, 10); + if (!Number.isNaN(retry)) { + self.reconnectInterval = retry; + retryDelayStrategy.setBaseDelay(retry); + } + } + } + } +} + +// This export sits where the original writes `module.exports = { EventSource: EventSource }`. +// It is a named export, not `export default`: a default export of a plain value carries no linked +// instance type, unlike a default export of a `class`. The type alias and the const share one name +// on purpose. A same-named `interface` is not possible here: the declaration bundler in tsup +// (rollup-plugin-dts) fails on an interface merged with a const of the same name, but it accepts +// the alias form. The single `export { EventSource }` carries the value and the type. As a result, +// `import { EventSource }` works as a value (`new EventSource(...)`) and as the instance type +// (`es: EventSource`). The const keeps the name `EventSource` so that the `EventSource.CONNECTING`, +// `OPEN`, and `CLOSED` references above read as in the original. +type EventSource = EventSourceInstance; +const EventSource = EventSourceImpl as unknown as EventSourceConstructor; +export { EventSource }; + +util.inherits(EventSourceImpl, EventEmitter); +EventSourceImpl.prototype.constructor = EventSourceImpl; // make stacktraces readable + +['open', 'end', 'error', 'message', 'retrying', 'closed'].forEach((method) => { + Object.defineProperty(EventSourceImpl.prototype, `on${method}`, { + /** + * Returns the current listener + * + * @return the set function or undefined + */ + get(): EventSourceListener | undefined { + const listener = this.listeners(method)[0] as + | (EventSourceListener & { _listener?: EventSourceListener }) + | undefined; + return listener ? (listener._listener ? listener._listener : listener) : undefined; + }, + + /** + * Start listening for events + * + * @param listener the listener + */ + set(listener: EventSourceListener | undefined): void { + this.removeAllListeners(method); + this.addEventListener(method, listener); + }, + }); +}); + +/** + * Ready states + */ +Object.defineProperty(EventSourceImpl, 'CONNECTING', { enumerable: true, value: 0 }); +Object.defineProperty(EventSourceImpl, 'OPEN', { enumerable: true, value: 1 }); +Object.defineProperty(EventSourceImpl, 'CLOSED', { enumerable: true, value: 2 }); + +EventSourceImpl.prototype.CONNECTING = 0; +EventSourceImpl.prototype.OPEN = 1; +EventSourceImpl.prototype.CLOSED = 2; + +/** + * Adds the EventSource.supportedOptions property that allows application code to know which + * custom options are supported by this polyfill. + */ +const supportedOptions: SupportedOptionName[] = [ + 'errorFilter', + 'headers', + 'https', + 'initialRetryDelayMillis', + 'jitterRatio', + 'maxBackoffMillis', + 'method', + 'proxy', + 'retryResetIntervalMillis', + 'skipDefaultHeaders', +]; +const supportedOptionsObject = {}; +supportedOptions.forEach((name) => { + // Using custom properties for this allows us to make them read-only. + Object.defineProperty(supportedOptionsObject, name, { enumerable: true, value: true }); +}); +Object.defineProperty(EventSourceImpl, 'supportedOptions', { + enumerable: true, + value: supportedOptionsObject, +}); + +/** + * Closes the connection, if one is made, and sets the readyState attribute to 2 (closed) + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventSource/close + */ +EventSourceImpl.prototype.close = function close(): void { + this._close(); +}; + +/** + * Emulates the W3C Browser based WebSocket interface using addEventListener. + * + * @param type A string representing the event type to listen out for + * @param listener callback + * @see https://developer.mozilla.org/en/DOM/element.addEventListener + * @see http://dev.w3.org/html5/websockets/#the-websocket-interface + */ +EventSourceImpl.prototype.addEventListener = function addEventListener( + type: string, + listener: EventSourceListener, +): void { + if (typeof listener === 'function') { + // store a reference so we can return the original function again + (listener as EventSourceListener & { _listener?: EventSourceListener })._listener = listener; + this.on(type, listener); + } +}; + +/** + * Emulates the W3C Browser based WebSocket interface using dispatchEvent. + * + * @param event An event to be dispatched + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent + */ +EventSourceImpl.prototype.dispatchEvent = function dispatchEvent(event: { + type?: string; + detail?: any; +}): boolean { + if (!event.type) { + throw new Error('UNSPECIFIED_EVENT_TYPE_ERR'); + } + // if event is instance of an CustomEvent (or has 'details' property), + // send the detail object as the payload for the event + return this.emit(event.type, event.detail); +}; + +/** + * Emulates the W3C Browser based WebSocket interface using removeEventListener. + * + * @param type A string representing the event type to remove + * @param listener callback + * @see https://developer.mozilla.org/en/DOM/element.removeEventListener + * @see http://dev.w3.org/html5/websockets/#the-websocket-interface + */ +EventSourceImpl.prototype.removeEventListener = function removeEventListener( + type: string, + listener: EventSourceListener, +): void { + if (typeof listener === 'function') { + (listener as EventSourceListener & { _listener?: EventSourceListener })._listener = undefined; + this.removeListener(type, listener); + } +}; + +/** + * W3C Event + * + * The original declares this as a constructor function and invokes it with `new`. TypeScript does + * not permit `new` on a plain function. So this port writes it as a factory function that returns + * the same object shape. + * + * @see http://www.w3.org/TR/DOM-Level-3-Events/#interface-Event + */ +function Event(type: string, optionalProperties?: Record): { type: string } { + const event: Record = {}; + Object.defineProperty(event, 'type', { writable: false, value: type, enumerable: true }); + if (optionalProperties) { + Object.keys(optionalProperties).forEach((f) => { + Object.defineProperty(event, f, { + writable: false, + value: optionalProperties[f], + enumerable: true, + }); + }); + } + return event as { type: string }; +} + +/** + * W3C MessageEvent + * + * This is a factory function, not a constructor, for the same reason as `Event` above. + * + * @see http://www.w3.org/TR/webmessaging/#event-definitions + */ +function MessageEvent(type: string, eventInitDict: Record): MessageEventPayload { + const event: Record = {}; + Object.defineProperty(event, 'type', { writable: false, value: type, enumerable: true }); + Object.keys(eventInitDict).forEach((f) => { + Object.defineProperty(event, f, { + writable: false, + value: eventInitDict[f], + enumerable: true, + }); + }); + return event as unknown as MessageEventPayload; +} + +export type { + OpenEvent, + ErrorEvent, + RetryingEvent, + ClosedEvent, + MessageEventPayload as MessageEvent, +}; diff --git a/packages/shared/eventsource-node/src/index.ts b/packages/shared/eventsource-node/src/index.ts index a5326d45fb..bc71a3224e 100644 --- a/packages/shared/eventsource-node/src/index.ts +++ b/packages/shared/eventsource-node/src/index.ts @@ -1,2 +1,2 @@ -// TODO(scaffold): the `EventSource` implementation and its export arrive in a later layer. +export { EventSource } from './EventSource'; export * from './types';