msw
Version:
599 lines (598 loc) • 18 kB
JavaScript
import { h as devUtils, r as toPublicUrl, s as getTimestamp } from "./cookie-store.js";
import { t as HttpHandler } from "./http-handler.js";
import { t as colors } from "./colors.js";
import { delay } from "../utils/delay.js";
import { invariant } from "outvariant";
import { Emitter, TypedEvent } from "rettime";
//#region src/sse/sse.ts
/**
* Intercept Server-Sent Events (SSE).
*
* @example
* sse('http://localhost:4321', ({ client }) => {
* client.send({ data: 'hello world' })
* })
*
* @see {@link https://mswjs.io/docs/sse/ Mocking Server-Sent Events}
* @see {@link https://mswjs.io/docs/api/sse `sse()` API reference}
*/
const sse = (path, resolver) => {
return new ServerSentEventHandler(path, resolver);
};
const SSE_RESPONSE_INIT = { headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive"
} };
var ServerSentEventHandler = class extends HttpHandler {
#loggableRequests;
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);
super("GET", path, async (info) => {
const { client, server, response } = createEventStream(info.request);
/**
* @note Scope the emitter to this connection so its logging
* listeners don't accumulate on the handler across connections.
*/
const emitter = new Emitter();
client[kClientEmitter] = emitter;
if (this.#loggableRequests.has(info.request)) this.#attachClientLogger(info.request, emitter);
/**
* @note Extend the resolver info via assignment instead of a spread.
* Spreading the info would invoke its lazy `finalize` getter,
* initializing the finalize machinery for resolvers that never use it.
*/
await resolver(Object.assign(info, {
client,
server
}));
return response;
});
this.#loggableRequests = /* @__PURE__ */ new WeakSet();
}
async predicate(args) {
if (args.request.headers.get("accept") !== "text/event-stream") return false;
const matches = await super.predicate(args);
if (matches && !args.resolutionContext?.quiet) {
/**
* @note Log the intercepted request early.
* Normally, the `this.log()` method is called when the handler returns a response.
* For SSE, call that method earlier so the logs are in correct order.
*/
await super.log({
request: args.request,
/**
* @note Construct a placeholder response since SSE response
* is being streamed and cannot be cloned/consumed for logging.
*/
response: new Response("[streaming]", SSE_RESPONSE_INIT)
});
this.#loggableRequests.add(args.request);
}
return matches;
}
async log(args) {
/**
* @note Cancel the response stream because it's not needed for logging.
* Otherwise, this cloned response remains unconsumed and its original
* doesn't propagate stream cancelations at all.
*/
args.response.body?.cancel();
}
#attachClientLogger(request, emitter) {
const publicUrl = toPublicUrl(request.url);
emitter.on("message", ({ data }) => {
console.groupCollapsed(devUtils.formatMessage(`${getTimestamp()} SSE %s %c⇣%c ${data.event}`), publicUrl, `color:${colors.mocked}`, "color:inherit");
console.log(data.frames);
console.groupEnd();
});
emitter.on("error", () => {
console.groupCollapsed(devUtils.formatMessage(`${getTimestamp()} SSE %s %c\u00D7%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■%c close`), publicUrl, `colors:${colors.system}`, "color:inherit");
console.log("Handler:", this);
console.groupEnd();
});
}
};
const kClientEmitter = Symbol.for("kClientEmitter");
var ServerSentEventClient = class {
[kClientEmitter];
#encoder;
#controller;
#closed;
#isClosed;
constructor(controller) {
this.#encoder = new TextEncoder();
this.#controller = controller;
this.#closed = Promise.withResolvers();
this.#isClosed = false;
}
/**
* 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) {
/**
* @note Use the internal send mechanism to skip normalization
* of the message data (already normalized by the server).
*/
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() {
if (this.#isClosed) return;
this.#controller.error();
this.#isClosed = true;
this.#closed.resolve();
this[kClientEmitter]?.emit(new TypedEvent("error"));
}
/**
* Closes the underlying `EventSource`, closing the connection.
*/
close() {
if (this.#isClosed) return;
try {
this.#controller.close();
this.#isClosed = true;
this.#closed.resolve();
} catch {}
this[kClientEmitter]?.emit(new TypedEvent("close"));
}
#enqueue(chunk) {
if (this.#isClosed) return;
try {
this.#controller.enqueue(chunk);
} catch (error) {
console.error(error);
devUtils.error("Failed to write to server-side EventSource. Please see the original error above.");
}
}
#sendRetry(retry) {
this.#enqueue(this.#encoder.encode(`retry:${retry}\n\n`));
}
#sendMessage(message) {
const frames = [];
if (message.id) frames.push(`id:${message.id}`);
if (message.event) frames.push(`event:${message.event.toString()}`);
if (message.data != null)
/**
* Split data on line terminators (LF, CR, or CRLF) and translate them to individual frames.
* @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
* @see https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream
*/
for (const line of message.data.toString().split(/\r\n|\r|\n/)) frames.push(`data:${line}`);
frames.push("", "");
this.#enqueue(this.#encoder.encode(frames.join("\n")));
this[kClientEmitter]?.emit(new TypedEvent("message", { data: {
id: message.id,
event: message.event?.toString() || "message",
data: message.data,
frames
} }));
}
};
var ServerSentEventServer = class {
#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" },
signal: this.#request.signal
});
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");
var ObservableEventSource = 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
});
if (init?.signal) {
if (init.signal.aborted) {
this.close();
return;
}
init.signal.addEventListener("abort", () => this.close(), {
once: true,
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 onerror(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;
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);
},
retry: (reconnectionTime) => {
this[kReconnectionTime] = reconnectionTime;
},
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"));
});
const signal = this[kAbortController].signal;
if (signal.aborted) return;
const aborted = Promise.withResolvers();
const onAbort = () => aborted.resolve();
signal.addEventListener("abort", onAbort, { once: true });
await Promise.race([delay(this[kReconnectionTime]), aborted.promise]).finally(() => {
signal.removeEventListener("abort", onAbort);
});
if (signal.aborted) return;
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"));
}
});
}
};
/**
* Checks if the given `Response` instance is a network error.
* @see https://fetch.spec.whatwg.org/#concept-network-error
*/
function isNetworkError(response) {
return response.type === "error" && response.status === 0 && response.statusText === "" && Array.from(response.headers.entries()).length === 0 && response.body === null;
}
var EventSourceParsingStream = class extends WritableStream {
underlyingSink;
decoder;
buffer;
position;
fieldLength;
discardTrailingNewline = false;
message = {
id: void 0,
event: void 0,
data: void 0
};
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;
}
resetMessage() {
this.message = {
id: void 0,
event: void 0,
data: 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) lineStart = ++this.position;
this.discardTrailingNewline = false;
}
let lineEnd = -1;
for (; this.position < bufferLength && lineEnd === -1; ++this.position) switch (this.buffer[this.position]) {
case 58:
if (this.fieldLength === -1) this.fieldLength = this.position - lineStart;
break;
case 13:
this.discardTrailingNewline = true;
break;
case 10: lineEnd = this.position;
}
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 ? 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":
/**
* Apply the retry immediately. Don't buffer onto the current message.
* @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
*/
if (/^\d+$/.test(value)) this.underlyingSink.retry?.(parseInt(value, 10));
}
}
}
};
/**
* Create an event stream out of the given Fetch API `Request`.
* Returns the following properties:
* - `client`, to operate on the intercepted request;
* - `server`, to establish and manage the actual server connection;
* - `response`, a `Response` to use as the mock response's body.
*
* @example
* http.post('/resource', ({ request }) => {
* const { client, server, response } = createEventStream(request)
* client.send({ data: 'hello world' })
* return response
* })
*/
function createEventStream(request) {
invariant(!request.signal.aborted, "Failed to call \"createEventStream\" on the \"%s %s\" request: request aborted", request.method, request.url);
let controller;
const readable = new ReadableStream({
start(defaultController) {
controller = defaultController;
},
cancel() {
client.close();
}
});
const client = new ServerSentEventClient(controller);
const server = new ServerSentEventServer({
request,
client
});
const response = new Response(readable, SSE_RESPONSE_INIT);
request.signal.addEventListener("abort", () => {
client.close();
}, { once: true });
return {
client,
server,
response
};
}
//#endregion
export { sse as t };
//# sourceMappingURL=sse.js.map