jsgui3-server 0.0.142 → 0.0.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/publishers-guide.md
CHANGED
|
@@ -104,16 +104,18 @@ const publisher = new HTTP_Function_Publisher({
|
|
|
104
104
|
|
|
105
105
|
**Purpose:** Streams observable data to clients using Server-Sent Events (SSE).
|
|
106
106
|
|
|
107
|
-
**Key Features:**
|
|
108
|
-
- Real-time streaming of observable events
|
|
109
|
-
- SSE protocol support (`text/event-stream`)
|
|
110
|
-
- Chunked transfer encoding for long-running connections
|
|
111
|
-
- Integration with `fnl` observables
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
107
|
+
**Key Features:**
|
|
108
|
+
- Real-time streaming of observable events
|
|
109
|
+
- SSE protocol support (`text/event-stream`)
|
|
110
|
+
- Chunked transfer encoding for long-running connections
|
|
111
|
+
- Integration with `fnl` observables
|
|
112
|
+
- Connection cleanup on client disconnect
|
|
113
|
+
- Optional `pause()`, `resume()`, `stop()` controls
|
|
114
|
+
|
|
115
|
+
**Usage:**
|
|
116
|
+
```javascript
|
|
117
|
+
const { observable } = require('fnl');
|
|
118
|
+
const Observable_Publisher = require('jsgui3-server/publishers/http-observable-publisher');
|
|
117
119
|
|
|
118
120
|
// Create a hot observable that emits continuously
|
|
119
121
|
let tick_count = 0;
|
|
@@ -136,13 +138,28 @@ const publisher = new Observable_Publisher({
|
|
|
136
138
|
obs: tick_stream
|
|
137
139
|
});
|
|
138
140
|
|
|
139
|
-
// Register with server router
|
|
140
|
-
server.server_router.set_route('/api/stream', publisher, publisher.handle_http);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
// Register with server router
|
|
142
|
+
server.server_router.set_route('/api/stream', publisher, publisher.handle_http);
|
|
143
|
+
|
|
144
|
+
// Optional: control the stream from server-side code
|
|
145
|
+
publisher.pause();
|
|
146
|
+
publisher.resume();
|
|
147
|
+
publisher.stop();
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Optional Control (HTTP):**
|
|
151
|
+
```javascript
|
|
152
|
+
// From browser or any HTTP client:
|
|
153
|
+
await fetch('/api/stream', {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
156
|
+
body: JSON.stringify({ action: 'pause' }) // 'resume' | 'stop' | 'status'
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Client-Side Consumption:**
|
|
161
|
+
```javascript
|
|
162
|
+
// In browser, use EventSource API
|
|
146
163
|
const eventSource = new EventSource('/api/stream');
|
|
147
164
|
|
|
148
165
|
eventSource.onmessage = (event) => {
|
|
@@ -159,20 +176,19 @@ eventSource.onerror = () => {
|
|
|
159
176
|
};
|
|
160
177
|
```
|
|
161
178
|
|
|
162
|
-
**SSE Protocol:**
|
|
163
|
-
The publisher sends events in SSE format:
|
|
164
|
-
```
|
|
165
|
-
HTTP/1.1 200 OK
|
|
166
|
-
Content-Type: text/event-stream
|
|
167
|
-
Transfer-Encoding: chunked
|
|
168
|
-
|
|
169
|
-
OK
|
|
170
|
-
|
|
171
|
-
data:{"tick":1,"timestamp":1234567890,"message":"Server tick #1"}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
```
|
|
179
|
+
**SSE Protocol:**
|
|
180
|
+
The publisher sends events in SSE format:
|
|
181
|
+
```
|
|
182
|
+
HTTP/1.1 200 OK
|
|
183
|
+
Content-Type: text/event-stream
|
|
184
|
+
Transfer-Encoding: chunked
|
|
185
|
+
|
|
186
|
+
data: OK
|
|
187
|
+
|
|
188
|
+
data: {"tick":1,"timestamp":1234567890,"message":"Server tick #1"}
|
|
189
|
+
|
|
190
|
+
data: {"tick":2,"timestamp":1234567891,"message":"Server tick #2"}
|
|
191
|
+
```
|
|
176
192
|
|
|
177
193
|
**See Also:** [Observable SSE Demo](../examples/controls/15)%20window,%20observable%20SSE/) for a complete working example.
|
|
178
194
|
|
|
@@ -386,4 +402,4 @@ Publishers provide comprehensive logging:
|
|
|
386
402
|
|
|
387
403
|
---
|
|
388
404
|
|
|
389
|
-
This guide provides the foundation for understanding and extending the publisher system. For specific publisher implementations, refer to their individual source files in the `publishers/` directory.
|
|
405
|
+
This guide provides the foundation for understanding and extending the publisher system. For specific publisher implementations, refer to their individual source files in the `publishers/` directory.
|
|
@@ -52,23 +52,35 @@ node server.js
|
|
|
52
52
|
|
|
53
53
|
2. **Reactive UI Updates**: The `Observable_Demo_UI` control updates progress bars, status text, and log entries in real-time as events arrive.
|
|
54
54
|
|
|
55
|
-
## The SSE Protocol
|
|
56
|
-
|
|
57
|
-
Server-Sent Events use HTTP chunked transfer encoding:
|
|
58
|
-
|
|
59
|
-
```
|
|
60
|
-
HTTP/1.1 200 OK
|
|
61
|
-
Content-Type: text/event-stream
|
|
62
|
-
Transfer-Encoding: chunked
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
data: {"progress": 20, "stage": "Processing..."}
|
|
69
|
-
|
|
70
|
-
...
|
|
71
|
-
```
|
|
55
|
+
## The SSE Protocol
|
|
56
|
+
|
|
57
|
+
Server-Sent Events use HTTP chunked transfer encoding:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
HTTP/1.1 200 OK
|
|
61
|
+
Content-Type: text/event-stream
|
|
62
|
+
Transfer-Encoding: chunked
|
|
63
|
+
|
|
64
|
+
data: OK
|
|
65
|
+
|
|
66
|
+
data: {"progress": 10, "stage": "Loading..."}
|
|
67
|
+
|
|
68
|
+
data: {"progress": 20, "stage": "Processing..."}
|
|
69
|
+
|
|
70
|
+
...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Optional Pause/Resume/Stop Control
|
|
74
|
+
|
|
75
|
+
`HTTP_Observable_Publisher` also supports controlling the published observable:
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
await fetch('/api/tick-stream', {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ action: 'pause' }) // 'resume' | 'stop' | 'status'
|
|
82
|
+
});
|
|
83
|
+
```
|
|
72
84
|
|
|
73
85
|
## Observable Pattern Benefits
|
|
74
86
|
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"jsgui3-html": "^0.0.175",
|
|
15
15
|
"jsgui3-webpage": "^0.0.8",
|
|
16
16
|
"jsgui3-website": "^0.0.8",
|
|
17
|
-
"lang-tools": "^0.0.
|
|
17
|
+
"lang-tools": "^0.0.44",
|
|
18
18
|
"mocha": "^11.7.5",
|
|
19
19
|
"multiparty": "^4.2.3",
|
|
20
20
|
"ncp": "^2.0.0",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"type": "git",
|
|
40
40
|
"url": "https://github.com/metabench/jsgui3-server.git"
|
|
41
41
|
},
|
|
42
|
-
"version": "0.0.
|
|
42
|
+
"version": "0.0.143",
|
|
43
43
|
"scripts": {
|
|
44
44
|
"cli": "node cli.js",
|
|
45
45
|
"serve": "node cli.js serve",
|
|
@@ -1,125 +1,318 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
=>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
1
|
+
const HTTP_Publisher = require('./http-publisher');
|
|
2
|
+
|
|
3
|
+
const default_keep_alive_interval_ms = 15000;
|
|
4
|
+
|
|
5
|
+
// Want to do this over websockets too.
|
|
6
|
+
// Should be a single ws point.
|
|
7
|
+
// Could give out connection keys alongside the HTTP request.
|
|
8
|
+
// All the security needed - give out a 256 bit key in the HTML doc, require it to open the websocket connection.
|
|
9
|
+
// Seems simple, would work with a single process auth provider, then could have centralised auth and token issuance and checking.
|
|
10
|
+
|
|
11
|
+
class Observable_Publisher extends HTTP_Publisher {
|
|
12
|
+
constructor(spec = {}) {
|
|
13
|
+
super(spec);
|
|
14
|
+
|
|
15
|
+
let obs;
|
|
16
|
+
if (spec && spec.obs) {
|
|
17
|
+
obs = spec.obs;
|
|
18
|
+
if (spec.schema) this.schema = spec.schema;
|
|
19
|
+
} else {
|
|
20
|
+
obs = spec;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!obs || typeof obs.on !== 'function') {
|
|
24
|
+
throw new Error('No observable found.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.type = 'observable';
|
|
28
|
+
this.obs = obs;
|
|
29
|
+
|
|
30
|
+
this.keep_alive_interval_ms = (spec && spec.keep_alive_interval_ms !== undefined)
|
|
31
|
+
? spec.keep_alive_interval_ms
|
|
32
|
+
: default_keep_alive_interval_ms;
|
|
33
|
+
|
|
34
|
+
this.is_paused = (typeof obs.status === 'string' && obs.status === 'paused');
|
|
35
|
+
|
|
36
|
+
this.active_sse_connections = new Set();
|
|
37
|
+
this._keep_alive_timer = null;
|
|
38
|
+
|
|
39
|
+
this._source_handlers_bound = false;
|
|
40
|
+
this._bind_source_handlers();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_bind_source_handlers() {
|
|
44
|
+
if (this._source_handlers_bound) return;
|
|
45
|
+
this._source_handlers_bound = true;
|
|
46
|
+
|
|
47
|
+
const { obs } = this;
|
|
48
|
+
|
|
49
|
+
this._source_next_handler = (data) => {
|
|
50
|
+
if (this.is_paused) return;
|
|
51
|
+
this._broadcast_sse_data(data);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this._source_complete_handler = (data) => {
|
|
55
|
+
this._broadcast_sse_event('complete', data);
|
|
56
|
+
this._close_all_sse_connections();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
this._source_error_handler = (err) => {
|
|
60
|
+
this._broadcast_sse_event('error', this._normalize_error(err));
|
|
61
|
+
this._close_all_sse_connections();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
this._source_paused_handler = () => {
|
|
65
|
+
this.is_paused = true;
|
|
66
|
+
this._broadcast_sse_event('paused', { status: 'paused' });
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
this._source_resumed_handler = () => {
|
|
70
|
+
this.is_paused = false;
|
|
71
|
+
this._broadcast_sse_event('resumed', { status: 'ok' });
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
obs.on('next', this._source_next_handler);
|
|
75
|
+
obs.on('complete', this._source_complete_handler);
|
|
76
|
+
obs.on('error', this._source_error_handler);
|
|
77
|
+
obs.on('paused', this._source_paused_handler);
|
|
78
|
+
obs.on('resumed', this._source_resumed_handler);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_unbind_source_handlers() {
|
|
82
|
+
if (!this._source_handlers_bound) return;
|
|
83
|
+
this._source_handlers_bound = false;
|
|
84
|
+
|
|
85
|
+
const { obs } = this;
|
|
86
|
+
const safe_off = (event_name, handler) => {
|
|
87
|
+
if (typeof obs.off === 'function' && handler) {
|
|
88
|
+
obs.off(event_name, handler);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
safe_off('next', this._source_next_handler);
|
|
93
|
+
safe_off('complete', this._source_complete_handler);
|
|
94
|
+
safe_off('error', this._source_error_handler);
|
|
95
|
+
safe_off('paused', this._source_paused_handler);
|
|
96
|
+
safe_off('resumed', this._source_resumed_handler);
|
|
97
|
+
|
|
98
|
+
this._source_next_handler = null;
|
|
99
|
+
this._source_complete_handler = null;
|
|
100
|
+
this._source_error_handler = null;
|
|
101
|
+
this._source_paused_handler = null;
|
|
102
|
+
this._source_resumed_handler = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_normalize_error(err) {
|
|
106
|
+
if (!err) return { message: 'Unknown error' };
|
|
107
|
+
if (typeof err === 'string') return { message: err };
|
|
108
|
+
if (err instanceof Error) return { name: err.name, message: err.message, stack: err.stack };
|
|
109
|
+
return { message: String(err) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_stringify_sse_data(data) {
|
|
113
|
+
if (data === undefined) return '';
|
|
114
|
+
if (typeof data === 'string') return data;
|
|
115
|
+
try {
|
|
116
|
+
return JSON.stringify(data);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return JSON.stringify({
|
|
119
|
+
error: 'non_serializable',
|
|
120
|
+
message: this._normalize_error(err).message
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_write_sse(res, s) {
|
|
126
|
+
try {
|
|
127
|
+
res.write(s);
|
|
128
|
+
return true;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_broadcast_raw_sse(s) {
|
|
135
|
+
const arr_connections = Array.from(this.active_sse_connections);
|
|
136
|
+
for (const res of arr_connections) {
|
|
137
|
+
if (res.destroyed || res.writableEnded) {
|
|
138
|
+
this.active_sse_connections.delete(res);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const ok = this._write_sse(res, s);
|
|
142
|
+
if (!ok || res.destroyed || res.writableEnded) {
|
|
143
|
+
this.active_sse_connections.delete(res);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this._maybe_stop_keep_alive_timer();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_broadcast_sse_data(data) {
|
|
150
|
+
const s_data = this._stringify_sse_data(data);
|
|
151
|
+
this._broadcast_raw_sse(`data: ${s_data}\n\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_broadcast_sse_event(event_name, data) {
|
|
155
|
+
const s_data = this._stringify_sse_data(data);
|
|
156
|
+
this._broadcast_raw_sse(`event: ${event_name}\ndata: ${s_data}\n\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_close_all_sse_connections() {
|
|
160
|
+
const arr_connections = Array.from(this.active_sse_connections);
|
|
161
|
+
this.active_sse_connections.clear();
|
|
162
|
+
for (const res of arr_connections) {
|
|
163
|
+
try {
|
|
164
|
+
if (!res.writableEnded) res.end();
|
|
165
|
+
} catch (e) { }
|
|
166
|
+
}
|
|
167
|
+
this._maybe_stop_keep_alive_timer();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_ensure_keep_alive_timer() {
|
|
171
|
+
if (!this.keep_alive_interval_ms) return;
|
|
172
|
+
if (this._keep_alive_timer) return;
|
|
173
|
+
|
|
174
|
+
this._keep_alive_timer = setInterval(() => {
|
|
175
|
+
this._broadcast_raw_sse(`: keep-alive ${Date.now()}\n\n`);
|
|
176
|
+
}, this.keep_alive_interval_ms);
|
|
177
|
+
|
|
178
|
+
if (typeof this._keep_alive_timer.unref === 'function') {
|
|
179
|
+
this._keep_alive_timer.unref();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_maybe_stop_keep_alive_timer() {
|
|
184
|
+
if (this._keep_alive_timer && this.active_sse_connections.size === 0) {
|
|
185
|
+
clearInterval(this._keep_alive_timer);
|
|
186
|
+
this._keep_alive_timer = null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pause() {
|
|
191
|
+
this.is_paused = true;
|
|
192
|
+
if (this.obs && typeof this.obs.pause === 'function') {
|
|
193
|
+
this.obs.pause();
|
|
194
|
+
} else {
|
|
195
|
+
this._broadcast_sse_event('paused', { status: 'paused' });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
resume() {
|
|
200
|
+
this.is_paused = false;
|
|
201
|
+
if (this.obs && typeof this.obs.resume === 'function') {
|
|
202
|
+
this.obs.resume();
|
|
203
|
+
} else {
|
|
204
|
+
this._broadcast_sse_event('resumed', { status: 'ok' });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
stop() {
|
|
209
|
+
if (this.obs && typeof this.obs.stop === 'function') {
|
|
210
|
+
this.obs.stop();
|
|
211
|
+
}
|
|
212
|
+
this._broadcast_sse_event('stopped', { status: 'stopped' });
|
|
213
|
+
this._close_all_sse_connections();
|
|
214
|
+
this._unbind_source_handlers();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_handle_http_control(req, res) {
|
|
218
|
+
const chunks = [];
|
|
219
|
+
|
|
220
|
+
req.on('data', chunk => {
|
|
221
|
+
chunks.push(chunk);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
req.on('end', () => {
|
|
225
|
+
const content_type = (req.headers['content-type'] || '').toLowerCase();
|
|
226
|
+
const body = Buffer.concat(chunks).toString();
|
|
227
|
+
|
|
228
|
+
let action = '';
|
|
229
|
+
if (content_type.includes('application/json')) {
|
|
230
|
+
try {
|
|
231
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
232
|
+
action = parsed.action || parsed.command || parsed.cmd || '';
|
|
233
|
+
} catch (e) {
|
|
234
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
235
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
action = body.trim();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const action_lc = String(action || 'status').toLowerCase();
|
|
243
|
+
|
|
244
|
+
if (action_lc === 'pause') this.pause();
|
|
245
|
+
else if (action_lc === 'resume') this.resume();
|
|
246
|
+
else if (action_lc === 'stop') this.stop();
|
|
247
|
+
else if (action_lc !== 'status') {
|
|
248
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
249
|
+
res.end(JSON.stringify({ ok: false, error: 'Unknown action', action: action_lc }));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const obs_status = (this.obs && typeof this.obs.status === 'string')
|
|
254
|
+
? this.obs.status
|
|
255
|
+
: (this.is_paused ? 'paused' : 'ok');
|
|
256
|
+
|
|
257
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
258
|
+
res.end(JSON.stringify({
|
|
259
|
+
ok: true,
|
|
260
|
+
action: action_lc,
|
|
261
|
+
status: obs_status,
|
|
262
|
+
connections: this.active_sse_connections.size
|
|
263
|
+
}));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
req.on('error', (err) => {
|
|
267
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
268
|
+
res.end(JSON.stringify({ ok: false, error: this._normalize_error(err) }));
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
handle_http(req, res) {
|
|
273
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
274
|
+
|
|
275
|
+
if (method === 'POST') {
|
|
276
|
+
this._handle_http_control(req, res);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (method !== 'GET') {
|
|
281
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
282
|
+
res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
res.writeHead(200, {
|
|
287
|
+
'Content-Type': 'text/event-stream',
|
|
288
|
+
'Cache-Control': 'no-cache',
|
|
289
|
+
'Connection': 'keep-alive',
|
|
290
|
+
'Transfer-Encoding': 'chunked',
|
|
291
|
+
'X-Accel-Buffering': 'no'
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// SSE handshake (consumed by client demo; ignored by tests).
|
|
295
|
+
this._write_sse(res, 'data: OK\n\n');
|
|
296
|
+
|
|
297
|
+
this.active_sse_connections.add(res);
|
|
298
|
+
this._ensure_keep_alive_timer();
|
|
299
|
+
|
|
300
|
+
if (this.is_paused) {
|
|
301
|
+
this._write_sse(res, `event: paused\ndata: ${this._stringify_sse_data({ status: 'paused' })}\n\n`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let removed = false;
|
|
305
|
+
const remove_connection = () => {
|
|
306
|
+
if (removed) return;
|
|
307
|
+
removed = true;
|
|
308
|
+
this.active_sse_connections.delete(res);
|
|
309
|
+
this._maybe_stop_keep_alive_timer();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
req.on('close', remove_connection);
|
|
313
|
+
res.on('close', remove_connection);
|
|
314
|
+
res.on('finish', remove_connection);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = Observable_Publisher;
|
|
@@ -9,8 +9,8 @@ const http = require('http');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const { get_free_port } = require('../port-utils');
|
|
11
11
|
|
|
12
|
-
describe('Observable SSE Demo E2E Tests', function() {
|
|
13
|
-
this.timeout(
|
|
12
|
+
describe('Observable SSE Demo E2E Tests', function() {
|
|
13
|
+
this.timeout(90000); // Allow time for bundling + server startup + SSE streaming
|
|
14
14
|
|
|
15
15
|
let server_process;
|
|
16
16
|
let server_port;
|
|
@@ -140,9 +140,9 @@ describe('Observable SSE Demo E2E Tests', function() {
|
|
|
140
140
|
// Wait for server to be ready
|
|
141
141
|
await new Promise((resolve, reject) => {
|
|
142
142
|
let output = '';
|
|
143
|
-
const timeout = setTimeout(() => {
|
|
144
|
-
reject(new Error('Server startup timeout'));
|
|
145
|
-
},
|
|
143
|
+
const timeout = setTimeout(() => {
|
|
144
|
+
reject(new Error('Server startup timeout'));
|
|
145
|
+
}, 45000);
|
|
146
146
|
|
|
147
147
|
server_process.stdout.on('data', (data) => {
|
|
148
148
|
output += data.toString();
|