@nestjs/core
Version:
Nest - modern, fast, powerful node.js web framework (@core)
168 lines (167 loc) • 6.66 kB
JavaScript
import { Transform } from 'stream';
import { isFunction, isNil, isObject, isString, isUndefined, } from '@nestjs/common/internal';
function serializeSseLines(value, prefix) {
return value
.split(/\r\n|\r|\n/)
.map(line => `${prefix}${line}\n`)
.join('');
}
function toDataString(data) {
if (isObject(data)) {
return toDataString(JSON.stringify(data));
}
return serializeSseLines(data, 'data: ');
}
function toCommentString(comment) {
return serializeSseLines(comment, ': ');
}
function omitHeaders(headers, names) {
const omitted = new Set(names.map(name => name.toLowerCase()));
return Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !omitted.has(name.toLowerCase())));
}
function formatMessage(message) {
const sanitize = (val) => String(val).replace(/[\r\n]/g, '');
let data = message.type ? `event: ${sanitize(message.type)}\n` : '';
data += !isNil(message.id) ? `id: ${sanitize(message.id)}\n` : '';
data += !isNil(message.retry) ? `retry: ${sanitize(message.retry)}\n` : '';
data += !isNil(message.comment) ? toCommentString(message.comment) : '';
data += !isNil(message.data) ? toDataString(message.data) : '';
return data + '\n';
}
function isCommentOnly(message) {
return (!isNil(message.comment) &&
isUndefined(message.data) &&
isUndefined(message.type) &&
isUndefined(message.retry));
}
/**
* Adapted from https://raw.githubusercontent.com/EventSource/node-ssestream
* Transforms "messages" to W3C event stream content.
* See https://html.spec.whatwg.org/multipage/server-sent-events.html
* A message is an object with one or more of the following properties:
* - data (String or object, which gets turned into JSON)
* - type
* - id
* - retry
* - comment
*
* If constructed with a HTTP Request, it will optimise the socket for streaming.
* If this stream is piped to an HTTP Response, it will set appropriate headers.
*/
export class SseStream extends Transform {
lastEventId = null;
_headersCommitted = false;
_destination = null;
_statusCode;
_additionalHeaders;
_isHttp2;
constructor(req) {
super({ objectMode: true });
this._isHttp2 = (req?.httpVersionMajor ?? 1) > 1;
// Under HTTP/2 these calls are not request-scoped: `setTimeout` is routed
// to the shared `Http2Session` and the rest to the shared TCP socket, so
// tuning one SSE request would disable the idle timeout for every stream
// on that connection. See https://nodejs.org/api/http2.html#requestsocket
if (req && req.socket && !this._isHttp2) {
req.socket.setKeepAlive(true);
req.socket.setNoDelay(true);
req.socket.setTimeout(0);
}
}
get headersCommitted() {
return this._headersCommitted;
}
pipe(destination, options) {
this._destination = destination;
this._statusCode = options?.statusCode;
this._additionalHeaders = options?.additionalHeaders;
return super.pipe(destination, options);
}
/**
* Writes SSE headers to the destination if they have not been sent yet.
* The sources are read here, not in `pipe()`, to pick up the values that
* interceptors and handlers set after piping.
*/
commitHeaders() {
if (this._headersCommitted ||
!this._destination ||
this._destination.writableEnded) {
return;
}
const statusCode = this.readStatusCode();
const additionalHeaders = this.readAdditionalHeaders();
this._headersCommitted = true;
if (this._destination.writeHead) {
const sseHeaders = {
// See https://github.com/dunglas/mercure/blob/main/subscribe.go#L347-L362
'Content-Type': 'text/event-stream',
// Hop-by-hop header, forbidden in HTTP/2
// https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2
...(!this._isHttp2 && { Connection: 'keep-alive' }),
// Disable cache, even for old browsers and proxies
'Cache-Control': 'private, no-cache, no-store, must-revalidate, max-age=0, no-transform',
Pragma: 'no-cache',
Expires: '0',
// NGINX support https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/#x-accel-buffering
'X-Accel-Buffering': 'no',
};
// Fastify keeps its own header names (lower case), so a name that only
// differs in case would be sent twice instead of being overridden.
this._destination.writeHead(statusCode, {
...omitHeaders(additionalHeaders, Object.keys(sseHeaders)),
...sseHeaders,
});
this._destination.flushHeaders?.();
}
this._destination.write('\n');
}
readStatusCode() {
const source = this._statusCode;
return (isFunction(source) ? source() : source) ?? 200;
}
readAdditionalHeaders() {
const source = this._additionalHeaders;
return isFunction(source) ? source() : source;
}
_transform(message, encoding, callback) {
this.commitHeaders();
this.push(isString(message) ? message : formatMessage(message));
callback();
}
/**
* Calls `.write` but handles the drain if needed
*/
writeMessage(message, cb) {
let event = message;
if (isNil(message.id) && !isCommentOnly(message)) {
this.lastEventId++;
// The generated id goes on a copy: the message can be the handler's own
// object, emitted more than once or frozen. Fields are copied one by one,
// as a spread would drop the getters of an event implemented as a class.
event = {
type: message.type,
id: this.lastEventId.toString(),
retry: message.retry,
comment: message.comment,
data: message.data,
};
}
// Formatted before writing: a throw inside _transform would leave the
// stream stuck mid-write, while an error passed to the callback lets the
// caller report it and end the response.
let chunk;
try {
chunk = formatMessage(event);
}
catch (err) {
process.nextTick(cb, err);
return;
}
if (!this.write(chunk, 'utf-8')) {
this.once('drain', cb);
}
else {
process.nextTick(cb);
}
}
}