@karpeleslab/klbfw 0.2.22 → 0.2.23

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/index.d.ts CHANGED
@@ -32,7 +32,50 @@ declare function setCookie(name: string, value: string, expires?: Date | number,
32
32
  declare function rest(name: string, verb: string, params?: Record<string, any>, context?: Record<string, any>): Promise<any>;
33
33
  declare function rest_get(name: string, params?: Record<string, any>): Promise<any>; // Backward compatibility
34
34
  declare function restGet(name: string, params?: Record<string, any>): Promise<any>;
35
- declare function restSSE(name: string, method: 'GET', params?: Record<string, any>, context?: Record<string, any>): EventSource;
35
+
36
+ /** SSE message event */
37
+ interface SSEMessageEvent {
38
+ /** Event type */
39
+ type: string;
40
+ /** Event data */
41
+ data: string;
42
+ /** Last event ID */
43
+ lastEventId: string;
44
+ /** Origin */
45
+ origin: string;
46
+ }
47
+
48
+ /** SSE error event */
49
+ interface SSEErrorEvent {
50
+ type: 'error';
51
+ error: Error | Record<string, any>;
52
+ }
53
+
54
+ /** EventSource-like object returned by restSSE */
55
+ interface SSESource {
56
+ /** Handler called when connection opens */
57
+ onopen: ((event: { type: 'open' }) => void) | null;
58
+ /** Handler called for message events */
59
+ onmessage: ((event: SSEMessageEvent) => void) | null;
60
+ /** Handler called on error */
61
+ onerror: ((event: SSEErrorEvent) => void) | null;
62
+ /** Connection state: 0 = CONNECTING, 1 = OPEN, 2 = CLOSED */
63
+ readyState: number;
64
+ /** CONNECTING state constant */
65
+ readonly CONNECTING: 0;
66
+ /** OPEN state constant */
67
+ readonly OPEN: 1;
68
+ /** CLOSED state constant */
69
+ readonly CLOSED: 2;
70
+ /** Add event listener for specific event type */
71
+ addEventListener(type: string, listener: (event: SSEMessageEvent) => void): void;
72
+ /** Remove event listener */
73
+ removeEventListener(type: string, listener: (event: SSEMessageEvent) => void): void;
74
+ /** Close the connection */
75
+ close(): void;
76
+ }
77
+
78
+ declare function restSSE(name: string, method?: string, params?: Record<string, any>, context?: Record<string, any>): SSESource;
36
79
 
37
80
  // Upload module types
38
81
 
@@ -148,5 +191,8 @@ export {
148
191
  trimPrefix,
149
192
  UploadFileInput,
150
193
  UploadFileOptions,
151
- UploadManyFilesOptions
194
+ UploadManyFilesOptions,
195
+ SSEMessageEvent,
196
+ SSEErrorEvent,
197
+ SSESource
152
198
  };
package/internal.js CHANGED
@@ -344,4 +344,5 @@ module.exports.responseParse = responseParse;
344
344
  module.exports.padNumber = padNumber;
345
345
  module.exports.getTimezoneData = getTimezoneData;
346
346
  module.exports.buildRestUrl = buildRestUrl;
347
- module.exports.internalRest = internalRest;
347
+ module.exports.internalRest = internalRest;
348
+ module.exports.checkAndRefreshToken = checkAndRefreshToken;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/klbfw",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "description": "Frontend Framework",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/rest.js CHANGED
@@ -154,62 +154,359 @@ const restGet = (name, params) => {
154
154
  };
155
155
 
156
156
  /**
157
- * Creates a Server-Sent Events (SSE) connection to a REST API endpoint
157
+ * Parses a single SSE event from text
158
+ * @param {string} eventText - The raw SSE event text
159
+ * @returns {Object} Parsed event object with type, data, id, and retry fields
160
+ */
161
+ const parseSSEEvent = (eventText) => {
162
+ const event = {
163
+ type: 'message',
164
+ data: '',
165
+ id: null,
166
+ retry: null
167
+ };
168
+
169
+ const lines = eventText.split('\n');
170
+ const dataLines = [];
171
+
172
+ for (const line of lines) {
173
+ if (line === '') continue;
174
+
175
+ // Handle lines with field:value format
176
+ const colonIndex = line.indexOf(':');
177
+
178
+ if (colonIndex === 0) {
179
+ // Comment line (starts with :), skip
180
+ continue;
181
+ }
182
+
183
+ let field, value;
184
+ if (colonIndex === -1) {
185
+ // Field with no value
186
+ field = line;
187
+ value = '';
188
+ } else {
189
+ field = line.substring(0, colonIndex);
190
+ // Skip the optional space after colon
191
+ value = line.substring(colonIndex + 1);
192
+ if (value.charAt(0) === ' ') {
193
+ value = value.substring(1);
194
+ }
195
+ }
196
+
197
+ switch (field) {
198
+ case 'event':
199
+ event.type = value;
200
+ break;
201
+ case 'data':
202
+ dataLines.push(value);
203
+ break;
204
+ case 'id':
205
+ event.id = value;
206
+ break;
207
+ case 'retry':
208
+ const retryMs = parseInt(value, 10);
209
+ if (!isNaN(retryMs)) {
210
+ event.retry = retryMs;
211
+ }
212
+ break;
213
+ }
214
+ }
215
+
216
+ // Join data lines with newlines (per SSE spec)
217
+ event.data = dataLines.join('\n');
218
+
219
+ return event;
220
+ };
221
+
222
+ /**
223
+ * Makes a REST API request that handles SSE streaming responses
158
224
  * @param {string} name - API endpoint name
159
- * @param {string} method - HTTP method (must be GET)
225
+ * @param {string} method - HTTP method (GET, POST, etc.)
160
226
  * @param {Object} params - Request parameters
161
- * @param {Object} context - Context object with additional parameters
162
- * @returns {EventSource} EventSource instance for the SSE connection
227
+ * @param {Object} [context] - Context object with additional parameters
228
+ * @returns {Object} EventSource-like object with onmessage, onerror, addEventListener, close
163
229
  */
164
230
  const restSSE = (name, method, params, context) => {
165
- // EventSource only supports GET requests
166
- if (method !== 'GET') {
167
- throw new Error('EventSource only supports GET method');
168
- }
231
+ const abortController = new AbortController();
169
232
 
170
- // EventSource only works in browsers
171
- if (typeof EventSource === 'undefined') {
172
- throw new Error('EventSource is not supported in this environment');
173
- }
233
+ method = method || 'GET';
234
+ params = params || {};
235
+ context = context || {};
236
+
237
+ // EventSource-like object
238
+ const eventSource = {
239
+ onopen: null,
240
+ onmessage: null,
241
+ onerror: null,
242
+ readyState: 0, // 0 = CONNECTING, 1 = OPEN, 2 = CLOSED
243
+ CONNECTING: 0,
244
+ OPEN: 1,
245
+ CLOSED: 2,
246
+ _listeners: {},
247
+
248
+ addEventListener: function(type, listener) {
249
+ if (!this._listeners[type]) {
250
+ this._listeners[type] = [];
251
+ }
252
+ this._listeners[type].push(listener);
253
+ },
254
+
255
+ removeEventListener: function(type, listener) {
256
+ if (!this._listeners[type]) return;
257
+ const idx = this._listeners[type].indexOf(listener);
258
+ if (idx !== -1) {
259
+ this._listeners[type].splice(idx, 1);
260
+ }
261
+ },
262
+
263
+ dispatchEvent: function(event) {
264
+ // Call type-specific handler (onmessage, onerror, etc.)
265
+ const handlerName = 'on' + event.type;
266
+ if (typeof this[handlerName] === 'function') {
267
+ this[handlerName](event);
268
+ }
269
+
270
+ // Call addEventListener listeners
271
+ const listeners = this._listeners[event.type];
272
+ if (listeners) {
273
+ for (const listener of listeners) {
274
+ listener(event);
275
+ }
276
+ }
277
+ },
278
+
279
+ close: function() {
280
+ this.readyState = 2;
281
+ abortController.abort();
282
+ }
283
+ };
174
284
 
175
285
  if (!internal.checkSupport()) {
176
- throw new Error('Environment not supported');
286
+ setTimeout(() => {
287
+ eventSource.readyState = 2;
288
+ eventSource.dispatchEvent({ type: 'error', error: new Error('Environment not supported') });
289
+ }, 0);
290
+ return eventSource;
177
291
  }
178
292
 
179
- params = params || {};
180
- context = context || {};
181
-
182
293
  // Add timezone data if in browser
183
294
  if (typeof window !== 'undefined') {
184
295
  context['t'] = internal.getTimezoneData();
185
296
  }
186
297
 
187
- // Build URL with authentication and context
188
298
  let callUrl = internal.buildRestUrl(name, true, context);
299
+ const headers = {
300
+ 'Accept': 'text/event-stream, application/json'
301
+ };
189
302
 
190
- // Add params to the URL
191
- if (params) {
192
- const glue = callUrl.indexOf('?') === -1 ? '?' : '&';
193
- if (typeof params === 'string') {
194
- callUrl += glue + '_=' + encodeURIComponent(params);
195
- } else {
196
- callUrl += glue + '_=' + encodeURIComponent(JSON.stringify(params));
303
+ const token = fwWrapper.getToken();
304
+ if (token && token !== '') {
305
+ headers['Authorization'] = 'Session ' + token;
306
+ }
307
+
308
+ // Build fetch options based on method
309
+ const fetchOptions = {
310
+ method: method,
311
+ credentials: 'include',
312
+ headers: headers,
313
+ signal: abortController.signal
314
+ };
315
+
316
+ if (method === 'GET') {
317
+ // For GET requests, add params to URL
318
+ if (params && Object.keys(params).length > 0) {
319
+ const glue = callUrl.indexOf('?') === -1 ? '?' : '&';
320
+ if (typeof params === 'string') {
321
+ callUrl += glue + '_=' + encodeURIComponent(params);
322
+ } else {
323
+ callUrl += glue + '_=' + encodeURIComponent(JSON.stringify(params));
324
+ }
197
325
  }
326
+ } else {
327
+ // For other methods, add params to body as JSON
328
+ headers['Content-Type'] = 'application/json; charset=utf-8';
329
+ fetchOptions.body = JSON.stringify(params);
198
330
  }
199
331
 
200
- // Create and return EventSource instance
201
- // Note: EventSource doesn't support custom headers directly,
202
- // but authentication is handled via URL parameters or cookies
203
- const eventSource = new EventSource(callUrl, {
204
- withCredentials: true
205
- });
332
+ // Helper to dispatch SSE events
333
+ const dispatchSSEEvent = (parsedEvent) => {
334
+ const event = {
335
+ type: parsedEvent.type,
336
+ data: parsedEvent.data,
337
+ lastEventId: parsedEvent.id || '',
338
+ origin: ''
339
+ };
206
340
 
207
- // Handle errors and server-side closures
208
- eventSource.onerror = (error) => {
209
- console.error('EventSource error:', error);
210
- eventSource.close();
341
+ // For 'message' type, use onmessage
342
+ if (parsedEvent.type === 'message') {
343
+ eventSource.dispatchEvent(event);
344
+ } else {
345
+ // For custom event types, dispatch to both the specific type and as a generic event
346
+ eventSource.dispatchEvent(event);
347
+ }
211
348
  };
212
349
 
350
+ // Check and refresh token if needed, then make the request
351
+ internal.checkAndRefreshToken().then(() => {
352
+ fetch(callUrl, fetchOptions)
353
+ .then(response => {
354
+ if (!response.ok) {
355
+ // Handle HTTP errors
356
+ const contentType = response.headers.get('content-type') || '';
357
+ if (contentType.indexOf('application/json') !== -1) {
358
+ return response.json().then(json => {
359
+ json.headers = response.headers;
360
+ json.status = response.status;
361
+ throw json;
362
+ });
363
+ }
364
+ throw {
365
+ message: `HTTP Error: ${response.status} ${response.statusText}`,
366
+ status: response.status,
367
+ headers: response.headers
368
+ };
369
+ }
370
+
371
+ // Connection is now open
372
+ eventSource.readyState = 1;
373
+ eventSource.dispatchEvent({ type: 'open' });
374
+
375
+ const contentType = response.headers.get('content-type') || '';
376
+
377
+ // Check if response is SSE
378
+ if (contentType.indexOf('text/event-stream') !== -1) {
379
+ // Stream SSE events
380
+ let buffer = '';
381
+
382
+ const processData = (chunk) => {
383
+ buffer += chunk;
384
+
385
+ // SSE events are separated by double newlines
386
+ const events = buffer.split(/\n\n/);
387
+
388
+ // Keep the last incomplete event in the buffer
389
+ buffer = events.pop() || '';
390
+
391
+ // Process complete events
392
+ for (const eventText of events) {
393
+ if (eventText.trim()) {
394
+ const parsed = parseSSEEvent(eventText);
395
+ if (parsed.data || parsed.type !== 'message') {
396
+ dispatchSSEEvent(parsed);
397
+ }
398
+ }
399
+ }
400
+ };
401
+
402
+ const processEnd = () => {
403
+ // Process any remaining data in buffer
404
+ if (buffer.trim()) {
405
+ const parsed = parseSSEEvent(buffer);
406
+ if (parsed.data || parsed.type !== 'message') {
407
+ dispatchSSEEvent(parsed);
408
+ }
409
+ }
410
+ eventSource.readyState = 2;
411
+ };
412
+
413
+ // Check if we have a web ReadableStream (browser) or Node.js stream
414
+ if (response.body && typeof response.body.getReader === 'function') {
415
+ // Browser environment - use ReadableStream API
416
+ const reader = response.body.getReader();
417
+ const decoder = new TextDecoder();
418
+
419
+ const processStream = () => {
420
+ reader.read().then(({ done, value }) => {
421
+ if (done) {
422
+ processEnd();
423
+ return;
424
+ }
425
+
426
+ processData(decoder.decode(value, { stream: true }));
427
+
428
+ // Continue reading
429
+ processStream();
430
+ }).catch(err => {
431
+ if (err.name !== 'AbortError') {
432
+ eventSource.dispatchEvent({ type: 'error', error: err });
433
+ }
434
+ eventSource.readyState = 2;
435
+ });
436
+ };
437
+
438
+ processStream();
439
+ } else if (response.body && typeof response.body.on === 'function') {
440
+ // Node.js environment - use Node stream API
441
+ response.body.on('data', (chunk) => {
442
+ processData(chunk.toString());
443
+ });
444
+
445
+ response.body.on('end', () => {
446
+ processEnd();
447
+ });
448
+
449
+ response.body.on('error', (err) => {
450
+ // Handle abort errors gracefully
451
+ if (err.type !== 'aborted' && err.name !== 'AbortError') {
452
+ eventSource.dispatchEvent({ type: 'error', error: err });
453
+ }
454
+ eventSource.readyState = 2;
455
+ });
456
+ } else {
457
+ // Fallback - read entire body as text
458
+ response.text().then(text => {
459
+ processData(text);
460
+ processEnd();
461
+ }).catch(err => {
462
+ eventSource.dispatchEvent({ type: 'error', error: err });
463
+ eventSource.readyState = 2;
464
+ });
465
+ }
466
+ } else if (contentType.indexOf('application/json') !== -1) {
467
+ // Non-SSE JSON response - emit as single event
468
+ response.json().then(json => {
469
+ // Check for gtag (consistent with responseParse)
470
+ if (json.gtag && typeof window !== 'undefined' && window.gtag) {
471
+ json.gtag.map(item => window.gtag.apply(null, item));
472
+ }
473
+
474
+ dispatchSSEEvent({
475
+ type: 'message',
476
+ data: JSON.stringify(json),
477
+ id: null
478
+ });
479
+ eventSource.readyState = 2;
480
+ }).catch(err => {
481
+ eventSource.dispatchEvent({ type: 'error', error: err });
482
+ eventSource.readyState = 2;
483
+ });
484
+ } else {
485
+ // Other content types - emit raw text as single event
486
+ response.text().then(text => {
487
+ dispatchSSEEvent({
488
+ type: 'message',
489
+ data: text,
490
+ id: null
491
+ });
492
+ eventSource.readyState = 2;
493
+ }).catch(err => {
494
+ eventSource.dispatchEvent({ type: 'error', error: err });
495
+ eventSource.readyState = 2;
496
+ });
497
+ }
498
+ })
499
+ .catch(err => {
500
+ if (err.name !== 'AbortError') {
501
+ eventSource.dispatchEvent({ type: 'error', error: err });
502
+ }
503
+ eventSource.readyState = 2;
504
+ });
505
+ }).catch(err => {
506
+ eventSource.dispatchEvent({ type: 'error', error: err });
507
+ eventSource.readyState = 2;
508
+ });
509
+
213
510
  return eventSource;
214
511
  };
215
512