Skip to content

Commit 52aed50

Browse files
committed
feat: add EventSource (Server-Sent Events) API support
Implement the WHATWG EventSource API for WebF, enabling real-time server-to-client streaming over HTTP. This follows the established two-layer module pattern (TypeScript polyfill + Dart module). - TypeScript polyfill with full EventSource interface (CONNECTING/OPEN/CLOSED states, onopen/onmessage/onerror handlers, named events, close()) - Dart module with SSE line parser per WHATWG spec (data/event/id/retry fields, multi-line data, comment lines, auto-reconnection) - Dedicated Dio instance with IOHttpClientAdapter for SSE streaming to avoid CupertinoAdapter's URLCache buffering on macOS/iOS - HttpClient fallback when useDioForNetwork is disabled - Named event support via separate listener dispatch (bridge limitation workaround) - Integration tests with mock SSE server endpoints - Use case demo page with live stream and named event examples
1 parent 76d0588 commit 52aed50

File tree

12 files changed

+17130
-15633
lines changed

12 files changed

+17130
-15633
lines changed

bridge/core/bridge_polyfill.c

Lines changed: 15962 additions & 15632 deletions
Large diffs are not rendered by default.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Copyright (C) 2024-present The OpenWebF Company. All rights reserved.
3+
* Licensed under GNU GPL with Enterprise exception.
4+
*/
5+
6+
import {webf} from './webf';
7+
8+
const esClientMap: Record<string, EventSource> = {};
9+
// Separate storage for named SSE event listeners. The bridge cannot create
10+
// a MessageEvent with a non-standard type (like 'update'), so we route named
11+
// events outside of the normal EventTarget.dispatchEvent path.
12+
const namedListenersMap: Record<string, Record<string, Array<EventListener>>> = {};
13+
14+
function initPropertyHandlersForEventTargets(eventTarget: any, builtInEvents: string[]) {
15+
for (let i = 0; i < builtInEvents.length; i++) {
16+
const eventName = builtInEvents[i];
17+
const propertyName = 'on' + eventName;
18+
Object.defineProperty(eventTarget, propertyName, {
19+
get: function () {
20+
return this['_' + propertyName];
21+
},
22+
set: function (value) {
23+
if (value == null) {
24+
this.removeEventListener(eventName, this['_' + propertyName]);
25+
} else {
26+
this.addEventListener(eventName, value);
27+
}
28+
this['_' + propertyName] = value;
29+
}
30+
});
31+
}
32+
}
33+
34+
function validateUrl(url: string): string {
35+
// Resolve relative URLs
36+
let resolvedUrl: string;
37+
try {
38+
resolvedUrl = new URL(url, location.href).toString();
39+
} catch (e) {
40+
throw new SyntaxError(`Failed to construct 'EventSource': The URL '${url}' is invalid.`);
41+
}
42+
const protocol = resolvedUrl.substring(0, resolvedUrl.indexOf(':'));
43+
if (protocol !== 'http' && protocol !== 'https') {
44+
throw new SyntaxError(
45+
`Failed to construct 'EventSource': The URL's scheme must be either 'http' or 'https'. '${protocol}' is not allowed.`
46+
);
47+
}
48+
return resolvedUrl;
49+
}
50+
51+
const builtInEvents = ['open', 'message', 'error'];
52+
53+
export class EventSource extends EventTarget {
54+
static CONNECTING = 0;
55+
static OPEN = 1;
56+
static CLOSED = 2;
57+
58+
CONNECTING: number;
59+
OPEN: number;
60+
CLOSED: number;
61+
62+
url: string;
63+
withCredentials: boolean;
64+
readyState: number;
65+
id: string;
66+
67+
constructor(url: string | URL, eventSourceInitDict?: { withCredentials?: boolean }) {
68+
// @ts-ignore
69+
super();
70+
this.CONNECTING = EventSource.CONNECTING;
71+
this.OPEN = EventSource.OPEN;
72+
this.CLOSED = EventSource.CLOSED;
73+
74+
const urlStr = typeof url === 'object' && url instanceof URL ? url.toString() : url;
75+
this.url = validateUrl(urlStr);
76+
this.withCredentials = eventSourceInitDict?.withCredentials ?? false;
77+
this.readyState = EventSource.CONNECTING;
78+
this.id = webf.invokeModule('EventSource', 'init', this.url, this.withCredentials);
79+
esClientMap[this.id] = this;
80+
initPropertyHandlersForEventTargets(this, builtInEvents);
81+
}
82+
83+
addEventListener(type: string, callback: EventListener | EventListenerObject) {
84+
webf.invokeModule('EventSource', 'addEvent', this.id, type);
85+
if (builtInEvents.indexOf(type) === -1) {
86+
// Named event — store in separate map since the bridge cannot create
87+
// a MessageEvent with a non-standard type.
88+
if (!namedListenersMap[this.id]) namedListenersMap[this.id] = {};
89+
if (!namedListenersMap[this.id][type]) namedListenersMap[this.id][type] = [];
90+
namedListenersMap[this.id][type].push(callback as EventListener);
91+
} else {
92+
super.addEventListener(type, callback);
93+
}
94+
}
95+
96+
removeEventListener(type: string, callback: EventListener | EventListenerObject) {
97+
if (builtInEvents.indexOf(type) === -1) {
98+
const listeners = namedListenersMap[this.id]?.[type];
99+
if (listeners) {
100+
const idx = listeners.indexOf(callback as EventListener);
101+
if (idx !== -1) listeners.splice(idx, 1);
102+
}
103+
} else {
104+
super.removeEventListener(type, callback);
105+
}
106+
}
107+
108+
close() {
109+
this.readyState = EventSource.CLOSED;
110+
webf.invokeModule('EventSource', 'close', this.id);
111+
delete esClientMap[this.id];
112+
delete namedListenersMap[this.id];
113+
}
114+
}
115+
116+
webf.addWebfModuleListener('EventSource', function (event: any, data: string) {
117+
// For named SSE events, data is encoded as "clientId\nnamedEventType".
118+
// For standard events (open, error, message), data is just the clientId.
119+
let clientId = data;
120+
let namedEventType = '';
121+
const nlIdx = data.indexOf('\n');
122+
if (nlIdx !== -1) {
123+
clientId = data.substring(0, nlIdx);
124+
namedEventType = data.substring(nlIdx + 1);
125+
}
126+
127+
const client = esClientMap[clientId];
128+
if (client) {
129+
switch (event.type) {
130+
case 'open':
131+
client.readyState = EventSource.OPEN;
132+
break;
133+
case 'error':
134+
if (client.readyState !== EventSource.CLOSED) {
135+
client.readyState = EventSource.CONNECTING;
136+
}
137+
break;
138+
}
139+
140+
if (namedEventType) {
141+
// Named SSE events arrive as MessageEvent with type='message' from the
142+
// bridge (to preserve .data). The bridge cannot create a MessageEvent
143+
// with a non-standard type, and event.type is read-only on the JS side.
144+
// Dispatch directly to stored named listeners.
145+
const listeners = namedListenersMap[clientId]?.[namedEventType];
146+
if (listeners) {
147+
for (let i = 0; i < listeners.length; i++) {
148+
listeners[i](event);
149+
}
150+
}
151+
} else {
152+
client.dispatchEvent(event);
153+
}
154+
}
155+
});

