spyne 0.25.0 → 0.26.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.
- package/lib/spyne.esm.js +3 -3
- package/lib/spyne.esm.js.map +1 -1
- package/lib/spyne.umd.js +2 -2
- package/lib/spyne.umd.js.LICENSE.txt +2 -2
- package/package.json +1 -1
- package/src/spyne/channels/channel-payload-class.js +112 -17
- package/src/spyne/channels/channel.js +13 -0
- package/src/spyne/channels/spyne-channel-window.js +95 -3
- package/src/spyne/spyne-app.js +4 -2
- package/src/spyne/spyne-plugins.js +17 -0
- package/src/spyne/spyne.js +6 -1
- package/src/spyne/utils/channel-payload-filter.js +26 -22
- package/src/spyne/utils/safe-clone.js +128 -2
- package/src/spyne/utils/sanitize-data.js +180 -18
- package/src/spyne/utils/sanitize-html.js +9 -38
- package/src/spyne/utils/spyne-app-properties.js +30 -0
- package/src/spyne/views/dom-element-template.js +93 -0
- package/src/spyne/views/dom-element.js +2 -2
- package/src/tests/channels/channel-payload-filter.test.js +26 -0
- package/src/tests/channels/channel-window-custom-events.test.js +130 -0
- package/src/tests/utils/safe-clone.test.js +193 -0
- package/src/tests/utils/security.test.js +88 -0
package/package.json
CHANGED
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
is
|
|
13
13
|
} from 'ramda'
|
|
14
14
|
import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
|
|
15
|
-
import { safeClone } from '../utils/safe-clone.js'
|
|
15
|
+
import { safeClone, safeCloneDeep } from '../utils/safe-clone.js'
|
|
16
|
+
|
|
17
|
+
// read-only facades for cms-proxied payload subtrees, cached per proxy so
|
|
18
|
+
// repeated dispatches of the same canonical data reuse a single wrapper
|
|
19
|
+
const _readOnlyFacadeCache = new WeakMap()
|
|
16
20
|
|
|
17
21
|
export class ChannelPayload {
|
|
18
22
|
/**
|
|
@@ -45,8 +49,7 @@ export class ChannelPayload {
|
|
|
45
49
|
|
|
46
50
|
const channelPayloadItemObj = { channelName, action, srcElement, event }
|
|
47
51
|
// Object.defineProperty(channelPayloadItemObj, 'payload', {get: () => clone(payload)});
|
|
48
|
-
|
|
49
|
-
channelPayloadItemObj.payload = frozenPayload
|
|
52
|
+
channelPayloadItemObj.payload = ChannelPayload.deepFreeze(payload)
|
|
50
53
|
/**
|
|
51
54
|
* This is a convenience method that helps with destructuring by merging all properties.
|
|
52
55
|
*
|
|
@@ -71,14 +74,47 @@ export class ChannelPayload {
|
|
|
71
74
|
ChannelPayload.validateAction(action, channel, channelActionsArr)
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
channelPayloadItemObj.clone = () =>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
channelPayloadItemObj.clone = () => {
|
|
78
|
+
return mergeAll([
|
|
79
|
+
{ payload:safeClone(channelPayloadItemObj.payload) },
|
|
80
|
+
channelPayloadItemObj.payload,
|
|
81
|
+
{ channel: clone(channel) },
|
|
82
|
+
{ event: clone(event) },
|
|
83
|
+
{ srcElement },
|
|
84
|
+
clone(channelPayloadItemObj.srcElement), { action: clone(channelPayloadItemObj.action) }
|
|
85
|
+
])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Proxy-preserving version of clone. Returns the same merged shape, but
|
|
90
|
+
* the payload is copied with safeCloneDeep, so any CMS proxy objects —
|
|
91
|
+
* at the root or nested inside plain containers or arrays — are revived
|
|
92
|
+
* as writable proxy copies instead of being stripped by a structural
|
|
93
|
+
* clone. Use this when a handler treats the payload as its data source.
|
|
94
|
+
*
|
|
95
|
+
* @returns
|
|
96
|
+
* JSON Object
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* TITLE['<h4>Retrieving Proxied Payload Data</h4>']
|
|
100
|
+
* onStoryEvent(e){
|
|
101
|
+
* const data = e.safeClone();
|
|
102
|
+
* this.addStoryArticle(data);
|
|
103
|
+
* }
|
|
104
|
+
*
|
|
105
|
+
*/
|
|
106
|
+
channelPayloadItemObj.safeClone = () => {
|
|
107
|
+
const revivedPayload = safeCloneDeep(channelPayloadItemObj.payload)
|
|
108
|
+
return mergeAll([
|
|
109
|
+
{ payload: revivedPayload },
|
|
110
|
+
revivedPayload,
|
|
111
|
+
{ channel },
|
|
112
|
+
{ event },
|
|
113
|
+
{ srcElement },
|
|
114
|
+
channelPayloadItemObj.srcElement,
|
|
115
|
+
{ action: channelPayloadItemObj.action }
|
|
116
|
+
])
|
|
117
|
+
}
|
|
82
118
|
|
|
83
119
|
const channelPayloadItemObjProps = {
|
|
84
120
|
$dir: {
|
|
@@ -145,22 +181,81 @@ export class ChannelPayload {
|
|
|
145
181
|
return isIterable ? compose(fromPairs, toPairs, clone)(o) : clone(o)
|
|
146
182
|
}
|
|
147
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Wraps a CMS-proxied payload subtree in a read-only facade. Payloads are
|
|
186
|
+
* reference-by-wire (every subscriber shares the same object), and freezing
|
|
187
|
+
* a proxy would freeze the CANONICAL cms target for the whole app — so
|
|
188
|
+
* instead the payload boundary gets a lightweight second proxy that blocks
|
|
189
|
+
* writes while reads (and nested reads) pass through. Consumers that need
|
|
190
|
+
* a writable copy call e.safeClone(). Facades are cached per proxy so
|
|
191
|
+
* repeated dispatches of the same data reuse one wrapper.
|
|
192
|
+
*/
|
|
193
|
+
static createReadOnlyProxyFacade(proxyObj) {
|
|
194
|
+
if (_readOnlyFacadeCache.has(proxyObj)) {
|
|
195
|
+
return _readOnlyFacadeCache.get(proxyObj)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const warnBlockedWrite = (propName) => {
|
|
199
|
+
if (SpyneAppProperties.debug === true) {
|
|
200
|
+
console.warn(`Spyne Warning: blocked write of '${String(propName)}' on read-only channel payload data; use e.safeClone() for a writable copy`)
|
|
201
|
+
}
|
|
202
|
+
return true // silent no-op; returning false would throw in strict mode
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const facade = new Proxy(proxyObj, {
|
|
206
|
+
get(target, propName) {
|
|
207
|
+
const val = target[propName]
|
|
208
|
+
// nested cms containers are proxies themselves — wrap them so deep
|
|
209
|
+
// writes are blocked too (cached, so repeat reads reuse the facade)
|
|
210
|
+
if (val !== null && typeof val === 'object' && val.__proxy__isProxy === true) {
|
|
211
|
+
return ChannelPayload.createReadOnlyProxyFacade(val)
|
|
212
|
+
}
|
|
213
|
+
return val
|
|
214
|
+
},
|
|
215
|
+
set(target, propName) {
|
|
216
|
+
return warnBlockedWrite(propName)
|
|
217
|
+
},
|
|
218
|
+
defineProperty(target, propName) {
|
|
219
|
+
return warnBlockedWrite(propName)
|
|
220
|
+
},
|
|
221
|
+
deleteProperty(target, propName) {
|
|
222
|
+
return warnBlockedWrite(propName)
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
_readOnlyFacadeCache.set(proxyObj, facade)
|
|
227
|
+
return facade
|
|
228
|
+
}
|
|
229
|
+
|
|
148
230
|
static deepFreeze(o) {
|
|
149
231
|
// return o;
|
|
150
232
|
// return Object.freeze(o);
|
|
151
233
|
const elIsDomElement = compose(lte(0), defaultTo(-1), prop('nodeType'))
|
|
152
234
|
|
|
235
|
+
// CMS proxy subtrees are NOT frozen (that would freeze the canonical cms
|
|
236
|
+
// target app-wide) — they are wrapped in a read-only facade instead, so
|
|
237
|
+
// the payload immutability contract holds without copies
|
|
238
|
+
if (o !== null && typeof o === 'object' && o.__proxy__isProxy === true) {
|
|
239
|
+
return ChannelPayload.createReadOnlyProxyFacade(o)
|
|
240
|
+
}
|
|
241
|
+
|
|
153
242
|
try {
|
|
154
|
-
|
|
243
|
+
// children first: proxy subtrees must be swapped for their facades
|
|
244
|
+
// BEFORE the parent is frozen, or the assignment would be rejected
|
|
155
245
|
Object.getOwnPropertyNames(o).forEach(function(prop) {
|
|
246
|
+
const val = o[prop]
|
|
156
247
|
if (Object.prototype.hasOwnProperty.call(o, prop) &&
|
|
157
|
-
elIsDomElement(
|
|
158
|
-
|
|
159
|
-
(typeof
|
|
160
|
-
!Object.isFrozen(
|
|
161
|
-
ChannelPayload.deepFreeze(
|
|
248
|
+
elIsDomElement(val) === false &&
|
|
249
|
+
val !== null &&
|
|
250
|
+
(typeof val === 'object' || typeof val === 'function') &&
|
|
251
|
+
!Object.isFrozen(val)) {
|
|
252
|
+
const processed = ChannelPayload.deepFreeze(val)
|
|
253
|
+
if (processed !== val) {
|
|
254
|
+
o[prop] = processed
|
|
255
|
+
}
|
|
162
256
|
}
|
|
163
257
|
})
|
|
258
|
+
Object.freeze(o)
|
|
164
259
|
} catch (e) {
|
|
165
260
|
// console.log("FREEZE ERR ",{o,e});
|
|
166
261
|
return o
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { registeredStreamNames } from './channels-config.js'
|
|
2
2
|
import { ChannelPayload } from './channel-payload-class.js'
|
|
3
|
+
import { safeCloneDeep } from '../utils/safe-clone.js'
|
|
3
4
|
import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
|
|
4
5
|
import { RouteChannelUpdater } from '../utils/route-channel-updater.js'
|
|
5
6
|
|
|
@@ -318,6 +319,18 @@ export class Channel {
|
|
|
318
319
|
const mergeProps = (d) => mergeAll([d, { action: prop('action', d) }, prop('payload', d), prop('srcElement', d)])
|
|
319
320
|
const dataObj = obsVal => ({
|
|
320
321
|
clone: () => mergeProps(obj.data),
|
|
322
|
+
// proxy-preserving clone — mirrors ChannelPayload.safeClone for
|
|
323
|
+
// channel-side getChannel subscribers
|
|
324
|
+
safeClone: () => {
|
|
325
|
+
const revivedPayload = safeCloneDeep(payload)
|
|
326
|
+
return mergeAll([
|
|
327
|
+
{ payload: revivedPayload },
|
|
328
|
+
revivedPayload,
|
|
329
|
+
{ action },
|
|
330
|
+
{ srcElement },
|
|
331
|
+
{ event: obsVal }
|
|
332
|
+
])
|
|
333
|
+
},
|
|
321
334
|
action,
|
|
322
335
|
payload,
|
|
323
336
|
srcElement,
|
|
@@ -3,10 +3,12 @@ import { checkIfObjIsNotEmptyOrNil } from '../utils/frp-tools.js'
|
|
|
3
3
|
import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
|
|
4
4
|
import { SpyneUtilsChannelWindow } from '../utils/spyne-utils-channel-window.js'
|
|
5
5
|
import { merge } from 'rxjs'
|
|
6
|
-
import { map, debounceTime, skipWhile } from 'rxjs/operators'
|
|
6
|
+
import { map, debounceTime, skipWhile, buffer, bufferCount, throttleTime, filter } from 'rxjs/operators'
|
|
7
7
|
import { curry, pick, partialRight, mapObjIndexed, apply, map as rMap } from 'ramda'
|
|
8
8
|
import { deepMerge } from '../utils/deep-merge.js'
|
|
9
9
|
|
|
10
|
+
const customActionsArr = []
|
|
11
|
+
|
|
10
12
|
export class SpyneChannelWindow extends Channel {
|
|
11
13
|
/**
|
|
12
14
|
* @module SpyneChannelWindow
|
|
@@ -21,6 +23,7 @@ export class SpyneChannelWindow extends Channel {
|
|
|
21
23
|
* @param {Object} config
|
|
22
24
|
* @property {Object} config - = {}; The config has several options used to listen to window and document events.
|
|
23
25
|
* @property {Array} config.events - = []; Any window and document events can be added here.
|
|
26
|
+
* @property {Array} config.customEvents - = []; Application CustomEvents to capture. Each entry is either an event name string, or an object with a name property plus ONE rate/batch option: buffer (ms of quiet that closes a batch; emits one payload whose payload.detail is the array of batched details), count (batch every n events), debounce (ms; emits only the latest event), or throttle (ms; emits the leading event per window). Example: ['simple_event', {name: 'spyne_cms_item_connected', buffer: 400}].
|
|
24
27
|
* @property {Object} config.mediaQueries - = {}; Media queries are added as key,value pairs; the key is the name of the boolean for the query, the value is the query itself.
|
|
25
28
|
* @property {Boolean} config.listenForResize - = true; This is default listening for resize event.
|
|
26
29
|
* @property {Boolean} config.listenForOrientation - = true; Listen for horizontal and landscape orientation changes on mobile devices.
|
|
@@ -181,12 +184,88 @@ export class SpyneChannelWindow extends Channel {
|
|
|
181
184
|
getEventsFromConfig(config = this.domChannelConfig) {
|
|
182
185
|
const obs$Arr = config.events
|
|
183
186
|
|
|
187
|
+
const customEntries = Array.isArray(config.customEvents)
|
|
188
|
+
? config.customEvents.map(SpyneChannelWindow.conformCustomEventConfig).filter(entry => entry !== null)
|
|
189
|
+
: []
|
|
190
|
+
|
|
191
|
+
customEntries.forEach(entry => this.updateChannelActions(entry.name))
|
|
192
|
+
|
|
184
193
|
const addWindowEventToArr = str => {
|
|
185
194
|
const mapFn = SpyneChannelWindow.createCurriedGenericEvent(str)
|
|
186
195
|
return SpyneUtilsChannelWindow.createDomObservableFromEvent(str, mapFn)
|
|
187
196
|
}
|
|
188
197
|
|
|
189
|
-
|
|
198
|
+
const addCustomEventToArr = ({ name, operator, value }) => {
|
|
199
|
+
const obs$ = addWindowEventToArr(name)
|
|
200
|
+
return operator === undefined ? obs$ : SpyneChannelWindow.customizeCustomEventObservable(obs$, operator, value)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return rMap(addWindowEventToArr, obs$Arr).concat(rMap(addCustomEventToArr, customEntries))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Normalizes a config.customEvents entry — an event name string or a
|
|
208
|
+
* {name, <operator>} object — to {name, operator, value}. Complexity is
|
|
209
|
+
* capped at one level: a single operator key (buffer, count, debounce or
|
|
210
|
+
* throttle); when several are set the first in that order wins.
|
|
211
|
+
*/
|
|
212
|
+
static conformCustomEventConfig(entry) {
|
|
213
|
+
if (typeof entry === 'string') {
|
|
214
|
+
return { name: entry, operator: undefined, value: undefined }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (entry !== null && typeof entry === 'object' && typeof entry.name === 'string') {
|
|
218
|
+
const operatorKeys = ['buffer', 'count', 'debounce', 'throttle']
|
|
219
|
+
const setKeys = operatorKeys.filter(key => entry[key] !== undefined)
|
|
220
|
+
const operator = setKeys[0]
|
|
221
|
+
|
|
222
|
+
if (setKeys.length > 1 && SpyneAppProperties.debug === true) {
|
|
223
|
+
console.warn(`Spyne Warning: the customEvent, ${entry.name}, sets multiple operators (${setKeys.join(', ')}); using ${operator}.`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { name: entry.name, operator, value: operator !== undefined ? entry[operator] : undefined }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.warn(`Spyne Warning: the following config.customEvents entry is not a string or a {name} object and was skipped: ${JSON.stringify(entry)}`)
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Applies the entry's rate/batch operator to the conformed CustomEvent
|
|
235
|
+
* observable. buffer and count emit ONE batched payload (payload.detail is
|
|
236
|
+
* the array of each event's detail); debounce and throttle pass single
|
|
237
|
+
* conformed events through untouched.
|
|
238
|
+
*/
|
|
239
|
+
static customizeCustomEventObservable(obs$, operator, value) {
|
|
240
|
+
switch (operator) {
|
|
241
|
+
case 'buffer':
|
|
242
|
+
// quiet-gap batching: the batch closes after `value` ms without a new event
|
|
243
|
+
return obs$.pipe(
|
|
244
|
+
buffer(obs$.pipe(debounceTime(value))),
|
|
245
|
+
filter(pArr => pArr.length > 0),
|
|
246
|
+
map(SpyneChannelWindow.mapBatchedEvents)
|
|
247
|
+
)
|
|
248
|
+
case 'count':
|
|
249
|
+
// fixed-size batching: a partial batch is held until it fills
|
|
250
|
+
return obs$.pipe(
|
|
251
|
+
bufferCount(value),
|
|
252
|
+
map(SpyneChannelWindow.mapBatchedEvents)
|
|
253
|
+
)
|
|
254
|
+
case 'debounce':
|
|
255
|
+
return obs$.pipe(debounceTime(value))
|
|
256
|
+
case 'throttle':
|
|
257
|
+
return obs$.pipe(throttleTime(value))
|
|
258
|
+
default:
|
|
259
|
+
return obs$
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
static mapBatchedEvents(pArr) {
|
|
264
|
+
const last = pArr[pArr.length - 1]
|
|
265
|
+
const { action, srcElement, event } = last
|
|
266
|
+
const detail = pArr.map(p => p.event !== undefined && p.event !== null ? p.event.detail : undefined)
|
|
267
|
+
const payload = { detail, isBatch: true, batchCount: pArr.length }
|
|
268
|
+
return { action, payload, srcElement, event }
|
|
190
269
|
}
|
|
191
270
|
|
|
192
271
|
getActiveObservables(config = this.domChannelConfig) {
|
|
@@ -245,8 +324,19 @@ export class SpyneChannelWindow extends Channel {
|
|
|
245
324
|
return obs$.pipe(map(this.getMediaQueryMapFn.bind(this)))
|
|
246
325
|
}
|
|
247
326
|
|
|
327
|
+
updateChannelActions(str) {
|
|
328
|
+
if (typeof str !== 'string') {
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
function getWindowAction() {
|
|
332
|
+
return `CHANNEL_WINDOW_${str.toUpperCase()}_EVENT`
|
|
333
|
+
}
|
|
334
|
+
const windowAction = getWindowAction()
|
|
335
|
+
customActionsArr.push(windowAction)
|
|
336
|
+
}
|
|
337
|
+
|
|
248
338
|
addRegisteredActions() {
|
|
249
|
-
|
|
339
|
+
const mainActions = [
|
|
250
340
|
'CHANNEL_WINDOW_ABORT_EVENT',
|
|
251
341
|
'CHANNEL_WINDOW_AFTERPRINT_EVENT',
|
|
252
342
|
'CHANNEL_WINDOW_ANIMATIONEND_EVENT',
|
|
@@ -334,6 +424,8 @@ export class SpyneChannelWindow extends Channel {
|
|
|
334
424
|
'CHANNEL_WINDOW_UNLOAD_EVENT',
|
|
335
425
|
'CHANNEL_WINDOW_WHEEL_EVENT'
|
|
336
426
|
]
|
|
427
|
+
|
|
428
|
+
return mainActions.concat(customActionsArr)
|
|
337
429
|
}
|
|
338
430
|
|
|
339
431
|
onScrollLockEvent(e) {
|
package/src/spyne/spyne-app.js
CHANGED
|
@@ -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.
|
|
9
|
+
const version = '0.26.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.
|
|
44
|
+
* Spyne 0.26.0
|
|
45
45
|
* https://spynejs.org
|
|
46
46
|
*
|
|
47
47
|
* @license
|
|
@@ -77,6 +77,7 @@ class SpyneApplication {
|
|
|
77
77
|
mode: 'app',
|
|
78
78
|
disableSanitize: false,
|
|
79
79
|
iframes: {},
|
|
80
|
+
anchors: {},
|
|
80
81
|
customElements: [],
|
|
81
82
|
baseHref: undefined,
|
|
82
83
|
IMG_PATH: imgPath,
|
|
@@ -95,6 +96,7 @@ class SpyneApplication {
|
|
|
95
96
|
|
|
96
97
|
},
|
|
97
98
|
events: [],
|
|
99
|
+
customEvents: [],
|
|
98
100
|
listenForResize: true,
|
|
99
101
|
listenForOrientation: true,
|
|
100
102
|
listenForScroll: false,
|
|
@@ -128,16 +128,33 @@ export class SpynePlugin {
|
|
|
128
128
|
|
|
129
129
|
checkForRequiredWindowEvent() {
|
|
130
130
|
const requiredEvents = this.props.requiredEvents ?? []
|
|
131
|
+
const requiredCustomEvents = this.props.requiredCustomEvents ?? []
|
|
132
|
+
|
|
131
133
|
const windowConfig = SpyneAppProperties.getChannelConfig('WINDOW')
|
|
132
134
|
|
|
133
135
|
const missingEvents = requiredEvents.filter(evt => !windowConfig.events.includes(evt))
|
|
134
136
|
|
|
137
|
+
// entries on BOTH sides can be a name string or a {name, buffer|count|debounce|throttle}
|
|
138
|
+
// object — hosts declare how the event is conformed, plugins declare the exact
|
|
139
|
+
// configuration they expect, and matching is by name only
|
|
140
|
+
const entryName = entry => typeof entry === 'string' ? entry : entry?.name
|
|
141
|
+
const customEventNames = windowConfig.customEvents.map(entryName)
|
|
142
|
+
const missingCustomEvents = requiredCustomEvents.filter(evt => !customEventNames.includes(entryName(evt)))
|
|
143
|
+
|
|
135
144
|
// If any are missing, log a single warning listing them
|
|
136
145
|
if (missingEvents.length > 0) {
|
|
137
146
|
console.warn(
|
|
138
147
|
`plugin "${this.props.name}" requires the following config.WINDOW.events, --> ${missingEvents.join(', ')} <--`
|
|
139
148
|
)
|
|
140
149
|
}
|
|
150
|
+
|
|
151
|
+
if (missingCustomEvents.length > 0) {
|
|
152
|
+
// print the exact configuration entry so it can be pasted into the host config
|
|
153
|
+
const entryAsConfigStr = entry => typeof entry === 'string' ? `'${entry}'` : JSON.stringify(entry)
|
|
154
|
+
console.warn(
|
|
155
|
+
`plugin "${this.props.name}" requires the following config.WINDOW.customEvents, --> ${missingCustomEvents.map(entryAsConfigStr).join(', ')} <--`
|
|
156
|
+
)
|
|
157
|
+
}
|
|
141
158
|
}
|
|
142
159
|
|
|
143
160
|
defaultConfig() {
|
package/src/spyne/spyne.js
CHANGED
|
@@ -14,7 +14,7 @@ import { ChannelPayload } from './channels/channel-payload-class.js'
|
|
|
14
14
|
import { ChannelPayloadFilter } from './utils/channel-payload-filter.js'
|
|
15
15
|
import { SpynePlugin } from './spyne-plugins.js'
|
|
16
16
|
import { deepMerge } from './utils/deep-merge.js'
|
|
17
|
-
import { safeClone } from './utils/safe-clone.js'
|
|
17
|
+
import { safeClone, safeCloneDeep, safeAugment, safeFilter, safeMap, safeReject } from './utils/safe-clone.js'
|
|
18
18
|
import { SpyneAppProperties } from './utils/spyne-app-properties.js'
|
|
19
19
|
import { SpyneApp } from './spyne-app.js'
|
|
20
20
|
import { setSpyneWarningsDisabled } from './utils/spyne-warn.js'
|
|
@@ -40,6 +40,11 @@ export {
|
|
|
40
40
|
SpynePlugin,
|
|
41
41
|
deepMerge,
|
|
42
42
|
safeClone,
|
|
43
|
+
safeCloneDeep,
|
|
44
|
+
safeAugment,
|
|
45
|
+
safeFilter,
|
|
46
|
+
safeMap,
|
|
47
|
+
safeReject,
|
|
43
48
|
sanitizeData,
|
|
44
49
|
sanitizeEventTarget,
|
|
45
50
|
allowCustomElements,
|
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
mergeAll,
|
|
17
17
|
F,
|
|
18
18
|
omit,
|
|
19
|
-
flatten,
|
|
20
19
|
any,
|
|
21
20
|
curry,
|
|
22
21
|
lte
|
|
@@ -218,8 +217,11 @@ export class ChannelPayloadFilter {
|
|
|
218
217
|
const { payload, srcElement, event } = srcPayload || {}
|
|
219
218
|
|
|
220
219
|
const reduceFindEl = (acc, src) => {
|
|
220
|
+
if (acc !== undefined) {
|
|
221
|
+
return acc
|
|
222
|
+
}
|
|
221
223
|
const el = prop('el', src)
|
|
222
|
-
if (ChannelPayloadFilter.elIsDomElement(el)
|
|
224
|
+
if (ChannelPayloadFilter.elIsDomElement(el)) {
|
|
223
225
|
acc = el
|
|
224
226
|
}
|
|
225
227
|
return acc
|
|
@@ -227,35 +229,37 @@ export class ChannelPayloadFilter {
|
|
|
227
229
|
|
|
228
230
|
const el = [srcElement, payload, prop('srcElement', event), srcPayload].reduce(reduceFindEl, undefined)
|
|
229
231
|
|
|
230
|
-
//
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
232
|
+
// CHECK IF PAYLOAD EL EXISTS
|
|
233
|
+
const elCanMatch = el !== null && typeof el === 'object' && typeof el.matches === 'function'
|
|
234
|
+
|
|
235
|
+
// the payload element is tested directly with Element.matches — the same
|
|
236
|
+
// "is this el one of the selector's elements" answer as the previous
|
|
237
|
+
// document-wide querySelectorAll walk, without scanning the document on
|
|
238
|
+
// every dispatch (isEqualNode could also pass a structural lookalike of
|
|
239
|
+
// a matched node, which was an over-match, not a contract)
|
|
240
|
+
const elMatches = (sel) => {
|
|
241
|
+
try {
|
|
242
|
+
return el.matches(sel)
|
|
243
|
+
} catch (e) {
|
|
244
|
+
if (SpyneAppProperties.debug === true) {
|
|
245
|
+
console.warn(`Spyne Warning: The ChannelPayloadFilter selector, ${sel}, is not a valid css selector!`)
|
|
246
|
+
}
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
239
249
|
}
|
|
240
250
|
|
|
251
|
+
const matchesBoolArr = elCanMatch ? rMap(elMatches, arr) : []
|
|
252
|
+
|
|
241
253
|
if (debugLabel !== undefined) {
|
|
242
|
-
const nodeArrResultsDebugger = compose(flatten, rMap(mapNodeArrWithEl))(arr)
|
|
243
254
|
const selectorsArr = arr
|
|
244
|
-
console.log(`%c CHANNEL PAYLOAD FILTER DEBUGGER ["${debugLabel}"] - selectors: `, 'color:orange;', { el, selectorsArr, nodeArrResultsDebugger })
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// CHECK IF PAYLOAD EL EXISTS
|
|
248
|
-
if (typeof (el) !== 'object') {
|
|
249
|
-
return false
|
|
255
|
+
console.log(`%c CHANNEL PAYLOAD FILTER DEBUGGER ["${debugLabel}"] - selectors: `, 'color:orange;', { el, selectorsArr, nodeArrResultsDebugger: matchesBoolArr })
|
|
250
256
|
}
|
|
251
257
|
|
|
252
|
-
|
|
253
|
-
const nodeArrResult = compose(flatten, rMap(mapNodeArrWithEl))(arr)
|
|
254
|
-
if (isEmpty(nodeArrResult) === true) {
|
|
258
|
+
if (elCanMatch === false || isEmpty(matchesBoolArr) === true) {
|
|
255
259
|
return false
|
|
256
260
|
}
|
|
257
261
|
|
|
258
|
-
return any(equals(true),
|
|
262
|
+
return any(equals(true), matchesBoolArr)
|
|
259
263
|
}
|
|
260
264
|
|
|
261
265
|
static elIsDomElement(o) {
|
|
@@ -15,7 +15,7 @@ const getPropType = (prp) => {
|
|
|
15
15
|
return type
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const cmsDataReviveNestedProxyObj = (proxyObj, proxyReviverMethod) => {
|
|
18
|
+
const cmsDataReviveNestedProxyObj = (proxyObj = {}, proxyReviverMethod) => {
|
|
19
19
|
const { __proxy__proxyName } = proxyObj
|
|
20
20
|
|
|
21
21
|
if (__proxy__proxyName === undefined) {
|
|
@@ -50,7 +50,7 @@ const cmsDataReviveNestedProxyObj = (proxyObj, proxyReviverMethod) => {
|
|
|
50
50
|
return rootObj
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
const reviveIfProxy = (o = {}) => {
|
|
54
54
|
const { __proxy__proxyName } = o
|
|
55
55
|
if (__proxy__proxyName !== undefined) {
|
|
56
56
|
const proxyReviverMethod = SpyneAppProperties.getProxyReviver(__proxy__proxyName)
|
|
@@ -58,6 +58,132 @@ export const safeClone = function(o) {
|
|
|
58
58
|
return cmsDataReviveNestedProxyObj(o, proxyReviverMethod)
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
*
|
|
66
|
+
* Deep, proxy-preserving clone. Unlike safeClone — which only revives when the
|
|
67
|
+
* ROOT object is a proxy — this walks plain containers (objects and arrays)
|
|
68
|
+
* and revives any nested proxy it finds, so wrappers that hold proxied data
|
|
69
|
+
* (e.g. channel payloads, pageData containers) survive cloning intact.
|
|
70
|
+
* Functions are passed through by reference; cycles are guarded.
|
|
71
|
+
*
|
|
72
|
+
* @param {Object|Array} o
|
|
73
|
+
* @returns A writable copy in which every nested proxy has been revived
|
|
74
|
+
*/
|
|
75
|
+
export const safeCloneDeep = function(o, seen = new WeakSet()) {
|
|
76
|
+
if (o === null || typeof o !== 'object') {
|
|
77
|
+
return o
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const revived = reviveIfProxy(o)
|
|
81
|
+
if (revived !== undefined) {
|
|
82
|
+
return revived
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (seen.has(o)) {
|
|
86
|
+
return o
|
|
87
|
+
}
|
|
88
|
+
seen.add(o)
|
|
89
|
+
|
|
90
|
+
if (Array.isArray(o)) {
|
|
91
|
+
return o.map(item => safeCloneDeep(item, seen))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const out = {}
|
|
95
|
+
Object.entries(o).forEach(([key, val]) => {
|
|
96
|
+
out[key] = safeCloneDeep(val, seen)
|
|
97
|
+
})
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Re-wraps a derived array with the SOURCE array's proxy metadata via the
|
|
103
|
+
* registered reviver. Native filter/reject/map keep element identity but
|
|
104
|
+
* return a plain array — losing the array-level proxy that string-item
|
|
105
|
+
* template loops (e.g. {{#titles}}) rely on for __cms__dataId / keyFor
|
|
106
|
+
* lookups. In production (no proxies) this is a single undefined check.
|
|
107
|
+
*
|
|
108
|
+
* Note: the reviver rebuilds its key/value map from the derived contents, so
|
|
109
|
+
* value-keyed lookups survive while original index positions shift.
|
|
110
|
+
*/
|
|
111
|
+
const rewrapArrayFromSource = (sourceArr, resultArr) => {
|
|
112
|
+
const proxyName = sourceArr?.__proxy__proxyName
|
|
113
|
+
if (proxyName === undefined) {
|
|
114
|
+
return resultArr
|
|
115
|
+
}
|
|
116
|
+
const proxyReviverMethod = SpyneAppProperties.getProxyReviver(proxyName)
|
|
117
|
+
if (typeof proxyReviverMethod !== 'function') {
|
|
118
|
+
return resultArr
|
|
119
|
+
}
|
|
120
|
+
return proxyReviverMethod(resultArr, sourceArr.__proxy__props)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
*
|
|
125
|
+
* Proxy-preserving Array.prototype.filter: elements keep their identity
|
|
126
|
+
* (native behavior) AND the returned array keeps the source array's proxy
|
|
127
|
+
* metadata. Use in place of ramda filter / arr.filter on CMS data.
|
|
128
|
+
*/
|
|
129
|
+
export const safeFilter = function(arr, predicate) {
|
|
130
|
+
return rewrapArrayFromSource(arr, arr.filter(predicate))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
*
|
|
135
|
+
* Proxy-preserving reject — inverse of safeFilter.
|
|
136
|
+
*/
|
|
137
|
+
export const safeReject = function(arr, predicate) {
|
|
138
|
+
return rewrapArrayFromSource(arr, arr.filter((item, i) => predicate(item, i) === false))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
*
|
|
143
|
+
* Proxy-preserving Array.prototype.map with an augment-in-place contract:
|
|
144
|
+
* proxied elements arrive in the callback as WRITABLE revived copies, so
|
|
145
|
+
* `safeMap(stories, s => { s.type = 'story'; return s; })` is safe by
|
|
146
|
+
* construction (no silent live-proxy write traps). Plain elements pass by
|
|
147
|
+
* reference exactly like native map, so production cost matches native.
|
|
148
|
+
* A callback that builds an entirely new object per element still strips
|
|
149
|
+
* that element's identity — there is nothing left to preserve.
|
|
150
|
+
*/
|
|
151
|
+
export const safeMap = function(arr, mapFn) {
|
|
152
|
+
const result = arr.map((item, i) => {
|
|
153
|
+
const isProxied = item !== null && typeof item === 'object' && item.__proxy__proxyName !== undefined
|
|
154
|
+
return mapFn(isProxied ? safeCloneDeep(item) : item, i)
|
|
155
|
+
})
|
|
156
|
+
return rewrapArrayFromSource(arr, result)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
*
|
|
161
|
+
* Revive-then-augment. Live CMS proxies silently ignore writes of new keys,
|
|
162
|
+
* so properties cannot be attached to them directly. This returns a writable,
|
|
163
|
+
* proxy-preserved copy of o with the given props applied — the sanctioned way
|
|
164
|
+
* to add view or route properties to CMS-proxied data.
|
|
165
|
+
*
|
|
166
|
+
* @param {Object|Array} o
|
|
167
|
+
* @param {Object} propsObj properties to assign onto the revived copy
|
|
168
|
+
* @returns A writable proxy-preserved copy carrying the extra props
|
|
169
|
+
*/
|
|
170
|
+
export const safeAugment = function(o, propsObj = {}) {
|
|
171
|
+
const target = safeCloneDeep(o)
|
|
172
|
+
Object.entries(propsObj).forEach(([key, val]) => {
|
|
173
|
+
target[key] = val
|
|
174
|
+
})
|
|
175
|
+
return target
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const safeClone = function(o, deep = false) {
|
|
179
|
+
const revived = reviveIfProxy(o)
|
|
180
|
+
if (revived !== undefined) {
|
|
181
|
+
return revived
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (deep === true) {
|
|
185
|
+
return safeCloneDeep(o)
|
|
186
|
+
}
|
|
61
187
|
|
|
62
188
|
const isArr = is(Array)
|
|
63
189
|
const isObj = is(Object)
|