spyne 0.23.0 → 0.25.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.25.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.25.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.25.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.25.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.25.0
45
45
  * https://spynejs.org
46
46
  *
47
47
  * @license
@@ -74,6 +74,10 @@ class SpyneApplication {
74
74
  scrollLockY: 0,
75
75
  debug: false,
76
76
  strict: false,
77
+ mode: 'app',
78
+ disableSanitize: false,
79
+ iframes: {},
80
+ customElements: [],
77
81
  baseHref: undefined,
78
82
  IMG_PATH: imgPath,
79
83
  pluginMethods:{
@@ -137,7 +141,7 @@ class SpyneApplication {
137
141
  }
138
142
 
139
143
  sanitizeHTMLConfigure(SpyneAppProperties.config);
140
- sanitizeDataConfigure(SpyneAppProperties.config)
144
+ sanitizeDataConfigure(SpyneAppProperties.config);
141
145
 
142
146
  }
143
147
 
@@ -17,7 +17,8 @@ import { deepMerge } from './utils/deep-merge.js'
17
17
  import { safeClone } from './utils/safe-clone.js'
18
18
  import { SpyneAppProperties } from './utils/spyne-app-properties.js'
19
19
  import { SpyneApp } from './spyne-app.js'
20
- import sanitizeData, { sanitizeEventTarget } from './utils/sanitize-data.js'
20
+ import { setSpyneWarningsDisabled } from './utils/spyne-warn.js'
21
+ import sanitizeData, { sanitizeEventTarget, allowCustomElements } from './utils/sanitize-data.js'
21
22
 
22
23
  export {
23
24
  ViewStreamElement,
@@ -40,5 +41,7 @@ export {
40
41
  deepMerge,
41
42
  safeClone,
42
43
  sanitizeData,
43
- sanitizeEventTarget
44
+ sanitizeEventTarget,
45
+ allowCustomElements,
46
+ setSpyneWarningsDisabled
44
47
  }
@@ -1,65 +1,44 @@
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
+ import { spyneWarn } from './spyne-warn.js'
5
6
 
6
7
  export class ChannelFetchUtil {
7
8
  /**
8
9
  * @module ChannelFetchUtil
9
10
  * @type util
10
11
  *
11
- *
12
12
  * @desc
13
- * This is the core object used for ChannelFetch. This utility wraps the javascript fetch api into an observable.
13
+ * This is the core object used for ChannelFetch. This utility wraps the JavaScript
14
+ * fetch API into an observable.
14
15
  *
15
16
  * @constructor
16
17
  * @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);
18
+ * @param {Function} subscriber A method assigned to listen to the result
19
+ * @param {Boolean} testMode Controls initialization for unit tests
20
+ * @param {String} CHANNEL_NAME The name of the channel using this fetch util
47
21
  *
22
+ * @property {String} options.url - The URL used for the request
23
+ * @property {Object} options.serverOptions - The properties for the request
24
+ * @property {Function} options.mapFn - A method that can parse the data before it is returned
25
+ * @property {String} options.responseType - Default is json
26
+ * @property {Boolean} options.debug - Logs fetch response details before observable completes
27
+ * @property {Boolean} options.disableSanitize - Allows trusted fetch data to skip sanitization
48
28
  *
29
+ * @returns The fetched response parsed by the configured parameters.
49
30
  */
50
31
 
51
- constructor(options, subscriber, testMode, CHANNEL_NAME) {
52
- setSanitizeDataForceStrict(true)
53
-
54
- const testSubscriber = (p) => console.log('FETCH RETURNED ', p)
32
+ constructor(options = {}, subscriber, testMode, CHANNEL_NAME) {
33
+ const testSubscriber = p => console.log('FETCH RETURNED ', p)
55
34
 
56
35
  this._mapFn = ChannelFetchUtil.setMapFn(options)
57
36
  this._url = ChannelFetchUtil.setUrl(options)
58
37
  this._responseType = ChannelFetchUtil.setResponseType(options)
59
38
  this._serverOptions = ChannelFetchUtil.setServerOptions(options)
60
39
  this._subscriber = subscriber !== undefined ? subscriber : testSubscriber
61
- this.debug = options.debug !== undefined ? options.debug : false
62
- this.disableSanitize = options?.disableSanitize
40
+ this.debug = options?.debug === true
41
+ this.disableSanitize = options?.disableSanitize === true
63
42
  this.channelName = CHANNEL_NAME
64
43
 
65
44
  const fetchProps = {
@@ -70,6 +49,7 @@ export class ChannelFetchUtil {
70
49
  disableSanitize: this.disableSanitize,
71
50
  debug: this.debug
72
51
  }
52
+
73
53
  if (testMode !== true) {
74
54
  ChannelFetchUtil.startWindowFetch(fetchProps, this._subscriber, this.channelName)
75
55
  }
@@ -77,60 +57,189 @@ export class ChannelFetchUtil {
77
57
 
78
58
  static startWindowFetch(props, subscriber, channelName) {
79
59
  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
- }
60
+
61
+ const metadata = {
62
+ channelName,
63
+ url,
64
+ responseType,
65
+ serverOptions
110
66
  }
111
67
 
112
- // console.log('SERVER OPTIONS ', { props, serverOptions })
68
+ const tapLogDebug = p => {
69
+ console.log('DEBUG FETCH :', p, metadata)
70
+ }
71
+
72
+ const tapLog = debug === true ? tap(tapLogDebug) : tap(() => {})
73
+
74
+ const mapWrapper = mapMethod => data => {
75
+ const disableSanitize = props?.disableSanitize === true
76
+
77
+ // Fetched data is remote and untrusted: always sanitize in 'app' mode,
78
+ // regardless of the application's authoring/richtext posture.
79
+ const sanitizedData = disableSanitize ? data : sanitizeData(data, { mode: 'app' })
80
+
81
+ return mapMethod(sanitizedData, metadata)
82
+ }
83
+
84
+ const response$ = from(window.fetch(url, serverOptions)).pipe(
85
+ mergeMap(response =>
86
+ from(ChannelFetchUtil.parseResponse(response, metadata))
87
+ ),
88
+
89
+ tapLog,
113
90
 
114
- const response$ = from(window.fetch(url, serverOptions))
115
- .pipe(tap(tapLog), flatMap(r => from(r[responseType]())),
116
- map(mapWrapper(mapFn)),
117
- publish())
91
+ map(mapWrapper(mapFn)),
118
92
 
119
- response$.connect()
93
+ catchError(error =>
94
+ of(ChannelFetchUtil.createFetchErrorPayload(error, metadata))
95
+ ),
96
+
97
+ share()
98
+ )
120
99
 
121
100
  response$.subscribe(subscriber)
122
101
  }
123
102
 
103
+ static async parseResponse(response, metadata) {
104
+ const { responseType } = metadata
105
+
106
+ const status = response.status
107
+ const statusText = response.statusText
108
+ const contentType = response.headers?.get?.('content-type') || ''
109
+
110
+ const responseMetadata = {
111
+ ...metadata,
112
+ status,
113
+ statusText,
114
+ contentType,
115
+ ok: response.ok
116
+ }
117
+
118
+ if (response.ok !== true) {
119
+ const rawBody = await ChannelFetchUtil.safeReadText(response)
120
+
121
+ throw ChannelFetchUtil.createFetchError({
122
+ errorType: 'FETCH_HTTP_ERROR',
123
+ message: `Fetch request failed with status ${status} ${statusText}`,
124
+ metadata: responseMetadata,
125
+ rawBody
126
+ })
127
+ }
128
+
129
+ if (responseType === 'json') {
130
+ return ChannelFetchUtil.parseJsonResponse(response, responseMetadata)
131
+ }
132
+
133
+ if (responseType === 'text') {
134
+ return response.text()
135
+ }
136
+
137
+ if (responseType === 'blob') {
138
+ return response.blob()
139
+ }
140
+
141
+ if (responseType === 'arrayBuffer') {
142
+ return response.arrayBuffer()
143
+ }
144
+
145
+ if (responseType === 'formData') {
146
+ return response.formData()
147
+ }
148
+
149
+ throw ChannelFetchUtil.createFetchError({
150
+ errorType: 'FETCH_UNSUPPORTED_RESPONSE_TYPE',
151
+ message: `Unsupported fetch responseType "${responseType}"`,
152
+ metadata: responseMetadata
153
+ })
154
+ }
155
+
156
+ static async parseJsonResponse(response, metadata) {
157
+ const rawBody = await response.text()
158
+ const trimmedBody = rawBody.trim()
159
+
160
+ if (trimmedBody === '') {
161
+ return null
162
+ }
163
+
164
+ try {
165
+ return JSON.parse(trimmedBody)
166
+ } catch (error) {
167
+ throw ChannelFetchUtil.createFetchError({
168
+ errorType: 'FETCH_RESPONSE_PARSE_ERROR',
169
+ message: 'Expected JSON but could not parse response body.',
170
+ metadata,
171
+ rawBody,
172
+ originalError: error
173
+ })
174
+ }
175
+ }
176
+
177
+ static createFetchError({ errorType, message, metadata, rawBody = '', originalError }) {
178
+ const error = new Error(message)
179
+
180
+ error.isChannelFetchError = true
181
+ error.errorType = errorType
182
+ error.metadata = metadata
183
+ error.rawBody = rawBody
184
+ error.originalError = originalError
185
+
186
+ return error
187
+ }
188
+
189
+ static createFetchErrorPayload(error, metadata) {
190
+ const fetchMetadata = error?.metadata || metadata || {}
191
+
192
+ return {
193
+ isChannelFetchError: true,
194
+ isError: true,
195
+ error: true,
196
+ errorType: error?.errorType || 'FETCH_UNKNOWN_ERROR',
197
+ message: error?.message || 'Unknown ChannelFetchUtil error',
198
+
199
+ channelName: fetchMetadata.channelName,
200
+ url: fetchMetadata.url,
201
+ responseType: fetchMetadata.responseType,
202
+ status: fetchMetadata.status,
203
+ statusText: fetchMetadata.statusText,
204
+ contentType: fetchMetadata.contentType,
205
+ ok: fetchMetadata.ok,
206
+
207
+ rawBodyPreview: ChannelFetchUtil.truncateBody(error?.rawBody),
208
+
209
+ originalErrorMessage: error?.originalError?.message
210
+ }
211
+ }
212
+
213
+ static truncateBody(body = '', maxLength = 500) {
214
+ if (typeof body !== 'string') {
215
+ return ''
216
+ }
217
+
218
+ return body.length > maxLength
219
+ ? `${body.slice(0, maxLength)}...`
220
+ : body
221
+ }
222
+
223
+ static async safeReadText(response) {
224
+ try {
225
+ return await response.text()
226
+ } catch (error) {
227
+ return ''
228
+ }
229
+ }
230
+
124
231
  static setMapFn(opts) {
125
- const getFn = compose(defaultTo((p) => p), prop('mapFn'))
232
+ const getFn = compose(defaultTo(p => p), prop('mapFn'))
126
233
  return getFn(opts)
127
234
  }
128
235
 
129
236
  static setUrl(opts) {
130
237
  const url = prop('url', opts)
238
+
131
239
  if (url === undefined) {
132
- console.warn('SPYNE WARNING: URL is undefined for data channel')
240
+ spyneWarn('SPYNE WARNING: URL is undefined for data channel')
133
241
  }
242
+
134
243
  return url
135
244
  }
136
245
 
@@ -155,16 +264,47 @@ export class ChannelFetchUtil {
155
264
  }
156
265
 
157
266
  static stringifyBodyIfItExists(obj) {
158
- const convertToJSON = when(propIs(Object, 'body'), compose(over(lensProp('body'), JSON.stringify)))
267
+ const body = obj?.body
268
+
269
+ const shouldStringifyBody =
270
+ body !== null &&
271
+ typeof body === 'object' &&
272
+ body.constructor === Object
273
+
274
+ if (shouldStringifyBody !== true) {
275
+ return obj
276
+ }
159
277
 
160
- return convertToJSON(obj)
278
+ const headers = new Headers(obj.headers || {})
279
+
280
+ if (headers.has('Content-Type') !== true) {
281
+ headers.set('Content-Type', 'application/json')
282
+ }
283
+
284
+ return {
285
+ ...obj,
286
+ headers,
287
+ body: JSON.stringify(body)
288
+ }
161
289
  }
162
290
 
163
291
  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)
292
+ const hasBody = opts?.body !== undefined
293
+ const method = opts?.method || 'GET'
294
+
295
+ if (hasBody && method.toUpperCase() === 'GET') {
296
+ spyneWarn(
297
+ 'SPYNE WARNING: Fetch body was provided with method GET. Changing method to POST.',
298
+ opts
299
+ )
300
+
301
+ return {
302
+ ...opts,
303
+ method: 'POST'
304
+ }
305
+ }
306
+
307
+ return opts
168
308
  }
169
309
 
170
310
  static setServerOptions(opts) {
@@ -181,26 +321,30 @@ export class ChannelFetchUtil {
181
321
  'integrity',
182
322
  'keepalive'
183
323
  ], opts)
324
+
184
325
  let mergedOptions = mergeDeepRight(ChannelFetchUtil.baseOptions(), options)
326
+
185
327
  mergedOptions = ChannelFetchUtil.updateMethodWhenBodyExists(mergedOptions)
186
328
  mergedOptions = ChannelFetchUtil.stringifyBodyIfItExists(mergedOptions)
329
+
187
330
  return mergedOptions
188
331
  }
189
332
 
190
333
  static baseOptions() {
191
334
  return {
192
- method: 'GET', // Default method
335
+ method: 'GET',
336
+
193
337
  headers: new Headers({
194
338
  Accept: 'application/json, text/plain, */*'
195
339
  }),
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
340
+
341
+ mode: 'cors',
342
+ credentials: 'same-origin',
343
+ cache: 'default',
344
+ redirect: 'follow',
345
+ referrerPolicy: 'no-referrer-when-downgrade',
346
+ integrity: '',
347
+ keepalive: false
204
348
  }
205
349
  }
206
350
  }