bridge/polyfill/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {Storage, StorageInterface} from './storage';
2020
import { URL } from './url';
2121
import {Webf, webf} from './webf';
2222
import { WebSocket } from './websocket'
23+
import { EventSource } from './event-source'
2324
import { ResizeObserver } from './resize-observer';
2425
import { _AbortController, _AbortSignal } from './abort-signal';
2526
import { BroadcastChannel } from './broadcast-channel';
@@ -45,6 +46,7 @@ defineGlobalProperty('DOMException', DOMException);
4546
defineGlobalProperty('URL', URL);
4647
defineGlobalProperty('webf', webf);
4748
defineGlobalProperty('WebSocket', WebSocket);
49+
defineGlobalProperty('EventSource', EventSource);
4850
defineGlobalProperty('ResizeObserver', ResizeObserver);
4951
defineGlobalProperty('AbortSignal', _AbortSignal);
5052
defineGlobalProperty('AbortController', _AbortController);
@@ -110,6 +112,7 @@ export type PolyFillGlobal = {
110112
DOMException: DOMException,
111113
URL: URL,
112114
WebSocket: WebSocket,
115+
EventSource: EventSource,
113116
ResizeObserver: ResizeObserver,
114117
AbortSignal: _AbortSignal,
115118
AbortController: _AbortController,
@@ -141,6 +144,7 @@ export {
141144
webf,
142145
Webf,
143146
WebSocket,
147+
EventSource,
144148
ResizeObserver,
145149
TextDecoder,
146150
TextEncoder,

integration_tests/scripts/mock_http_server.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,123 @@ app.post('/upload', upload.any(), (req, res) => {
7474
}
7575
});
7676

77+
// --- SSE (Server-Sent Events) endpoints ---
78+
79+
// Sends a fixed number of messages then closes.
80+
app.get('/sse/basic', (req, res) => {
81+
res.writeHead(200, {
82+
'Content-Type': 'text/event-stream',
83+
'Cache-Control': 'no-cache',
84+
'Connection': 'keep-alive',
85+
});
86+
87+
const messages = ['hello', 'world', 'done'];
88+
let i = 0;
89+
const interval = setInterval(() => {
90+
if (i < messages.length) {
91+
res.write(`data: ${messages[i]}\n\n`);
92+
i++;
93+
} else {
94+
clearInterval(interval);
95+
res.end();
96+
}
97+
}, 50);
98+
99+
req.on('close', () => clearInterval(interval));
100+
});
101+
102+
// Sends named events with event type field.
103+
app.get('/sse/named-events', (req, res) => {
104+
res.writeHead(200, {
105+
'Content-Type': 'text/event-stream',
106+
'Cache-Control': 'no-cache',
107+
'Connection': 'keep-alive',
108+
});
109+
110+
const events = [
111+
{ type: 'update', data: 'first-update' },
112+
{ type: 'alert', data: 'important-alert' },
113+
{ data: 'default-message' }, // No event type = 'message'
114+
];
115+
let i = 0;
116+
const interval = setInterval(() => {
117+
if (i < events.length) {
118+
const evt = events[i];
119+
if (evt.type) {
120+
res.write(`event: ${evt.type}\n`);
121+
}
122+
res.write(`data: ${evt.data}\n\n`);
123+
i++;
124+
} else {
125+
clearInterval(interval);
126+
res.end();
127+
}
128+
}, 50);
129+
130+
req.on('close', () => clearInterval(interval));
131+
});
132+
133+
// Sends multi-line data fields.
134+
app.get('/sse/multiline', (req, res) => {
135+
res.writeHead(200, {
136+
'Content-Type': 'text/event-stream',
137+
'Cache-Control': 'no-cache',
138+
'Connection': 'keep-alive',
139+
});
140+
141+
// Multi-line data: two "data:" lines become one event with \n
142+
res.write('data: line1\ndata: line2\n\n');
143+
144+
setTimeout(() => res.end(), 100);
145+
146+
req.on('close', () => {});
147+
});
148+
149+
// Sends id and retry fields.
150+
app.get('/sse/id-and-retry', (req, res) => {
151+
res.writeHead(200, {
152+
'Content-Type': 'text/event-stream',
153+
'Cache-Control': 'no-cache',
154+
'Connection': 'keep-alive',
155+
});
156+
157+
res.write('retry: 500\n');
158+
res.write('id: evt-1\n');
159+
res.write('data: first\n\n');
160+
res.write('id: evt-2\n');
161+
res.write('data: second\n\n');
162+
163+
setTimeout(() => res.end(), 100);
164+
165+
req.on('close', () => {});
166+
});
167+
168+
// Sends comments (lines starting with :) which should be ignored.
169+
app.get('/sse/comments', (req, res) => {
170+
res.writeHead(200, {
171+
'Content-Type': 'text/event-stream',
172+
'Cache-Control': 'no-cache',
173+
'Connection': 'keep-alive',
174+
});
175+
176+
res.write(': this is a comment\n');
177+
res.write('data: after-comment\n\n');
178+
179+
setTimeout(() => res.end(), 100);
180+
181+
req.on('close', () => {});
182+
});
183+
184+
// Immediately closes connection to test error handling.
185+
app.get('/sse/immediate-close', (req, res) => {
186+
res.writeHead(200, {
187+
'Content-Type': 'text/event-stream',
188+
'Cache-Control': 'no-cache',
189+
'Connection': 'keep-alive',
190+
});
191+
res.end();
192+
});
193+
77194
const port = process.env.PORT || 3000;
78195
app.listen(port, () => {
79196
console.log(`Mock HTTP server listening on port ${port}`)

integration_tests/spec_group.json5

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"specs/timer/**/*.{js,jsx,ts,tsx,html}",
1515
"specs/window/**/*.{js,jsx,ts,tsx,html}",
1616
"specs/xhr/**/*.{js,jsx,ts,tsx,html}",
17-
"specs/text-codec/**/*.{js,jsx,ts,tsx,html}"
17+
"specs/text-codec/**/*.{js,jsx,ts,tsx,html}",
18+
"specs/eventsource/**/*.{js,jsx,ts,tsx,html}"
1819
]
1920
},
2021
{

0 commit comments

Comments
 (0)