Compare commits

...

6 Commits

Author SHA1 Message Date
Claude Bot
3a67efca0c Preserve DontEnum on EventSource reassignment, fix test leak
Changes:
- Preserve DontEnum attribute when EventSource is reassigned so the
  property remains non-enumerable after reassignment
- Wrap EventSource reassignment test in try/finally to ensure the
  original EventSource is always restored even if assertions fail

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 03:42:44 +00:00
Claude Bot
e49dce35be Allow EventSource reassignment and handle non-callable handlers
Changes:
- Add EventSource_setter to allow globalThis.EventSource to be
  reassigned (replaces accessor with plain data property on write)
- Update onopen/onmessage/onerror setters to handle non-callable
  values gracefully: non-functions are treated as null and don't
  throw errors (matching browser behavior)
- Add tests for global reassignment
- Add tests for non-callable handler values

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 03:25:27 +00:00
Claude Bot
9d1c0efd24 Address additional PR review feedback
Changes:
- Fix fallback EventSource.length to 1 per Web API spec
- Replace instance field definitions (CONNECTING, OPEN, CLOSED) with
  prototype getters that delegate to static constants, making them
  non-enumerable (matching browser behavior)
- Make Content-Type check case-insensitive and properly parse MIME
  type parameters (split on ';', trim, lowercase)
- Clear existing reconnect timer before scheduling a new one to
  prevent duplicate timers
- Remove per-test timeout options from tests (use default Bun timeouts)
- Add test for non-enumerable instance state constants
- Add tests for Content-Type with parameters and case-insensitivity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 03:02:29 +00:00
autofix-ci[bot]
c302061d1f [autofix.ci] apply automated fixes 2026-01-15 02:32:39 +00:00
Claude Bot
f661989942 Address PR review feedback for EventSource implementation
Changes based on CodeRabbit review comments:

- Change m_eventSourceConstructor from LazyPropertyOfGlobalObject<JSObject>
  to LazyPropertyOfGlobalObject<JSFunction> for correct type
- Add proper error handling: use jsDynamicCast to validate export is a
  function, create a throwing fallback function on failure
- Fix SSE CRLF handling: properly handle \r at end of chunk by waiting
  for more data before consuming
- Fix HTTP 204 handling: close connection permanently (per spec)
- Use Event("error") instead of ErrorEvent for error events (per spec)
- Fix test error handlers to reject instead of resolve
- Remove setTimeout patterns from tests, use Bun test timeouts instead
- Add test for CRLF line endings
- Add test for HTTP 204 behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 02:30:57 +00:00
Claude Bot
77b69c6a3e feat: implement EventSource (Server-Sent Events) API
Implements the EventSource API (Server-Sent Events) as defined by the
WHATWG HTML spec. This was previously removed from Bun because the
original implementation didn't work properly.

The new implementation:
- Uses fetch-based streaming for proper cookie/credential handling
- Properly extends EventTarget for addEventListener/removeEventListener
- Supports all SSE features: custom events, lastEventId, retry/reconnection
- Is exposed as a global like in browsers/Node.js

Fixes #3319

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 01:59:22 +00:00
4 changed files with 1047 additions and 2 deletions

View File

