spyne 0.23.0 → 0.24.0

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.
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Spyne 0.23.0
2
+ * Spyne 0.24.0
3
3
  * https://spynejs.org
4
4
  *
5
5
  * @license
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  /*!
18
- * spynejs 0.23.0
18
+ * spynejs 0.24.0
19
19
  * https://spynejs.org
20
20
  * (c) 2017-present Frank Batista
21
21
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spyne",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Reactive Real-DOM Framework for Advanced Javascript applications",
5
5
  "sideEffects": true,
6
6
  "main": "./lib/spyne.esm.js",
@@ -17,11 +17,13 @@ export class ChannelFetch extends Channel {
17
17
  * <li>Requires a url property at instantiation.</li>
18
18
  * <li>The response data is published as a ChannelPayload.</li>
19
19
  * <li>The action for the returned ChannelFetch data is always in the following format, "CHANNEL_NAME_RESPONSE_EVENT"</li>
20
+ * <li>Fetch failures (network errors, non-OK HTTP statuses, unparseable responses) are published as a conformed error ChannelPayload with the action format, "CHANNEL_NAME_ERROR_EVENT"</li>
21
+ * <li>The error payload contains filterable properties such as errorType, status, statusText, url and message.</li>
20
22
  * <li>The default request type is a GET request that returns a JSON object.</li>
21
23
  * <li>However, any type of request and return type can be configured by using the body property when creating the instance.</li>
22
- * <li>The mapFn gives access to the returned response and can be use to parse the data before it is published to other Channels and ViewStream instances.</li>
24
+ * <li>The mapFn gives access to the returned response and can be use to parse the data before it is published to other Channels and ViewStream instances. The mapFn is not called for error payloads.</li>
23
25
  * <li>Channel Fetch will send the last response to future subscribers, and will not make further http(s) requests unless directed to do so.</li>
24
- * <li>Data can be Fetched again, by sending a "CHANNEL_NAME_UPDATE_RESPONSE_EVENT" action from a ViewStream's sendInfoToChannel method.</li>
26
+ * <li>Data can be Fetched again, by sending a "CHANNEL_NAME_REQUEST_EVENT" action from a ViewStream's sendInfoToChannel method.</li>
25
27
  * </ul>
26
28
  *
27
29
  * @constructor
@@ -35,7 +37,7 @@ export class ChannelFetch extends Channel {
35
37
  *
36
38
  * @example
37
39
  * TITLE["<h4>A ViewStream Instance Sending A Fetch Update Request</h4>"]
38
- * const action = "CHANNEL_FETCHCHANNEL_NAME_UPDATE_RESPONSE_EVENT";
40
+ * const action = "CHANNEL_FETCHCHANNEL_NAME_REQUEST_EVENT";
39
41
  * const url = "//site.com/json/";
40
42
  * const body = {
41
43
  * method: "POST"
@@ -56,6 +58,7 @@ export class ChannelFetch extends Channel {
56
58
  }
57
59
  props.extendedActionsArr = [
58
60
  `${name}_RESPONSE_EVENT`,
61
+ `${name}_ERROR_EVENT`,
59
62
  [`${name}_REQUEST_EVENT`, 'onFetchUpdate']
60
63
  ]
61
64
  props.sendCachedPayload = true
@@ -89,6 +92,7 @@ export class ChannelFetch extends Channel {
89
92
  addRegisteredActions(name) {
90
93
  const arr = [
91
94
  'CHANNEL_RESPONSE_EVENT',
95
+ 'CHANNEL_ERROR_EVENT',
92
96
  ['CHANNEL_UPDATE_RESPONSE_EVENT', 'onFetchUpdate'],
93
97
  ['CHANNEL_FETCH_REQUEST_EVENT', 'onFetchUpdate']
94
98
  ]
@@ -108,7 +112,12 @@ export class ChannelFetch extends Channel {
108
112
  }
109
113
 
110
114
  onFetchReturned(streamItem) {
111
- return this.createChannelPayloadItem(streamItem)
115
+ const isFetchError = streamItem?.isChannelFetchError === true
116
+ const action = isFetchError
117
+ ? `${this.props.name}_ERROR_EVENT`
118
+ : `${this.props.name}_RESPONSE_EVENT`
119
+
120
+ return this.createChannelPayloadItem(streamItem, action)
112
121
  }
113
122
 
114
123
  createChannelPayloadItem(payload, action = `${this.props.name}_RESPONSE_EVENT`) {
@@ -118,7 +127,7 @@ export class ChannelFetch extends Channel {
118
127
  }
119
128
 
120
129
  getPropsForFetch(evt) {
121
- const dataObj = path(['viewStreamInfo', 'payload'], evt)
130
+ const dataObj = evt?.payload ?? evt?.viewStreamInfo?.payload ?? {}
122
131
  return pick(['mapFn', 'url', 'header', 'body', 'mode', 'method', 'responseType', 'debug'], dataObj)
123
132
  }
124
133
 
@@ -6,7 +6,7 @@ import { sanitizeHTMLConfigure } from './utils/sanitize-html.js'
6
6
  import { sanitizeDataConfigure } from './utils/sanitize-data.js'
7
7
 
8
8
  const _channels = new ChannelsMap()
9
- const version = '0.23.0'
9
+ const version = '0.24.0'
10
10
 
11
11
  class SpyneApplication {
12
12
  /**
@@ -41,7 +41,7 @@ class SpyneApplication {
41
41
  init(config = {}, testMode = false) {
42
42
  // this.channels = new ChannelsMap();
43
43
  /*!
44
- * Spyne 0.23.0
44
+ * Spyne 0.24.0
45
45
  * https://spynejs.org
46
46
  *
47
47
  * @license
@@ -74,6 +74,9 @@ class SpyneApplication {
74
74
  scrollLockY: 0,
75
75
  debug: false,
76
76
  strict: false,
77
+ mode: 'app',
78
+ disableSanitize: false,
79
+ iframes: {},
77
80
  baseHref: undefined,
78
81
  IMG_PATH: imgPath,
79
82
  pluginMethods:{
@@ -137,7 +140,7 @@ class SpyneApplication {
137
140
  }
138
141
 
139
142
  sanitizeHTMLConfigure(SpyneAppProperties.config);
140
- sanitizeDataConfigure(SpyneAppProperties.config)
143
+ sanitizeDataConfigure(SpyneAppProperties.config);
141
144
 
142
145
  }
143
146
 
@@ -1,65 +1,43 @@
1
- import { from } from 'rxjs'
2
- import { flatMap, map, publish, tap } from 'rxjs/operators'
3
- import { compose, prop, defaultTo, over, lensProp, has, propEq, when, propIs, allPass, assoc, pick, mergeDeepRight } from 'ramda'
4
- import sanitizeData, { setSanitizeDataForceStrict } from './sanitize-data.js'
1
+ import { from, of } from 'rxjs'
2
+ import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
3
+ import { compose, prop, defaultTo, pick, mergeDeepRight } from 'ramda'
4
+ import sanitizeData from './sanitize-data.js'
5
5
 
6
6
  export class ChannelFetchUtil {
7
7
  /**
8
8
  * @module ChannelFetchUtil
9
9
  * @type util
10
10
  *
11
- *
12
11
  * @desc
13
- * This is the core object used for ChannelFetch. This utility wraps the javascript fetch api into an observable.
12
+ * This is the core object used for ChannelFetch. This utility wraps the JavaScript
13
+ * fetch API into an observable.
14
14
  *
15
15
  * @constructor
16
16
  * @param {Object} options Properties used to create the fetch request
17
- * @param {function} subscriber A method assigned to listen to the result
18
- * @param {Boolean} testMode Controls the initializtion for unit tests
19
- *
20
- * @property {Object} options.url - = undefined; The url used for the request
21
- * @property {Object} options.serverOptions - = undefined; The properties, header, body, mode, method, for the request
22
- * @property {Object} options.mapFn - = undefined; A method that can be used to parse the data before it's returned
23
- * @property {Object} options.responseType - = 'json'; Default is json
24
- * @property {Object} options.debug - = false; will trace the fetch response to the console befor the observable completes
25
- * @property {Function} Subscriber - = undefined; the method that will be called when the fetch is complete.
26
- * @property {Boolean} testMode - = false; Used for unit testing.
27
- *
28
- * @returns The fetched response parsed by the set parameters.
29
- *
30
- *
31
- * @example
32
- * TITLE['<h4>Using ChannelFetchUtil to Retrieve an Image</h4>']
33
- *
34
- * const url = "/static/images/myimage.jpg";
35
- * const responseType = "blob";
36
- * const onImgBlobReturned = (blob)=>{
37
- * let blobUrl = URL.createObjectURL(blob);
38
- * this.appendView(
39
- * new ViewStream({
40
- * tagName: 'img',
41
- * src: blobUrl,
42
- * width:300
43
- * })
44
- * )}
45
- *
46
- * new ChannelFetchUtil({url, responseType}, onImgBlobReturned);
17
+ * @param {Function} subscriber A method assigned to listen to the result
18
+ * @param {Boolean} testMode Controls initialization for unit tests
19
+ * @param {String} CHANNEL_NAME The name of the channel using this fetch util
47
20
  *
21
+ * @property {String} options.url - The URL used for the request
22
+ * @property {Object} options.serverOptions - The properties for the request
23
+ * @property {Function} options.mapFn - A method that can parse the data before it is returned
24
+ * @property {String} options.responseType - Default is json
25
+ * @property {Boolean} options.debug - Logs fetch response details before observable completes
26
+ * @property {Boolean} options.disableSanitize - Allows trusted fetch data to skip sanitization
48
27
  *
28
+ * @returns The fetched response parsed by the configured parameters.
49
29
  */
