mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 13:51:47 +00:00
Compare commits
6 Commits
claude/fix
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a67efca0c | ||
|
|
e49dce35be | ||
|
|
9d1c0efd24 | ||
|
|
c302061d1f | ||
|
|
f661989942 | ||
|
|
77b69c6a3e |
@@ -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();
|
||||
|
||||
@@ -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: \
|
||||
|
||||
348
src/js/thirdparty/undici.js
vendored
348
src/js/thirdparty/undici.js
vendored
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
638
test/regression/issue/3319.test.ts
Normal file
638
test/regression/issue/3319.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user