UNPKG

@fanoutio/eventstream

Version:

Connect-compatible middleware that enables the easy creation of EventStream endpoints

224 lines (223 loc) 9.19 kB
import accepts from 'accepts'; import CallableInstance from 'callable-instance'; import Debug from 'debug'; import { ServeGrip, } from '@fanoutio/serve-grip'; import AddressedEvents from './AddressedEvents.js'; import ChannelPublisher from './ChannelPublisher.js'; import ServerSentEventsSerializer from './stream/ServerSentEventsSerializer.js'; import AddressedEventsReadable from './stream/AddressedEventsReadable.js'; import { encodeEvent, joinEncodedEvents } from './utils/textEventStream.js'; import { flattenHttpHeaders } from './utils/http.js'; import { getProcessSingleton } from './utils/singleton.js'; import { KEEP_ALIVE_TIMEOUT } from './constants.js'; const debug = Debug('eventstream'); export default class EventStream extends CallableInstance { addressedEvents; channelPublishers = {}; serveGrip; constructor(params) { super('route'); const { grip, gripPrefix } = params ?? {}; if (grip != null) { const prefix = gripPrefix ?? 'events-'; debug('Initializing ServeGrip with', { prefix, grip }); this.serveGrip = new ServeGrip({ grip, prefix, }); } // all events written to all channels as { channel, event } objects this.addressedEvents = new AddressedEvents(); this.addressedEvents.addListener((e) => debug('eventstream event', e)); if (this.serveGrip != null) { const publisher = this.serveGrip.getPublisher(); this.addressedEvents.addListener(async ({ channel, event }) => { const encodedEvent = encodeEvent({ event: event.event, data: JSON.stringify(event.data), }); debug('grip:publishing', { channel, event }); await publisher.publishHttpStream(channel, encodedEvent); debug('grip:published', { channel, event }); }); } else { debug('Events will not publish to GRIP because no gripPublisher'); } } async publishEvent(channel, event) { await this.addressedEvents.addressedEvent({ event, channel }); } getChannelPublisher(channel) { if (this.channelPublishers[channel] == null) { this.channelPublishers[channel] = new ChannelPublisher(this, channel); } return this.channelPublishers[channel]; } createChannelWritable(channel) { return this.getChannelPublisher(channel).createWritable(); } buildChannels(req, channels) { const regex = /(?:{([_A-Za-z][_A-Za-z0-9]*)})/g; // In Express, req will have a params property that matches the pieces of the URL // e.g., path: /api/channel-:channelId // Request URL: /api/channel-test // req.params: { "channelId": "test" } // In Next.js, req will have a query property that matches a route segment. // (Unlike Express, the dynamic part needs to be an entire segment.) // e.g, path: /api/channel/[channelId].ts // Request URL: /api/channel/test // req.query: { "channelId": "test" } let paramsObj = null; const reqAsAny = req; if (reqAsAny.params != null) { // Note: Express's req also has a req.query that matches the query params from the // search part of the URL. For this reason we test for Express first paramsObj = reqAsAny.params; } else if (reqAsAny.query != null) { paramsObj = reqAsAny.query; } return channels.map((ch) => ch.replace(regex, (_substring, token1) => { return paramsObj?.[token1] ?? ''; })); } route(...params) { let channelsBuilder; if (params.length === 0) { throw new Error('eventStream must be called with at least one parameter.'); } if (typeof params[0] === 'function') { debug('Treating parameter as channel builder function'); channelsBuilder = params[0]; } else { // Channel names is let channelNames; if (Array.isArray(params[0])) { debug('Parameter is array, treating as list of channel names'); channelNames = params[0]; } else { debug('Parameters are spread, treating as list of channel names'); channelNames = params; } channelsBuilder = (req) => this.buildChannels(req, channelNames); } debug('Called with configuration data, configuring and returning Connect middleware.'); return async (req, res, fn) => { const useFnForError = fn != null; if (useFnForError) { debug('Called with 3 params, will call fn(error) to handle errors.'); } else { debug('Called with 2 params, will throw async exception to handle errors.'); } const channels = channelsBuilder(req); try { await this.run(req, res, channels); } catch (ex) { const error = ex instanceof Error ? ex : new Error(String(ex)); if (useFnForError) { fn(error); } else { throw error; } } }; } async run(req, res, channels) { debug('Beginning NextjsEventStream.run'); const accept = accepts(req); const types = accept.types('text/event-stream'); if (!types) { debug('Type not accepted by client'); res.statusCode = 406; res.end('Not Acceptable.\n'); return; } debug('Type accepted by client'); // Run ServeGrip if it hasn't been run yet. if (req.grip == null && this.serveGrip != null) { debug('Running ServeGrip'); await this.serveGrip.run(req, res); } const requestGrip = req.grip; if (requestGrip?.isProxied) { debug('This is a GRIP-proxied request'); if (requestGrip.needsSigned && !requestGrip.isSigned) { req.statusCode = 403; res.end('GRIP Signature Invalid.\n'); return; } } if (requestGrip?.isSigned) { debug('This is a GRIP-signed request'); } const lastEventId = flattenHttpHeaders(req.headers['last-event-id']); if (lastEventId === 'error') { res.statusCode = 400; res.end(`last-event-id header may not be 'error'.\n`); return; } debug("'last-event-id' header value is", lastEventId); channels = Array.isArray(channels) ? channels : [channels]; channels = channels .map((x) => (typeof x === 'string' ? x.trim() : '')) .filter((x) => x != null && x !== ''); if (channels.length === 0) { debug('No specified channels.'); res.statusCode = 400; res.end('No specified channels.\n'); return; } debug('Listening for events on channels', channels); debug('Sending status code 200 and header Content-Type: text/event-stream'); res.statusCode = 200; res.setHeader('Content-Type', 'text/event-stream'); const events = [ encodeEvent({ event: 'stream-open', data: '', }), ]; debug('Added stream-open event'); if (requestGrip?.isProxied) { // Use a GRIP hold to keep a stream going const gripInstruct = res.grip.startInstruct(); gripInstruct.setHoldStream(); gripInstruct.addChannel(channels); const keepAliveValue = encodeEvent({ event: 'keep-alive', data: '', }); gripInstruct.setKeepAlive(keepAliveValue, KEEP_ALIVE_TIMEOUT); debug('GRIP Instruction Headers', gripInstruct.toHeaders()); } else { debug('Performing SSE over chunked HTTP'); res.setHeader('Connection', 'Transfer-Encoding'); res.setHeader('Transfer-Encoding', 'chunked'); } res.write(joinEncodedEvents(events)); if (requestGrip?.isProxied) { debug('Exiting. Future events will be delivered via GRIP publishing.'); res.end(); return; } debug('Starting subscription and piping from Addressed Events.'); debug('Future events will be delivered via piping to response.'); new AddressedEventsReadable(this.addressedEvents, channels) .pipe(new ServerSentEventsSerializer()) .pipe(res) .on('finish', () => debug('Response finish (no more writes)')) .on('close', () => debug('Response close')); } } export function getEventStreamSingleton(params, singletonKey = 'eventStream') { return getProcessSingleton(singletonKey, () => { return new EventStream(params); }); }