msw
Version:
581 lines (580 loc) • 16.2 kB
JavaScript
import { invariant } from "outvariant";
import { Emitter } from "strict-event-emitter";
import {
HttpHandler
} from './handlers/HttpHandler.mjs';
import { delay } from './delay.mjs';
import { getTimestamp } from './utils/logging/getTimestamp.mjs';
import { devUtils } from './utils/internal/devUtils.mjs';
import { colors } from './ws/utils/attachWebSocketLogger.mjs';
import { toPublicUrl } from './utils/request/toPublicUrl.mjs';
const sse = (path, resolver) => {
return new ServerSentEventHandler(path, resolver);
};
class ServerSentEventHandler extends HttpHandler {
constructor(path, resolver) {
invariant(
typeof EventSource !== "undefined",
'Failed to construct a Server-Sent Event handler for path "%s": the EventSource API is not supported in this environment',
path
);
const clientEmitter = new Emitter();
super("GET", path, async (info) => {
const responseInit = {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive"
}
};
await super.log({
request: info.request,
/**
* @note Construct a placeholder response since SSE response
* is being streamed and cannot be cloned/consumed for logging.
*/
response: new Response("[streaming]", responseInit)
});
this.#attachClientLogger(info.request, clientEmitter);
const stream = new ReadableStream({
async start(controller) {
const client = new ServerSentEventClient({
controller,
emitter: clientEmitter
});
const server = new ServerSentEventServer({
request: info.request,
client
});
await resolver({
...info,
client,
server
});
}
});
return new Response(stream, responseInit);
});
}
async predicate(args) {
if (args.request.headers.get("accept") !== "text/event-stream") {
return false;
}
return super.predicate(args);
}
async log(_args) {
return;
}
#attachClientLogger(request, emitter) {
const publicUrl = toPublicUrl(request.url);
emitter.on("message", (payload) => {
console.groupCollapsed(
devUtils.formatMessage(
`${getTimestamp()} SSE %s %c\u21E3%c ${payload.event}`
),
publicUrl,
`color:${colors.mocked}`,
"color:inherit"
);
console.log(payload.frames);
console.groupEnd();
});
emitter.on("error", () => {
console.groupCollapsed(
devUtils.formatMessage(`${getTimestamp()} SSE %s %c\xD7%c error`),
publicUrl,
`color: ${colors.system}`,
"color:inherit"
);
console.log("Handler:", this);
console.groupEnd();
});
emitter.on("close", () => {
console.groupCollapsed(
devUtils.formatMessage(`${getTimestamp()} SSE %s %c\u25A0%c close`),
publicUrl,
`colors:${colors.system}`,
"color:inherit"
);
console.log("Handler:", this);
console.groupEnd();
});
}
}
class ServerSentEventClient {
#encoder;
#controller;
#emitter;
constructor(args) {
this.#encoder = new TextEncoder();
this.#controller = args.controller;
this.#emitter = args.emitter;
}
/**
* Sends the given payload to the intercepted `EventSource`.
*/
send(payload) {
if ("retry" in payload && payload.retry != null) {
this.#sendRetry(payload.retry);
return;
}
this.#sendMessage({
id: payload.id,
event: payload.event,
data: typeof payload.data === "object" ? JSON.stringify(payload.data) : payload.data
});
}
/**
* Dispatches the given event on the intercepted `EventSource`.
*/
dispatchEvent(event) {
if (event instanceof MessageEvent) {
this.#sendMessage({
id: event.lastEventId || void 0,
event: event.type === "message" ? void 0 : event.type,
data: event.data
});
return;
}
if (event.type === "error") {
this.error();
return;
}
if (event.type === "close") {
this.close();
return;
}
}
/**
* Errors the underlying `EventSource`, closing the connection with an error.
* This is equivalent to aborting the connection and will produce a `TypeError: Failed to fetch`
* error.
*/
error() {
this.#controller.error();
this.#emitter.emit("error");
}
/**
* Closes the underlying `EventSource`, closing the connection.
*/
close() {
this.#controller.close();
this.#emitter.emit("close");
}
#sendRetry(retry) {
if (typeof retry === "number") {
this.#controller.enqueue(this.#encoder.encode(`retry:${retry}
`));
}
}
#sendMessage(message) {
const frames = [];
if (message.id) {
frames.push(`id:${message.id}`);
}
if (message.event) {
frames.push(`event:${message.event?.toString()}`);
}
frames.push(`data:${message.data}`);
frames.push("", "");
this.#controller.enqueue(this.#encoder.encode(frames.join("\n")));
this.#emitter.emit("message", {
id: message.id,
event: message.event?.toString() || "message",
data: message.data,
frames
});
}
}
class ServerSentEventServer {
#request;
#client;
constructor(args) {
this.#request = args.request;
this.#client = args.client;
}
/**
* Establishes the actual connection for this SSE request
* and returns the `EventSource` instance.
*/
connect() {
const source = new ObservableEventSource(this.#request.url, {
withCredentials: this.#request.credentials === "include",
headers: {
/**
* @note Mark this request as passthrough so it doesn't trigger
* an infinite loop matching against the existing request handler.
*/
accept: "msw/passthrough"
}
});
source[kOnAnyMessage] = (event) => {
Object.defineProperties(event, {
target: {
value: this,
enumerable: true,
writable: true,
configurable: true
}
});
queueMicrotask(() => {
if (!event.defaultPrevented) {
this.#client.dispatchEvent(event);
}
});
};
source.addEventListener("error", (event) => {
Object.defineProperties(event, {
target: {
value: this,
enumerable: true,
writable: true,
configurable: true
}
});
queueMicrotask(() => {
if (!event.defaultPrevented) {
this.#client.dispatchEvent(event);
}
});
});
return source;
}
}
const kRequest = Symbol("kRequest");
const kReconnectionTime = Symbol("kReconnectionTime");
const kLastEventId = Symbol("kLastEventId");
const kAbortController = Symbol("kAbortController");
const kOnOpen = Symbol("kOnOpen");
const kOnMessage = Symbol("kOnMessage");
const kOnAnyMessage = Symbol("kOnAnyMessage");
const kOnError = Symbol("kOnError");
class ObservableEventSource extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 2;
CONNECTING = ObservableEventSource.CONNECTING;
OPEN = ObservableEventSource.OPEN;
CLOSED = ObservableEventSource.CLOSED;
readyState;
url;
withCredentials;
[kRequest];
[kReconnectionTime];
[kLastEventId];
[kAbortController];
[kOnOpen] = null;
[kOnMessage] = null;
[kOnAnyMessage] = null;
[kOnError] = null;
constructor(url, init) {
super();
this.url = new URL(url).href;
this.withCredentials = init?.withCredentials ?? false;
this.readyState = this.CONNECTING;
const headers = new Headers(init?.headers || {});
headers.append("accept", "text/event-stream");
this[kAbortController] = new AbortController();
this[kReconnectionTime] = 2e3;
this[kLastEventId] = "";
this[kRequest] = new Request(this.url, {
method: "GET",
headers,
credentials: this.withCredentials ? "include" : "omit",
signal: this[kAbortController].signal
});
this.connect();
}
get onopen() {
return this[kOnOpen];
}
set onopen(handler) {
if (this[kOnOpen]) {
this.removeEventListener("open", this[kOnOpen]);
}
this[kOnOpen] = handler.bind(this);
this.addEventListener("open", this[kOnOpen]);
}
get onmessage() {
return this[kOnMessage];
}
set onmessage(handler) {
if (this[kOnMessage]) {
this.removeEventListener("message", { handleEvent: this[kOnMessage] });
}
this[kOnMessage] = handler.bind(this);
this.addEventListener("message", { handleEvent: this[kOnMessage] });
}
get onerror() {
return this[kOnError];
}
set oneerror(handler) {
if (this[kOnError]) {
this.removeEventListener("error", { handleEvent: this[kOnError] });
}
this[kOnError] = handler.bind(this);
this.addEventListener("error", { handleEvent: this[kOnError] });
}
addEventListener(type, listener, options) {
super.addEventListener(
type,
listener,
options
);
}
removeEventListener(type, listener, options) {
super.removeEventListener(
type,
listener,
options
);
}
dispatchEvent(event) {
return super.dispatchEvent(event);
}
close() {
this[kAbortController].abort();
this.readyState = this.CLOSED;
}
async connect() {
await fetch(this[kRequest]).then((response) => {
this.processResponse(response);
}).catch(() => {
this.failConnection();
});
}
processResponse(response) {
if (!response.body) {
this.failConnection();
return;
}
if (isNetworkError(response)) {
this.reestablishConnection();
return;
}
if (response.status !== 200 || response.headers.get("content-type") !== "text/event-stream") {
this.failConnection();
return;
}
this.announceConnection();
this.interpretResponseBody(response);
}
announceConnection() {
queueMicrotask(() => {
if (this.readyState !== this.CLOSED) {
this.readyState = this.OPEN;
this.dispatchEvent(new Event("open"));
}
});
}
interpretResponseBody(response) {
const parsingStream = new EventSourceParsingStream({
message: (message) => {
if (message.id) {
this[kLastEventId] = message.id;
}
if (message.retry) {
this[kReconnectionTime] = message.retry;
}
const messageEvent = new MessageEvent(
message.event ? message.event : "message",
{
data: message.data,
origin: this[kRequest].url,
lastEventId: this[kLastEventId],
cancelable: true
}
);
this[kOnAnyMessage]?.(messageEvent);
this.dispatchEvent(messageEvent);
},
abort: () => {
throw new Error("Stream abort is not implemented");
},
close: () => {
this.failConnection();
}
});
response.body.pipeTo(parsingStream).then(() => {
this.processResponseEndOfBody(response);
}).catch(() => {
this.failConnection();
});
}
processResponseEndOfBody(response) {
if (!isNetworkError(response)) {
this.reestablishConnection();
}
}
async reestablishConnection() {
queueMicrotask(() => {
if (this.readyState === this.CLOSED) {
return;
}
this.readyState = this.CONNECTING;
this.dispatchEvent(new Event("error"));
});
await delay(this[kReconnectionTime]);
queueMicrotask(async () => {
if (this.readyState !== this.CONNECTING) {
return;
}
if (this[kLastEventId] !== "") {
this[kRequest].headers.set("last-event-id", this[kLastEventId]);
}
await this.connect();
});
}
failConnection() {
queueMicrotask(() => {
if (this.readyState !== this.CLOSED) {
this.readyState = this.CLOSED;
this.dispatchEvent(new Event("error"));
}
});
}
}
function isNetworkError(response) {
return response.type === "error" && response.status === 0 && response.statusText === "" && Array.from(response.headers.entries()).length === 0 && response.body === null;
}
var ControlCharacters = /* @__PURE__ */ ((ControlCharacters2) => {
ControlCharacters2[ControlCharacters2["NewLine"] = 10] = "NewLine";
ControlCharacters2[ControlCharacters2["CarriageReturn"] = 13] = "CarriageReturn";
ControlCharacters2[ControlCharacters2["Space"] = 32] = "Space";
ControlCharacters2[ControlCharacters2["Colon"] = 58] = "Colon";
return ControlCharacters2;
})(ControlCharacters || {});
class EventSourceParsingStream extends WritableStream {
constructor(underlyingSink) {
super({
write: (chunk) => {
this.processResponseBodyChunk(chunk);
},
abort: (reason) => {
this.underlyingSink.abort?.(reason);
},
close: () => {
this.underlyingSink.close?.();
}
});
this.underlyingSink = underlyingSink;
this.decoder = new TextDecoder();
this.position = 0;
}
decoder;
buffer;
position;
fieldLength;
discardTrailingNewline = false;
message = {
id: void 0,
event: void 0,
data: void 0,
retry: void 0
};
resetMessage() {
this.message = {
id: void 0,
event: void 0,
data: void 0,
retry: void 0
};
}
processResponseBodyChunk(chunk) {
if (this.buffer == null) {
this.buffer = chunk;
this.position = 0;
this.fieldLength = -1;
} else {
const nextBuffer = new Uint8Array(this.buffer.length + chunk.length);
nextBuffer.set(this.buffer);
nextBuffer.set(chunk, this.buffer.length);
this.buffer = nextBuffer;
}
const bufferLength = this.buffer.length;
let lineStart = 0;
while (this.position < bufferLength) {
if (this.discardTrailingNewline) {
if (this.buffer[this.position] === 10 /* NewLine */) {
lineStart = ++this.position;
}
this.discardTrailingNewline = false;
}
let lineEnd = -1;
for (; this.position < bufferLength && lineEnd === -1; ++this.position) {
switch (this.buffer[this.position]) {
case 58 /* Colon */: {
if (this.fieldLength === -1) {
this.fieldLength = this.position - lineStart;
}
break;
}
case 13 /* CarriageReturn */: {
this.discardTrailingNewline = true;
break;
}
case 10 /* NewLine */: {
lineEnd = this.position;
break;
}
}
}
if (lineEnd === -1) {
break;
}
this.processLine(
this.buffer.subarray(lineStart, lineEnd),
this.fieldLength
);
lineStart = this.position;
this.fieldLength = -1;
}
if (lineStart === bufferLength) {
this.buffer = void 0;
} else if (lineStart !== 0) {
this.buffer = this.buffer.subarray(lineStart);
this.position -= lineStart;
}
}
processLine(line, fieldLength) {
if (line.length === 0) {
if (this.message.data === void 0) {
this.message.event = void 0;
return;
}
this.underlyingSink.message(this.message);
this.resetMessage();
return;
}
if (fieldLength > 0) {
const field = this.decoder.decode(line.subarray(0, fieldLength));
const valueOffset = fieldLength + (line[fieldLength + 1] === 32 /* Space */ ? 2 : 1);
const value = this.decoder.decode(line.subarray(valueOffset));
switch (field) {
case "data": {
this.message.data = this.message.data ? this.message.data + "\n" + value : value;
break;
}
case "event": {
this.message.event = value;
break;
}
case "id": {
this.message.id = value;
break;
}
case "retry": {
const retry = parseInt(value, 10);
if (!isNaN(retry)) {
this.message.retry = retry;
}
break;
}
}
}
}
}
export {
sse
};
//# sourceMappingURL=sse.mjs.map