spyne 0.22.4 → 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.22.4
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.22.4
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.22.4",
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"
@@ -46,7 +48,7 @@ export class ChannelFetch extends Channel {
46
48
  *
47
49
  */
48
50
 
49
- constructor(name, props = {}) {
51
+ constructor(name, props = { disableSanitize:false }) {
50
52
  // ALLOW FOR GENERIC MAP PROPERTY
51
53
 
52
54
  ChannelFetch.validateMapMethod(props, name)
@@ -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,12 +127,12 @@ 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
 
125
134
  consolidateAllFetchProps(options, props = this.props) {
126
- const propsOptions = pick(['mapFn', 'url', 'header', 'body', 'mode', 'method', 'responseType', 'debug'], props)
135
+ const propsOptions = pick(['mapFn', 'url', 'header', 'body', 'mode', 'disableSanitize', 'method', 'responseType', 'debug'], props)
127
136
  const mergeOptions = (o1, o2) => mergeDeepRight(o1, o2)
128
137
  const filterOutUndefined = reject(isNil)
129
138
  return compose(filterOutUndefined, mergeOptions)(propsOptions, options)
@@ -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.22.3'
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.22.4
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,64 +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
39
+ this.debug = options?.debug === true
40
+ this.disableSanitize = options?.disableSanitize === true
62
41
  this.channelName = CHANNEL_NAME
63
42
 
64
43
  const fetchProps = {
@@ -66,8 +45,10 @@ export class ChannelFetchUtil {
66
45
  url: this.url,
67
46
  serverOptions: this.serverOptions,
68
47
  responseType: this.responseType,
48
+ disableSanitize: this.disableSanitize,
69
49
  debug: this.debug
70
50
  }
51
+
71
52
  if (testMode !== true) {
72
53
  ChannelFetchUtil.startWindowFetch(fetchProps, this._subscriber, this.channelName)
73
54
  }
@@ -75,59 +56,189 @@ export class ChannelFetchUtil {
75
56
 
76
57
  static startWindowFetch(props, subscriber, channelName) {
77
58
  const { mapFn, url, serverOptions, responseType, debug } = props
78
- const tapLogDebug = p => console.log('DEBUG FETCH :', p, { url, serverOptions, responseType, channelName })
79
- const tapLog = debug === true ? tapLogDebug : () => {}
80
-
81
- const mapWrapper = (mapMethod) => {
82
- const metadata = { channelName, url, responseType, serverOptions }
83
-
84
- return (data) => {
85
- const disableSanitize = props?.disableSanitize === true
86
-
87
- if (disableSanitize) {
88
- const env = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV)
89
- ? process.env.NODE_ENV
90
- : 'development'
91
-
92
- 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.`
93
-
94
- if (env === 'production') {
95
- throw new Error(`SpyneJS security policy violation: disableSanitize=true is not allowed in production (${url}).`)
96
- } else {
97
- console.warn(
98
- `%c${msg}`,
99
- 'color:#f33;font-weight:bold;'
100
- )
101
- }
102
- }
103
-
104
- const sanitizedData = disableSanitize ? data : sanitizeData(data)
105
- return mapMethod(sanitizedData, metadata)
106
- }
59
+
60
+ const metadata = {
61
+ channelName,
62
+ url,
63
+ responseType,
64
+ serverOptions
107
65
  }
108
66
 
109
- // 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,
110
89
 
111
- const response$ = from(window.fetch(url, serverOptions))
112
- .pipe(tap(tapLog), flatMap(r => from(r[responseType]())),
113
- map(mapWrapper(mapFn)),
114
- publish())
90
+ map(mapWrapper(mapFn)),
115
91
 
116
- response$.connect()
92
+ catchError(error =>
93
+ of(ChannelFetchUtil.createFetchErrorPayload(error, metadata))
94
+ ),
95
+
96
+ share()
97
+ )
117
98
 
118
99
  response$.subscribe(subscriber)
119
100
  }
120
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
+
121
230
  static setMapFn(opts) {
122
- const getFn = compose(defaultTo((p) => p), prop('mapFn'))
231
+ const getFn = compose(defaultTo(p => p), prop('mapFn'))
123
232
  return getFn(opts)
124
233
  }
125
234
 
126
235
  static setUrl(opts) {
127
236
  const url = prop('url', opts)
237
+
128
238
  if (url === undefined) {
129
239
  console.warn('SPYNE WARNING: URL is undefined for data channel')
130
240
  }
241
+
131
242
  return url
132
243
  }
133
244
 
@@ -152,16 +263,47 @@ export class ChannelFetchUtil {
152
263
  }
153
264
 
154
265
  static stringifyBodyIfItExists(obj) {
155
- 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
+ }
156
276
 
157
- 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
+ }
158
288
  }
159
289
 
160
290
  static updateMethodWhenBodyExists(opts) {
161
- const hasBody = has('body')
162
- const methodIsGet = propEq('GET', 'method')
163
- const pred = allPass([hasBody, methodIsGet])
164
- 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
165
307
  }
166
308
 
167
309
  static setServerOptions(opts) {
@@ -178,26 +320,30 @@ export class ChannelFetchUtil {
178
320
  'integrity',
179
321
  'keepalive'
180
322
  ], opts)
323
+
181
324
  let mergedOptions = mergeDeepRight(ChannelFetchUtil.baseOptions(), options)
325
+
182
326
  mergedOptions = ChannelFetchUtil.updateMethodWhenBodyExists(mergedOptions)
183
327
  mergedOptions = ChannelFetchUtil.stringifyBodyIfItExists(mergedOptions)
328
+
184
329
  return mergedOptions
185
330
  }
186
331
 
187
332
  static baseOptions() {
188
333
  return {
189
- method: 'GET', // Default method
334
+ method: 'GET',
335
+
190
336
  headers: new Headers({
191
337
  Accept: 'application/json, text/plain, */*'
192
338
  }),
193
- mode: 'cors', // Default mode
194
- credentials: 'same-origin', // Default credentials policy
195
- cache: 'default', // Default cache policy
196
- redirect: 'follow', // Default redirect policy
197
- referrer: 'client', // Default referrer policy
198
- referrerPolicy: 'no-referrer-when-downgrade', // Default referrer policy
199
- integrity: '', // Default integrity
200
- 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
201
347
  }
202
348
  }
203
349
  }