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.
@@ -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
- **Usage:**
114
- ```javascript
115
- const { observable } = require('fnl');
116
- const Observable_Publisher = require('jsgui3-server/publishers/http-observable-publisher');
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
- **Client-Side Consumption:**
144
- ```javascript
145
- // In browser, use EventSource API
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
- event: message
171
- data:{"tick":1,"timestamp":1234567890,"message":"Server tick #1"}
172
-
173
- event: message
174
- data:{"tick":2,"timestamp":1234567891,"message":"Server tick #2"}
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
- event: message
65
- data: {"progress": 10, "stage": "Loading..."}
66
-
67
- event: message
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.43",
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.142",
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 jsgui = require('jsgui3-html');
2
-
3
- const {
4
- Evented_Class
5
- } = jsgui;
6
-
7
- const {
8
- observable
9
- } = require('fnl');
10
-
11
- const HTTP_Publisher = require('./http-publisher');
12
-
13
- // Evented_Class_Publisher?
14
- // Evented_Publisher
15
-
16
- // Want to do this over websockets too.
17
- // Should be a single ws point.
18
- // Could give out conntection keys alongside the HTTP request.
19
- // All the security needed - give out a 256 bit key in the HTML doc, require it to open the websocket connection.
20
- // Seems simple, would work with a single process auth provider, then could have centralised auth and token issuance and checking.
21
-
22
-
23
- class Observable_Publisher extends HTTP_Publisher {
24
- constructor(spec) {
25
- super(spec);
26
- let obs, schema;
27
- // needs to hook into the server though...
28
- if (spec.next && spec.complete && spec.error) {
29
- obs = spec;
30
- } else {
31
-
32
- if (spec.obs) {
33
- obs = spec.obs;
34
- } else {
35
- throw 'No observable found.';
36
- }
37
-
38
- //let {schema} = spec;
39
-
40
- schema = spec.schema;
41
-
42
- //console.trace();
43
- //throw 'NYI';
44
- }
45
- // need to be able to process (http) requests.
46
- // reobserve that observable to prevent too many events being attached.
47
-
48
- // reobserve
49
- // code here could be written as resobserve function.
50
- // or just obs on an existing obs?
51
-
52
- let obs2 = observable((next, complete, error) => {
53
- obs.on('next', data => {
54
- next(data);
55
- })
56
- return [];
57
- })
58
- this.type = 'observable';
59
-
60
- // Also should publish when it's over.
61
-
62
- /*
63
-
64
- => Request
65
- GET /stream HTTP/1.1
66
- Host: example.com
67
- Accept: text/event-stream
68
-
69
- <= Response
70
- HTTP/1.1 200 OK
71
- Connection: keep-alive
72
- Content-Type: text/event-stream
73
- Transfer-Encoding: chunked
74
-
75
- retry: 15000
76
-
77
- data: First message is a simple string.
78
-
79
- data: {"message": "JSON payload"}
80
-
81
- event: foo
82
- data: Message of type "foo"
83
-
84
- id: 42
85
- event: bar
86
- data: Multi-line message of
87
- data: type "bar" and id "42"
88
-
89
- id: 43
90
- data: Last message, id "43"
91
-
92
- 1
93
- 2
94
- 3
95
- 4
96
- event: message\n
97
- data: this is an important message\n
98
- data: that has two lines\n
99
- \n
100
-
101
- */
102
-
103
-
104
- this.handle_http = (req, res) => {
105
- // need to handle observable http request.
106
- // Begin sending to that connection...
107
- // Following SSE would be nice.
108
- res.writeHead(200, {
109
- 'Content-Type': 'text/event-stream',
110
- 'Transfer-Encoding': 'chunked',
111
- //'Trailer': 'Content-MD5'
112
- });
113
- res.write('OK\n');
114
- let obs2_handler = data => {
115
- //console.log('data', data);
116
- let s_data = JSON.stringify(data);
117
- //res.write(s_data + '\n');
118
- res.write('event: message\ndata:' + s_data + '\n\n');
119
- }
120
- obs2.on('next', obs2_handler);
121
- }
122
- }
123
- }
124
-
125
- module.exports = Observable_Publisher;
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(30000); // Allow time for server startup and SSE streaming
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
- }, 15000);
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();