@@ -910,6 +910,38 @@ JSC_DEFINE_CUSTOM_SETTER(errorConstructorPrepareStackTraceSetter,
#pragma mark - Globals
// EventSource stub function that throws when EventSource failed to load
static JSC_DECLARE_HOST_FUNCTION(eventSourceNotAvailable);
JSC_DEFINE_HOST_FUNCTION(eventSourceNotAvailable, (JSGlobalObject * globalObject, CallFrame*))
{
auto scope = DECLARE_THROW_SCOPE(globalObject->vm());
throwTypeError(globalObject, scope, "EventSource is not available"_s);
return {};
}
// EventSource constructor getter - loads from undici module lazily
JSC_DEFINE_CUSTOM_GETTER(EventSource_getter,
(JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue,
JSC::PropertyName))
{
Zig::GlobalObject* globalObject = JSC::jsCast<Zig::GlobalObject*>(lexicalGlobalObject);
return JSC::JSValue::encode(globalObject->m_eventSourceConstructor.get(globalObject));
}
// EventSource constructor setter - allows globalThis.EventSource to be reassigned
JSC_DEFINE_CUSTOM_SETTER(EventSource_setter,
(JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue,
JSC::EncodedJSValue encodedValue, JSC::PropertyName property))
{
auto& vm = JSC::getVM(lexicalGlobalObject);
JSValue value = JSValue::decode(encodedValue);
auto* globalObject = jsCast<Zig::GlobalObject*>(lexicalGlobalObject);
// Replace the accessor with a plain data property, preserving DontEnum
globalObject->putDirect(vm, Identifier::fromString(vm, "EventSource"_s), value, static_cast<unsigned>(PropertyAttribute::DontEnum));
return true;
}
JSC_DEFINE_CUSTOM_GETTER(globalOnMessage,
(JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue,
JSC::PropertyName))
@@ -2297,6 +2329,31 @@ void GlobalObject::finishCreation(VM& vm)
init.set(JSC::JSFunction::create(init.vm, init.owner, WebCore::ipcSerializeCodeGenerator(init.vm), init.owner));
});
// EventSource constructor is loaded from the undici module
m_eventSourceConstructor.initLater([](const LazyProperty<JSC::JSGlobalObject, JSC::JSFunction>::Initializer& init) {
auto* globalObject = jsCast<Zig::GlobalObject*>(init.owner);
auto& vm = init.vm;
auto scope = DECLARE_THROW_SCOPE(vm);
// Load from undici module which contains the full EventSource implementation
JSValue moduleValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, Bun::InternalModuleRegistry::Field::ThirdpartyUndici);
RETURN_IF_EXCEPTION(scope, );
// Get the EventSource export from undici
if (moduleValue.isObject()) {
JSObject* moduleObject = moduleValue.getObject();
JSValue eventSourceExport = moduleObject->getIfPropertyExists(globalObject, Identifier::fromString(vm, "EventSource"_s));
RETURN_IF_EXCEPTION(scope, );
if (JSFunction* eventSourceFunc = jsDynamicCast<JSFunction*>(eventSourceExport)) {
init.set(eventSourceFunc);
return;
}
}
// Fallback: create a function that throws when called (length 1 per Web API spec)
init.set(JSC::JSFunction::create(vm, globalObject, 1, "EventSource"_s, eventSourceNotAvailable, ImplementationVisibility::Public));
});
m_JSFileSinkClassStructure.initLater(
[](LazyClassStructure::Initializer& init) {
auto* prototype = createJSSinkPrototype(init.vm, init.global, WebCore::SinkID::FileSink);
@@ -2773,6 +2830,9 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm)
putDirectCustomAccessor(vm, JSC::Identifier::fromString(vm, "onmessage"_s), JSC::CustomGetterSetter::create(vm, globalOnMessage, setGlobalOnMessage), 0);
putDirectCustomAccessor(vm, JSC::Identifier::fromString(vm, "onerror"_s), JSC::CustomGetterSetter::create(vm, globalOnError, setGlobalOnError), 0);
// EventSource - loaded from undici module lazily, can be reassigned
putDirectCustomAccessor(vm, JSC::Identifier::fromString(vm, "EventSource"_s), JSC::CustomGetterSetter::create(vm, EventSource_getter, EventSource_setter), PropertyAttribute::DontEnum | PropertyAttribute::CustomValue);
// ----- Extensions to Built-in objects -----
JSC::JSObject* errorConstructor = this->errorConstructor();

View File

@@ -635,7 +635,8 @@ public:
V(public, LazyPropertyOfGlobalObject<Symbol>, m_nodeVMDontContextify) \
V(public, LazyPropertyOfGlobalObject<Symbol>, m_nodeVMUseMainContextDefaultLoader) \
V(public, LazyPropertyOfGlobalObject<JSFunction>, m_ipcSerializeFunction) \
V(public, LazyPropertyOfGlobalObject<JSFunction>, m_ipcParseHandleFunction)
V(public, LazyPropertyOfGlobalObject<JSFunction>, m_ipcParseHandleFunction) \
V(public, LazyPropertyOfGlobalObject<JSFunction>, m_eventSourceConstructor) /* EventSource from undici module */
#define DECLARE_GLOBALOBJECT_GC_MEMBER(visibility, T, name) \
visibility: \

View File

@@ -372,13 +372,359 @@ const util = {
},
};
// EventSource (Server-Sent Events) implementation
// Follows the WHATWG HTML spec: https://html.spec.whatwg.org/multipage/server-sent-events.html
class EventSource extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 2;
constructor() {
#url;
#withCredentials;
#readyState = 0;
#lastEventId = "";
#reconnectionTime = 3000;
#abortController = null;
#reconnectTimer = null;
#onopen = null;
#onmessage = null;
#onerror = null;
constructor(url, options) {
super();
// Validate and resolve URL
const resolvedUrl = new URL(url, typeof location !== "undefined" ? location.href : undefined);
this.#url = resolvedUrl.href;
this.#withCredentials = options?.withCredentials ?? false;
this.#readyState = EventSource.CONNECTING;
// Start connection on next tick
process.nextTick(() => this.#connect());
}
// Instance getters that delegate to static constants (not writable/enumerable own properties)
get CONNECTING() {
return EventSource.CONNECTING;
}
get OPEN() {
return EventSource.OPEN;
}
get CLOSED() {
return EventSource.CLOSED;
}
get url() {
return this.#url;
}
get readyState() {
return this.#readyState;
}
get withCredentials() {
return this.#withCredentials;
}
get onopen() {
return this.#onopen;
}
set onopen(value) {
const oldHandler = this.#onopen;
// Only store functions, treat non-callables as null
const newHandler = typeof value === "function" ? value : null;
this.#onopen = newHandler;
// Remove old handler if it was a function
if (typeof oldHandler === "function") {
this.removeEventListener("open", oldHandler);
}
// Add new handler if it's a function
if (typeof newHandler === "function") {
this.addEventListener("open", newHandler);
}
}
get onmessage() {
return this.#onmessage;
}
set onmessage(value) {
const oldHandler = this.#onmessage;
// Only store functions, treat non-callables as null
const newHandler = typeof value === "function" ? value : null;
this.#onmessage = newHandler;
// Remove old handler if it was a function
if (typeof oldHandler === "function") {
this.removeEventListener("message", oldHandler);
}
// Add new handler if it's a function
if (typeof newHandler === "function") {
this.addEventListener("message", newHandler);
}
}
get onerror() {
return this.#onerror;
}
set onerror(value) {
const oldHandler = this.#onerror;
// Only store functions, treat non-callables as null
const newHandler = typeof value === "function" ? value : null;
this.#onerror = newHandler;
// Remove old handler if it was a function
if (typeof oldHandler === "function") {
this.removeEventListener("error", oldHandler);
}
// Add new handler if it's a function
if (typeof newHandler === "function") {
this.addEventListener("error", newHandler);
}
}
close() {
this.#readyState = EventSource.CLOSED;
if (this.#abortController) {
this.#abortController.abort();
this.#abortController = null;
}
if (this.#reconnectTimer) {
clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
}
}
#connect() {
if (this.#readyState === EventSource.CLOSED) {
return;
}
const abortController = new AbortController();
this.#abortController = abortController;
const headers = {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
};
if (this.#lastEventId) {
headers["Last-Event-ID"] = this.#lastEventId;
}
fetch(this.#url, {
method: "GET",
headers,
credentials: this.#withCredentials ? "include" : "same-origin",
cache: "no-store",
signal: abortController.signal,
})
.then(response => {
if (this.#readyState === EventSource.CLOSED) {
return;
}
// HTTP 204 No Content means server wants to close the connection permanently
if (response.status === 204) {
this.#fail();
return;
}
if (!response.ok) {
this.#fail();
return;
}
const contentType = response.headers.get("Content-Type");
// Parse MIME type: extract media type before any parameters, case-insensitive comparison
const mimeType = contentType ? contentType.split(";")[0].trim().toLowerCase() : "";
if (mimeType !== "text/event-stream") {
this.#fail();
return;
}
this.#readyState = EventSource.OPEN;
this.dispatchEvent(new Event("open"));
if (!response.body) {
this.#reconnect();
return;
}
this.#readStream(response.body);
})
.catch(error => {
if (this.#readyState === EventSource.CLOSED) {
return;
}
if (error.name === "AbortError") {
return;
}
this.#reconnect();
});
}
async #readStream(body) {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventType = "";
let data = [];
try {
while (true) {
const { done, value } = await reader.read();
if (this.#readyState === EventSource.CLOSED) {
reader.cancel();
return;
}
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let lineEnd;
while ((lineEnd = this.#findLineEnd(buffer)) !== -1) {
const char = buffer[lineEnd];
const line = buffer.slice(0, lineEnd);
// Handle CRLF: if we see \r, check if next char is \n
// If \r is at end of buffer, wait for more data to check for \n
if (char === "\r") {
if (lineEnd + 1 >= buffer.length) {
// \r at end of buffer - need more data to know if CRLF
break;
}
buffer = buffer.slice(lineEnd + (buffer[lineEnd + 1] === "\n" ? 2 : 1));
} else {
buffer = buffer.slice(lineEnd + 1);
}
if (line === "") {
if (data.length > 0) {
const origin = new URL(this.#url).origin;
const event = new MessageEvent(eventType || "message", {
data: data.join("\n"),
origin: origin,
lastEventId: this.#lastEventId,
});
this.dispatchEvent(event);
}
eventType = "";
data = [];
} else if (line[0] === ":") {
// Comment line, ignore
} else {
const colonIndex = line.indexOf(":");
let field;
let fieldValue;
if (colonIndex === -1) {
field = line;
fieldValue = "";
} else {
field = line.slice(0, colonIndex);
fieldValue = line.slice(colonIndex + 1);
if (fieldValue[0] === " ") {
fieldValue = fieldValue.slice(1);
}
}
switch (field) {
case "event":
eventType = fieldValue;
break;
case "data":
data.push(fieldValue);
break;
case "id":
if (!fieldValue.includes("\0")) {
this.#lastEventId = fieldValue;
}
break;
case "retry":
if (/^\d+$/.test(fieldValue)) {
this.#reconnectionTime = parseInt(fieldValue, 10);
}
break;
}
}
}
}
} catch (error) {
if (this.#readyState === EventSource.CLOSED) {
return;
}
if (error.name === "AbortError") {
return;
}
this.#reconnect();
return;
}
this.#reconnect();
}
#findLineEnd(buffer) {
for (let i = 0; i < buffer.length; i++) {
if (buffer[i] === "\n" || buffer[i] === "\r") {
return i;
}
}
return -1;
}
#fail() {
this.#readyState = EventSource.CLOSED;
if (this.#abortController) {
this.#abortController.abort();
this.#abortController = null;
}
if (this.#reconnectTimer) {
clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
}
// Per spec, error events are simple Event objects, not ErrorEvent
this.dispatchEvent(new Event("error"));
}
#reconnect() {
if (this.#readyState === EventSource.CLOSED) {
return;
}
this.#readyState = EventSource.CONNECTING;
// Per spec, error events are simple Event objects, not ErrorEvent
this.dispatchEvent(new Event("error"));
// Clear any existing timer before scheduling a new one
if (this.#reconnectTimer) {
clearTimeout(this.#reconnectTimer);
}
this.#reconnectTimer = setTimeout(() => {
this.#reconnectTimer = null;
this.#connect();
}, this.#reconnectionTime);
}
}

