sse.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import { randomUUID } from 'node:crypto';
  2. import { TLSSocket } from 'node:tls';
  3. import { JSONRPCMessageSchema } from '../types.js';
  4. import getRawBody from 'raw-body';
  5. import contentType from 'content-type';
  6. import { URL } from 'node:url';
  7. const MAXIMUM_MESSAGE_SIZE = '4mb';
  8. /**
  9. * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests.
  10. *
  11. * This transport is only available in Node.js environments.
  12. * @deprecated SSEServerTransport is deprecated. Use StreamableHTTPServerTransport instead.
  13. */
  14. export class SSEServerTransport {
  15. /**
  16. * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`.
  17. */
  18. constructor(_endpoint, res, options) {
  19. this._endpoint = _endpoint;
  20. this.res = res;
  21. this._sessionId = randomUUID();
  22. this._options = options || { enableDnsRebindingProtection: false };
  23. }
  24. /**
  25. * Validates request headers for DNS rebinding protection.
  26. * @returns Error message if validation fails, undefined if validation passes.
  27. */
  28. validateRequestHeaders(req) {
  29. // Skip validation if protection is not enabled
  30. if (!this._options.enableDnsRebindingProtection) {
  31. return undefined;
  32. }
  33. // Validate Host header if allowedHosts is configured
  34. if (this._options.allowedHosts && this._options.allowedHosts.length > 0) {
  35. const hostHeader = req.headers.host;
  36. if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) {
  37. return `Invalid Host header: ${hostHeader}`;
  38. }
  39. }
  40. // Validate Origin header if allowedOrigins is configured
  41. if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) {
  42. const originHeader = req.headers.origin;
  43. if (originHeader && !this._options.allowedOrigins.includes(originHeader)) {
  44. return `Invalid Origin header: ${originHeader}`;
  45. }
  46. }
  47. return undefined;
  48. }
  49. /**
  50. * Handles the initial SSE connection request.
  51. *
  52. * This should be called when a GET request is made to establish the SSE stream.
  53. */
  54. async start() {
  55. if (this._sseResponse) {
  56. throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.');
  57. }
  58. this.res.writeHead(200, {
  59. 'Content-Type': 'text/event-stream',
  60. 'Cache-Control': 'no-cache, no-transform',
  61. Connection: 'keep-alive'
  62. });
  63. // Send the endpoint event
  64. // Use a dummy base URL because this._endpoint is relative.
  65. // This allows using URL/URLSearchParams for robust parameter handling.
  66. const dummyBase = 'http://localhost'; // Any valid base works
  67. const endpointUrl = new URL(this._endpoint, dummyBase);
  68. endpointUrl.searchParams.set('sessionId', this._sessionId);
  69. // Reconstruct the relative URL string (pathname + search + hash)
  70. const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash;
  71. this.res.write(`event: endpoint\ndata: ${relativeUrlWithSession}\n\n`);
  72. this._sseResponse = this.res;
  73. this.res.on('close', () => {
  74. this._sseResponse = undefined;
  75. this.onclose?.();
  76. });
  77. }
  78. /**
  79. * Handles incoming POST messages.
  80. *
  81. * This should be called when a POST request is made to send a message to the server.
  82. */
  83. async handlePostMessage(req, res, parsedBody) {
  84. if (!this._sseResponse) {
  85. const message = 'SSE connection not established';
  86. res.writeHead(500).end(message);
  87. throw new Error(message);
  88. }
  89. // Validate request headers for DNS rebinding protection
  90. const validationError = this.validateRequestHeaders(req);
  91. if (validationError) {
  92. res.writeHead(403).end(validationError);
  93. this.onerror?.(new Error(validationError));
  94. return;
  95. }
  96. const authInfo = req.auth;
  97. const host = req.headers.host;
  98. const protocol = req.socket instanceof TLSSocket ? 'https' : 'http';
  99. const fullUrl = host && req.url ? new URL(req.url, `${protocol}://${host}`) : undefined;
  100. const requestInfo = {
  101. headers: req.headers,
  102. url: fullUrl
  103. };
  104. let body;
  105. try {
  106. const ct = contentType.parse(req.headers['content-type'] ?? '');
  107. if (ct.type !== 'application/json') {
  108. throw new Error(`Unsupported content-type: ${ct.type}`);
  109. }
  110. body =
  111. parsedBody ??
  112. (await getRawBody(req, {
  113. limit: MAXIMUM_MESSAGE_SIZE,
  114. encoding: ct.parameters.charset ?? 'utf-8'
  115. }));
  116. }
  117. catch (error) {
  118. res.writeHead(400).end(String(error));
  119. this.onerror?.(error);
  120. return;
  121. }
  122. try {
  123. await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo });
  124. }
  125. catch {
  126. res.writeHead(400).end(`Invalid message: ${body}`);
  127. return;
  128. }
  129. res.writeHead(202).end('Accepted');
  130. }
  131. /**
  132. * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST.
  133. */
  134. async handleMessage(message, extra) {
  135. let parsedMessage;
  136. try {
  137. parsedMessage = JSONRPCMessageSchema.parse(message);
  138. }
  139. catch (error) {
  140. this.onerror?.(error);
  141. throw error;
  142. }
  143. this.onmessage?.(parsedMessage, extra);
  144. }
  145. async close() {
  146. this._sseResponse?.end();
  147. this._sseResponse = undefined;
  148. this.onclose?.();
  149. }
  150. async send(message) {
  151. if (!this._sseResponse) {
  152. throw new Error('Not connected');
  153. }
  154. this._sseResponse.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
  155. }
  156. /**
  157. * Returns the session ID for this transport.
  158. *
  159. * This can be used to route incoming POST requests.
  160. */
  161. get sessionId() {
  162. return this._sessionId;
  163. }
  164. }
  165. //# sourceMappingURL=sse.js.map