50
30
 
51
- constructor(options, subscriber, testMode, CHANNEL_NAME) {
52
- setSanitizeDataForceStrict(true)
53
-
54
- const testSubscriber = (p) => console.log('FETCH RETURNED ', p)
31
+ constructor(options = {}, subscriber, testMode, CHANNEL_NAME) {
32
+ const testSubscriber = p => console.log('FETCH RETURNED ', p)
55
33
 
56
34
  this._mapFn = ChannelFetchUtil.setMapFn(options)
57
35
  this._url = ChannelFetchUtil.setUrl(options)
58
36
  this._responseType = ChannelFetchUtil.setResponseType(options)
59
37
  this._serverOptions = ChannelFetchUtil.setServerOptions(options)
60
38
  this._subscriber = subscriber !== undefined ? subscriber : testSubscriber
61
- this.debug = options.debug !== undefined ? options.debug : false
62
- this.disableSanitize = options?.disableSanitize
39
+ this.debug = options?.debug === true
40
+ this.disableSanitize = options?.disableSanitize === true
63
41
  this.channelName = CHANNEL_NAME
64
42
 
65
43
  const fetchProps = {
@@ -70,6 +48,7 @@ export class ChannelFetchUtil {
70
48
  disableSanitize: this.disableSanitize,
71
49
  debug: this.debug
72
50
  }
51
+
73
52
  if (testMode !== true) {
74
53
  ChannelFetchUtil.startWindowFetch(fetchProps, this._subscriber, this.channelName)
75
54
  }
@@ -77,60 +56,189 @@ export class ChannelFetchUtil {
77
56
 
78
57
  static startWindowFetch(props, subscriber, channelName) {
79
58
  const { mapFn, url, serverOptions, responseType, debug } = props
80
- const tapLogDebug = p => console.log('DEBUG FETCH :', p, { url, serverOptions, responseType, channelName })
81
- const tapLog = debug === true ? tapLogDebug : () => {}
82
-
83
- const mapWrapper = (mapMethod) => {
84
- const metadata = { channelName, url, responseType, serverOptions }
85
-
86
- return (data) => {
87
- const disableSanitize = props?.disableSanitize === true
88
- /*
89
- if (disableSanitize) {
90
- const env = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV)
91
- ? process.env.NODE_ENV
92
- : 'development'
93
-
94
- const msg = `SPYNE SECURITY WARNING — Sanitization disabled for fetch from ${url}. Data will enter the reactive stream unsanitized. Only use disableSanitize=true for trusted or controlled sources.`
95
-
96
- if (env === 'production') {
97
- throw new Error(`SpyneJS security policy violation: disableSanitize=true is not allowed in production (${url}).`)
98
- } else {
99
- console.warn(
100
- `%c${msg}`,
101
- 'color:#f33;font-weight:bold;'
102
- )
103
- }
104
- }
105
- */
106
-
107
- const sanitizedData = disableSanitize ? data : sanitizeData(data)
108
- return mapMethod(sanitizedData, metadata)
109
- }
59
+
60
+ const metadata = {
61
+ channelName,
62
+ url,
63
+ responseType,
64
+ serverOptions
110
65
  }
111
66
 
112
- // console.log('SERVER OPTIONS ', { props, serverOptions })
67
+ const tapLogDebug = p => {
68
+ console.log('DEBUG FETCH :', p, metadata)
69
+ }
70
+
71
+ const tapLog = debug === true ? tap(tapLogDebug) : tap(() => {})
72
+
73
+ const mapWrapper = mapMethod => data => {
74
+ const disableSanitize = props?.disableSanitize === true
75
+
76
+ // Fetched data is remote and untrusted: always sanitize in 'app' mode,
77
+ // regardless of the application's authoring/richtext posture.
78
+ const sanitizedData = disableSanitize ? data : sanitizeData(data, { mode: 'app' })
79
+
80
+ return mapMethod(sanitizedData, metadata)
81
+ }
82
+
83
+ const response$ = from(window.fetch(url, serverOptions)).pipe(
84
+ mergeMap(response =>
85
+ from(ChannelFetchUtil.parseResponse(response, metadata))
86
+ ),
87
+
88
+ tapLog,
113
89
 
114
- const response$ = from(window.fetch(url, serverOptions))
115
- .pipe(tap(tapLog), flatMap(r => from(r[responseType]())),
116
- map(mapWrapper(mapFn)),
117
- publish())
90
+ map(mapWrapper(mapFn)),
118
91
 
119
- response$.connect()
92
+ catchError(error =>
93
+ of(ChannelFetchUtil.createFetchErrorPayload(error, metadata))
94
+ ),
95
+
96
+ share()
97
+ )
120
98
 
121
99
  response$.subscribe(subscriber)
122
100
  }
123
101
 
102
+ static async parseResponse(response, metadata) {
103
+ const { responseType } = metadata
104
+
105
+ const status = response.status
106
+ const statusText = response.statusText
107
+ const contentType = response.headers?.get?.('content-type') || ''
108
+
109
+ const responseMetadata = {
110
+ ...metadata,
111
+ status,
112
+ statusText,
113
+ contentType,
114
+ ok: response.ok
115
+ }
116
+
117
+ if (response.ok !== true) {
118
+ const rawBody = await ChannelFetchUtil.safeReadText(response)
119
+
120
+ throw ChannelFetchUtil.createFetchError({
121
+ errorType: 'FETCH_HTTP_ERROR',
122
+ message: `Fetch request failed with status ${status} ${statusText}`,
123
+ metadata: responseMetadata,
124
+ rawBody
125
+ })
126
+ }
127
+
128
+ if (responseType === 'json') {
129
+ return ChannelFetchUtil.parseJsonResponse(response, responseMetadata)
130
+ }
131
+
132
+ if (responseType === 'text') {
133
+ return response.text()
134
+ }
135
+
136
+ if (responseType === 'blob') {
137
+ return response.blob()
138
+ }
139
+
140
+ if (responseType === 'arrayBuffer') {
141
+ return response.arrayBuffer()
142
+ }
143
+
144
+ if (responseType === 'formData') {
145
+ return response.formData()
146
+ }
147
+
148
+ throw ChannelFetchUtil.createFetchError({
149
+ errorType: 'FETCH_UNSUPPORTED_RESPONSE_TYPE',
150
+ message: `Unsupported fetch responseType "${responseType}"`,
151
+ metadata: responseMetadata
152
+ })
153
+ }
154
+
155
+ static async parseJsonResponse(response, metadata) {
156
+ const rawBody = await response.text()
157
+ const trimmedBody = rawBody.trim()
158
+
159
+ if (trimmedBody === '') {
160
+ return null
161
+ }
162
+
163
+ try {
164
+ return JSON.parse(trimmedBody)
165
+ } catch (error) {
166
+ throw ChannelFetchUtil.createFetchError({
167
+ errorType: 'FETCH_RESPONSE_PARSE_ERROR',
168
+ message: 'Expected JSON but could not parse response body.',
169
+ metadata,
170
+ rawBody,
171
+ originalError: error
172
+ })
173
+ }
174
+ }
175
+
176
+ static createFetchError({ errorType, message, metadata, rawBody = '', originalError }) {
177
+ const error = new Error(message)
178
+
179
+ error.isChannelFetchError = true
180
+ error.errorType = errorType
181
+ error.metadata = metadata
182
+ error.rawBody = rawBody
183
+ error.originalError = originalError
184
+
185
+ return error
186
+ }
187
+
188
+ static createFetchErrorPayload(error, metadata) {
189
+ const fetchMetadata = error?.metadata || metadata || {}
190
+
191
+ return {
192
+ isChannelFetchError: true,
193
+ isError: true,
194
+ error: true,
195
+ errorType: error?.errorType || 'FETCH_UNKNOWN_ERROR',
196
+ message: error?.message || 'Unknown ChannelFetchUtil error',
197
+
198
+ channelName: fetchMetadata.channelName,
199
+ url: fetchMetadata.url,
200
+ responseType: fetchMetadata.responseType,
201
+ status: fetchMetadata.status,
202
+ statusText: fetchMetadata.statusText,
203
+ contentType: fetchMetadata.contentType,
204
+ ok: fetchMetadata.ok,
205
+
206
+ rawBodyPreview: ChannelFetchUtil.truncateBody(error?.rawBody),
207
+
208
+ originalErrorMessage: error?.originalError?.message
209
+ }
210
+ }
211
+
212
+ static truncateBody(body = '', maxLength = 500) {
213
+ if (typeof body !== 'string') {
214
+ return ''
215
+ }
216
+
217
+ return body.length > maxLength
218
+ ? `${body.slice(0, maxLength)}...`
219
+ : body
220
+ }
221
+
222
+ static async safeReadText(response) {
223
+ try {
224
+ return await response.text()
225
+ } catch (error) {
226
+ return ''
227
+ }
228
+ }
229
+
124
230
  static setMapFn(opts) {
125
- const getFn = compose(defaultTo((p) => p), prop('mapFn'))
231
+ const getFn = compose(defaultTo(p => p), prop('mapFn'))
126
232
  return getFn(opts)
127
233
  }
128
234
 
129
235
  static setUrl(opts) {
130
236
  const url = prop('url', opts)
237
+
131
238
  if (url === undefined) {
132
239
  console.warn('SPYNE WARNING: URL is undefined for data channel')
133
240
  }
241
+
134
242
  return url
135
243
  }
136
244
 
@@ -155,16 +263,47 @@ export class ChannelFetchUtil {
155
263
  }
156
264
 
157
265
  static stringifyBodyIfItExists(obj) {
158
- const convertToJSON = when(propIs(Object, 'body'), compose(over(lensProp('body'), JSON.stringify)))
266
+ const body = obj?.body
267
+
268
+ const shouldStringifyBody =
269
+ body !== null &&
270
+ typeof body === 'object' &&
271
+ body.constructor === Object
272
+
273
+ if (shouldStringifyBody !== true) {
274
+ return obj
275
+ }
159
276
 
160
- return convertToJSON(obj)
277
+ const headers = new Headers(obj.headers || {})
278
+
279
+ if (headers.has('Content-Type') !== true) {
280
+ headers.set('Content-Type', 'application/json')
281
+ }
282
+
283
+ return {
284
+ ...obj,
285
+ headers,
286
+ body: JSON.stringify(body)
287
+ }
161
288
  }
162
289
 
163
290
  static updateMethodWhenBodyExists(opts) {
164
- const hasBody = has('body')
165
- const methodIsGet = propEq('GET', 'method')
166
- const pred = allPass([hasBody, methodIsGet])
167
- return when(pred, assoc('method', 'POST'))(opts)
291
+ const hasBody = opts?.body !== undefined
292
+ const method = opts?.method || 'GET'
293
+
294
+ if (hasBody && method.toUpperCase() === 'GET') {
295
+ console.warn(
296
+ 'SPYNE WARNING: Fetch body was provided with method GET. Changing method to POST.',
297
+ opts
298
+ )
299
+
300
+ return {
301
+ ...opts,
302
+ method: 'POST'
303
+ }
304
+ }
305
+
306
+ return opts
168
307
  }
169
308
 
170
309
  static setServerOptions(opts) {
@@ -181,26 +320,30 @@ export class ChannelFetchUtil {
181
320
  'integrity',
182
321
  'keepalive'
183
322
  ], opts)
323
+
184
324
  let mergedOptions = mergeDeepRight(ChannelFetchUtil.baseOptions(), options)
325
+
185
326
  mergedOptions = ChannelFetchUtil.updateMethodWhenBodyExists(mergedOptions)
186
327
  mergedOptions = ChannelFetchUtil.stringifyBodyIfItExists(mergedOptions)
328
+
187
329
  return mergedOptions
188
330
  }
189
331
 
190
332
  static baseOptions() {
191
333
  return {
192
- method: 'GET', // Default method
334
+ method: 'GET',
335
+
193
336
  headers: new Headers({
194
337
  Accept: 'application/json, text/plain, */*'
195
338
  }),
196
- mode: 'cors', // Default mode
197
- credentials: 'same-origin', // Default credentials policy
198
- cache: 'default', // Default cache policy
199
- redirect: 'follow', // Default redirect policy
200
- referrer: 'client', // Default referrer policy
201
- referrerPolicy: 'no-referrer-when-downgrade', // Default referrer policy
202
- integrity: '', // Default integrity
203
- keepalive: false // Default keepalive
339
+
340
+ mode: 'cors',
341
+ credentials: 'same-origin',
342
+ cache: 'default',
343
+ redirect: 'follow',
344
+ referrerPolicy: 'no-referrer-when-downgrade',
345
+ integrity: '',
346
+ keepalive: false
204
347
  }
205
348
  }
206
349
  }