View File

@@ -0,0 +1,638 @@
import { describe, expect, it } from "bun:test";
// Test for https://github.com/oven-sh/bun/issues/3319
// EventSource (Server-Sent Events) implementation
describe("EventSource", () => {
it("should be defined globally", () => {
expect(typeof EventSource).toBe("function");
expect(EventSource.CONNECTING).toBe(0);
expect(EventSource.OPEN).toBe(1);
expect(EventSource.CLOSED).toBe(2);
});
it("should have correct prototype chain", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
expect(es instanceof EventTarget).toBe(true);
expect(EventSource.prototype).toBeDefined();
es.close();
});
describe("connection lifecycle", () => {
it("should start in CONNECTING state", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: test\n\n"));
// Don't close to keep stream open
},
}),
{ headers: { "Content-Type": "text/event-stream" } },
);
},
});
const es = new EventSource(`http://localhost:${server.port}`);
expect(es.readyState).toBe(EventSource.CONNECTING);
es.close();
});
it("should transition to OPEN state when connected", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: test\n\n"));
},
}),
{ headers: { "Content-Type": "text/event-stream" } },
);
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<void>();
es.onopen = () => {
expect(es.readyState).toBe(EventSource.OPEN);
es.close();
resolve();
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
await promise;
});
it("should transition to CLOSED state when close() is called", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: test\n\n"));
},
}),
{ headers: { "Content-Type": "text/event-stream" } },
);
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<void>();
es.onopen = () => {
es.close();
expect(es.readyState).toBe(EventSource.CLOSED);
resolve();
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
await promise;
});
});
describe("message events", () => {
it("should receive simple message events", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: Hello, World!\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<MessageEvent>();
es.onmessage = e => {
es.close();
resolve(e);
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
const event = await promise;
expect(event.data).toBe("Hello, World!");
});
it("should handle multi-line data", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: Line 1\ndata: Line 2\ndata: Line 3\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<MessageEvent>();
es.onmessage = e => {
es.close();
resolve(e);
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
const event = await promise;
expect(event.data).toBe("Line 1\nLine 2\nLine 3");
});
it("should handle custom event types", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("event: custom\ndata: Custom Event Data\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<MessageEvent>();
es.addEventListener("custom", (e: Event) => {
es.close();
resolve(e as MessageEvent);
});
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
const event = await promise;
expect(event.data).toBe("Custom Event Data");
});
it("should track lastEventId", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("id: 123\ndata: test\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<MessageEvent>();
es.onmessage = e => {
es.close();
resolve(e);
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
const event = await promise;
expect(event.lastEventId).toBe("123");
});
});
describe("error handling", () => {
it("should fire error event for wrong MIME type", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "text/plain" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve } = Promise.withResolvers<Event>();
es.onerror = e => {
es.close();
resolve(e);
};
await promise;
expect(es.readyState).toBe(EventSource.CLOSED);
});
it("should fire error event for HTTP errors", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("Not Found", { status: 404 });
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve } = Promise.withResolvers<Event>();
es.onerror = e => {
es.close();
resolve(e);
};
await promise;
expect(es.readyState).toBe(EventSource.CLOSED);
});
it("should close connection on HTTP 204", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response(null, { status: 204 });
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve } = Promise.withResolvers<Event>();
es.onerror = e => {
es.close();
resolve(e);
};
await promise;
expect(es.readyState).toBe(EventSource.CLOSED);
});
});
describe("properties", () => {
it("should have correct url property", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const url = `http://localhost:${server.port}/path?query=value`;
const es = new EventSource(url);
expect(es.url).toBe(url);
es.close();
});
it("should default withCredentials to false", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
expect(es.withCredentials).toBe(false);
es.close();
});
it("should respect withCredentials option", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`, { withCredentials: true });
expect(es.withCredentials).toBe(true);
es.close();
});
it("should have non-enumerable instance getters for state constants", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
// Instance should have getters that delegate to static values
expect(es.CONNECTING).toBe(0);
expect(es.OPEN).toBe(1);
expect(es.CLOSED).toBe(2);
// They should not be own enumerable properties
expect(Object.keys(es)).not.toContain("CONNECTING");
expect(Object.keys(es)).not.toContain("OPEN");
expect(Object.keys(es)).not.toContain("CLOSED");
es.close();
});
});
describe("comments and ignored lines", () => {
it("should ignore comment lines starting with colon", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response(":this is a comment\ndata: actual data\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<MessageEvent>();
es.onmessage = e => {
es.close();
resolve(e);
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
const event = await promise;
expect(event.data).toBe("actual data");
});
});
describe("retry field", () => {
it("should accept valid retry field values", async () => {
// Note: We can't directly test the internal reconnection time,
// but we verify the connection works with a retry field
using server = Bun.serve({
port: 0,
fetch() {
return new Response("retry: 1000\ndata: test\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<MessageEvent>();
es.onmessage = e => {
es.close();
resolve(e);
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
const event = await promise;
expect(event.data).toBe("test");
});
});
describe("multiple messages", () => {
it("should receive multiple messages in sequence", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: first\n\ndata: second\n\ndata: third\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const messages: string[] = [];
const { promise, resolve, reject } = Promise.withResolvers<void>();
es.onmessage = e => {
messages.push(e.data);
if (messages.length >= 3) {
es.close();
resolve();
}
};
es.onerror = () => {
es.close();
// On stream end, error fires before reconnect attempt
if (messages.length >= 3) {
resolve();
} else {
reject(new Error("Connection error"));
}
};
await promise;
expect(messages).toEqual(["first", "second", "third"]);
});
});
describe("CRLF handling", () => {
it("should handle CRLF line endings", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\r\n\r\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<MessageEvent>();
es.onmessage = e => {
es.close();
resolve(e);
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
const event = await promise;
expect(event.data).toBe("test");
});
});
describe("Content-Type handling", () => {
it("should accept Content-Type with parameters", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "text/event-stream; charset=utf-8" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<MessageEvent>();
es.onmessage = e => {
es.close();
resolve(e);
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
const event = await promise;
expect(event.data).toBe("test");
});
it("should accept Content-Type case-insensitively", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "TEXT/EVENT-STREAM" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<MessageEvent>();
es.onmessage = e => {
es.close();
resolve(e);
};
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
const event = await promise;
expect(event.data).toBe("test");
});
});
describe("global reassignment", () => {
it("should allow EventSource to be reassigned", () => {
const original = EventSource;
const fake = function FakeEventSource() {};
try {
// Reassign should work
(globalThis as any).EventSource = fake;
expect(EventSource).toBe(fake);
} finally {
// Always restore original to avoid leaking mutated global
(globalThis as any).EventSource = original;
}
expect(EventSource).toBe(original);
});
});
describe("event handler setters", () => {
it("should handle non-callable values gracefully", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
// Setting non-function should not throw and should result in null
es.onopen = 1 as any;
expect(es.onopen).toBe(null);
es.onmessage = "not a function" as any;
expect(es.onmessage).toBe(null);
es.onerror = {} as any;
expect(es.onerror).toBe(null);
// Setting null should work
es.onopen = null;
expect(es.onopen).toBe(null);
// Setting undefined should result in null
es.onopen = undefined as any;
expect(es.onopen).toBe(null);
es.close();
});
it("should properly replace handlers", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("data: test\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
},
});
const es = new EventSource(`http://localhost:${server.port}`);
const { promise, resolve, reject } = Promise.withResolvers<void>();
let firstCalled = false;
let secondCalled = false;
const firstHandler = () => {
firstCalled = true;
};
const secondHandler = () => {
secondCalled = true;
es.close();
resolve();
};
es.onmessage = firstHandler;
expect(es.onmessage).toBe(firstHandler);
// Replace with second handler - first should be removed
es.onmessage = secondHandler;
expect(es.onmessage).toBe(secondHandler);
es.onerror = () => {
es.close();
reject(new Error("Connection error"));
};
await promise;
expect(firstCalled).toBe(false);
expect(secondCalled).toBe(true);
});
});
});