@karpeleslab/klbfw 0.2.22 → 0.2.24

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.
Files changed (4) hide show
  1. package/index.d.ts +201 -5
  2. package/internal.js +2 -1
  3. package/package.json +1 -1
  4. package/rest.js +331 -34
package/index.d.ts CHANGED
@@ -29,10 +29,196 @@ declare function hasCookie(name: string): boolean;
29
29
  declare function setCookie(name: string, value: string, expires?: Date | number, path?: string, domain?: string, secure?: boolean): void;
30
30
 
31
31
  // REST API types
32
- declare function rest(name: string, verb: string, params?: Record<string, any>, context?: Record<string, any>): Promise<any>;
33
- declare function rest_get(name: string, params?: Record<string, any>): Promise<any>; // Backward compatibility
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;
32
+
33
+ /** Paging information returned by list endpoints */
34
+ interface RestPaging {
35
+ /** Current page number (1-indexed) */
36
+ page_no: number;
37
+ /** Total number of results across all pages */
38
+ count: number;
39
+ /** Maximum page number available */
40
+ page_max: number;
41
+ /** Number of results per page */
42
+ results_per_page: number;
43
+ }
44
+
45
+ /**
46
+ * Successful REST API response wrapper
47
+ * @typeParam T - Type of the data field
48
+ */
49
+ interface RestResponse<T = any> {
50
+ /** Result status */
51
+ result: 'success' | 'redirect';
52
+ /** Unique request identifier for debugging */
53
+ request_id: string;
54
+ /** Request processing time in seconds */
55
+ time: number;
56
+ /** Response payload */
57
+ data: T;
58
+ /** Paging information for list endpoints */
59
+ paging?: RestPaging;
60
+ /** Additional response fields */
61
+ [key: string]: any;
62
+ }
63
+
64
+ /** REST API error object (thrown on promise rejection) */
65
+ interface RestError {
66
+ /** Always 'error' for error responses */
67
+ result: 'error';
68
+ /** Exception class name from server */
69
+ exception: string;
70
+ /** Human-readable error message */
71
+ error: string;
72
+ /** HTTP status code */
73
+ code: number;
74
+ /** Translatable error token (e.g., 'error_invalid_field') */
75
+ token: string;
76
+ /** Request ID for debugging */
77
+ request: string;
78
+ /** Structured message data for translation */
79
+ message: Record<string, any>;
80
+ /** Parameter name that caused the error, if applicable */
81
+ param?: string;
82
+ /** Request processing time in seconds */
83
+ time: number;
84
+ /** Additional error fields */
85
+ [key: string]: any;
86
+ }
87
+
88
+ /**
89
+ * Server DateTime object
90
+ * @example
91
+ * // Convert to JavaScript Date
92
+ * new Date(Number(datetime.unixms))
93
+ */
94
+ interface DateTime {
95
+ /** Unix timestamp in milliseconds (use this for JS Date conversion) */
96
+ unixms: string | number;
97
+ /** Unix timestamp in seconds */
98
+ unix?: number;
99
+ /** Microseconds component */
100
+ us?: number;
101
+ /** ISO 8601 formatted string with microseconds */
102
+ iso?: string;
103
+ /** Timezone identifier (e.g., 'Asia/Tokyo') */
104
+ tz?: string;
105
+ /** Full precision timestamp as string (unix seconds + microseconds) */
106
+ full?: string;
107
+ }
108
+
109
+ /**
110
+ * Extended integer for precise decimal arithmetic without floating-point errors.
111
+ * Value = v / 10^e = f
112
+ *
113
+ * When sending to API, you can provide either:
114
+ * - Just `f` (as string or number)
115
+ * - Both `v` and `e`
116
+ *
117
+ * @example
118
+ * // $358.20 represented as:
119
+ * { v: "35820000", e: 5, f: 358.2 }
120
+ */
121
+ interface Xint {
122
+ /** Integer value (multiply by 10^-e to get actual value) */
123
+ v?: string;
124
+ /** Exponent (number of decimal places) */
125
+ e?: number;
126
+ /** Float value (convenience field, may have precision loss) */
127
+ f?: string | number;
128
+ }
129
+
130
+ /** Base price value without tax breakdown */
131
+ interface PriceValue {
132
+ /** Decimal value as string (e.g., "358.20000") */
133
+ value: string;
134
+ /** Integer representation for precise arithmetic */
135
+ value_int: string;
136
+ /** Value in cents/smallest currency unit */
137
+ value_cent: string;
138
+ /** Display-ready decimal string (e.g., "358.20") */
139
+ value_disp: string;
140
+ /** Extended integer for precise calculations */
141
+ value_xint: Xint;
142
+ /** Formatted display string with currency symbol (e.g., "$358.20") */
143
+ display: string;
144
+ /** Short formatted display string */
145
+ display_short: string;
146
+ /** ISO 4217 currency code (e.g., "USD") */
147
+ currency: string;
148
+ /** Currency unit (usually same as currency) */
149
+ unit: string;
150
+ /** Whether VAT/tax is included in this value */
151
+ has_vat: boolean;
152
+ /** Tax profile identifier or null if exempt */
153
+ tax_profile: string | null;
154
+ }
155
+
156
+ /**
157
+ * Full price object with optional tax breakdown
158
+ * @example
159
+ * // Display price with tax info
160
+ * console.log(price.display); // "$358.20"
161
+ * console.log(price.tax?.display); // "$358.20" (with tax)
162
+ * console.log(price.tax_only?.display); // "$0.00" (tax amount only)
163
+ */
164
+ interface Price extends PriceValue {
165
+ /** Original price before any tax calculations */
166
+ raw?: PriceValue;
167
+ /** Price including tax */
168
+ tax?: PriceValue;
169
+ /** Tax amount only */
170
+ tax_only?: PriceValue;
171
+ /** Tax rate as decimal (e.g., 0.1 for 10%) */
172
+ tax_rate?: number;
173
+ }
174
+
175
+ declare function rest<T = any>(name: string, verb: string, params?: Record<string, any>, context?: Record<string, any>): Promise<RestResponse<T>>;
176
+ declare function rest_get<T = any>(name: string, params?: Record<string, any>): Promise<RestResponse<T>>; // Backward compatibility
177
+ declare function restGet<T = any>(name: string, params?: Record<string, any>): Promise<RestResponse<T>>;
178
+
179
+ /** SSE message event */
180
+ interface SSEMessageEvent {
181
+ /** Event type */
182
+ type: string;
183
+ /** Event data */
184
+ data: string;
185
+ /** Last event ID */
186
+ lastEventId: string;
187
+ /** Origin */
188
+ origin: string;
189
+ }
190
+
191
+ /** SSE error event */
192
+ interface SSEErrorEvent {
193
+ type: 'error';
194
+ error: Error | Record<string, any>;
195
+ }
196
+
197
+ /** EventSource-like object returned by restSSE */
198
+ interface SSESource {
199
+ /** Handler called when connection opens */
200
+ onopen: ((event: { type: 'open' }) => void) | null;
201
+ /** Handler called for message events */
202
+ onmessage: ((event: SSEMessageEvent) => void) | null;
203
+ /** Handler called on error */
204
+ onerror: ((event: SSEErrorEvent) => void) | null;
205
+ /** Connection state: 0 = CONNECTING, 1 = OPEN, 2 = CLOSED */
206
+ readyState: number;
207
+ /** CONNECTING state constant */
208
+ readonly CONNECTING: 0;
209
+ /** OPEN state constant */
210
+ readonly OPEN: 1;
211
+ /** CLOSED state constant */
212
+ readonly CLOSED: 2;
213
+ /** Add event listener for specific event type */
214
+ addEventListener(type: string, listener: (event: SSEMessageEvent) => void): void;
215
+ /** Remove event listener */
216
+ removeEventListener(type: string, listener: (event: SSEMessageEvent) => void): void;
217
+ /** Close the connection */
218
+ close(): void;
219
+ }
220
+
221
+ declare function restSSE(name: string, method?: string, params?: Record<string, any>, context?: Record<string, any>): SSESource;
36
222
 
37
223
  // Upload module types
38
224
 
@@ -146,7 +332,17 @@ export {
146
332
  uploadManyFiles,
147
333
  getI18N,
148
334
  trimPrefix,
335
+ RestPaging,
336
+ RestResponse,
337
+ RestError,
338
+ DateTime,
339
+ Xint,
340
+ PriceValue,
341
+ Price,
149
342
  UploadFileInput,
150
343
  UploadFileOptions,
151
- UploadManyFilesOptions
344
+ UploadManyFilesOptions,
345
+ SSEMessageEvent,
346
+ SSEErrorEvent,
347
+ SSESource
152
348
  };
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.24",
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