@iebh/tera-fy 1.0.10 → 1.0.11

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,6 @@
1
1
  import {diff, jsonPatchPathConverter as jsPatchConverter} from 'just-diff';
2
2
  import {cloneDeep} from 'lodash-es';
3
+ import Mitt from 'mitt';
3
4
  import {nanoid} from 'nanoid';
4
5
 
5
6
  /* globals globalThis */
@@ -30,6 +31,13 @@ export default class TeraFy {
30
31
  };
31
32
 
32
33
 
34
+ /**
35
+ * Event emitter subscription endpoint
36
+ * @type {Mitt}
37
+ */
38
+ events = Mitt();
39
+
40
+
33
41
  /**
34
42
  * DOMElements for this TeraFy instance
35
43
  *
@@ -61,9 +69,12 @@ export default class TeraFy {
61
69
  // Projects
62
70
  'bindProject', 'getProject', 'getProjects', 'setActiveProject', 'requireProject', 'selectProject',
63
71
 
64
- // Project state
65
- 'getProjectState', 'applyProjectStatePatch', 'subscribeProjectState',
66
- // bindProjectState() - See below
72
+ // Project State
73
+ 'getProjectState', 'setProjectState', 'saveProjectState', 'replaceProjectState',
74
+
75
+ // Project State Patching + Subscribing
76
+ 'applyProjectStatePatch', 'subscribeProjectState',
77
+ // bindProjectState() - See individual plugins
67
78
 
68
79
  // Project files
69
80
  'getProjectFiles',
@@ -190,6 +201,13 @@ export default class TeraFy {
190
201
  response: e.toString(),
191
202
  });
192
203
  })
204
+ } else if (message?.action == 'event') {
205
+ return Promise.resolve()
206
+ .then(()=> this.events.emit(message.event, ...message.payload))
207
+ .catch(e => {
208
+ console.warn(`TERA-FY client threw while handling emitted event "${message.event}"`, {message});
209
+ throw e;
210
+ })
193
211
  } else if (message?.id) {
194
212
  this.debug(`Ignoring message ID ${message.id} - was meant for someone else?`);
195
213
  } else {
@@ -273,6 +291,11 @@ export default class TeraFy {
273
291
  this.injectStylesheet(),
274
292
  this.injectMethods(),
275
293
  ]))
294
+ .then(()=> this.rpc('setServerMode', // Tell server what mode its in
295
+ this.settings.mode == 'child' ? 'embedded'
296
+ : this.settings.mode == 'parent' ? 'window'
297
+ : (()=> { throw(`Unknown server mode "${this.settings.mode}"`) })()
298
+ ))
276
299
  .then(()=> Promise.all( // Init all plugins (with this outer module as the context)
277
300
  this.plugins.map(plugin =>
278
301
  plugin.init.call(context)
@@ -317,6 +340,8 @@ export default class TeraFy {
317
340
  injectComms() { return new Promise(resolve => {
318
341
  switch (this.settings.mode) {
319
342
  case 'child':
343
+ this.debug('Injecting TERA site as iFrame child');
344
+
320
345
  this.dom.el = document.createElement('div')
321
346
  this.dom.el.id = 'tera-fy';
322
347
  this.dom.el.classList.toggle('dev-mode', this.settings.devMode);
@@ -336,6 +361,7 @@ export default class TeraFy {
336
361
  this.dom.el.append(this.dom.iframe);
337
362
  break;
338
363
  case 'parent':
364
+ this.debug('Using TERA site stack parent');
339
365
  resolve();
340
366
  break;
341
367
  default:
@@ -637,6 +663,45 @@ export default class TeraFy {
637
663
  */
638
664
 
639
665
 
666
+ /**
667
+ * Set a nested value within the project state
668
+ * Paths can be any valid Lodash.set() value such as:
669
+ *
670
+ * - Dotted notation - e.g. `foo.bar.1.baz`
671
+ * - Array path segments e.g. `['foo', 'bar', 1, 'baz']`
672
+ *
673
+ *
674
+ * @function setProjectState
675
+ * @param {String|Array<String>} path The sub-path within the project state to set
676
+ * @param {*} value The value to set
677
+ *
678
+ * @param {Object} [options] Additional options to mutate behaviour
679
+ * @param {Boolean} [options.save=true] Save the changes to the server immediately, disable to queue up multiple writes
680
+ * @param {Boolean} [options.sync=false] Wait for the server to acknowledge the write, you almost never need to do this
681
+ *
682
+ * @returns {Promise} A promise which resolves when the operation has synced with the server
683
+ */
684
+
685
+
686
+ /**
687
+ * Force-Save the currently active project state
688
+ *
689
+ * @function saveProjectState
690
+ * @returns {Promise} A promise which resolves when the operation has completed
691
+ */
692
+
693
+
694
+ /**
695
+ * Overwrite the entire project state with a new object
696
+ * You almost never want to use this function directly, see `setProjectState(path, value)` for a nicer wrapper
697
+ *
698
+ * @function replaceProjectState
699
+ * @see setProjectState()
700
+ * @param {Object} newState The new state to replace the current state with
701
+ * @returns {Promise} A promise which resolves when the operation has completed
702
+ */
703
+
704
+
640
705
  /**
641
706
  * Apply a computed `just-diff` patch to the current project state
642
707
  *
@@ -1,6 +1,7 @@
1
1
  import {cloneDeep, set as pathSet} from 'lodash-es';
2
2
  import {diffApply, jsonPatchPathConverter as jsPatchConverter} from 'just-diff-apply';
3
3
  import {nanoid} from 'nanoid';
4
+ import mixin from '#utils/mixin';
4
5
 
5
6
  /**
6
7
  * Server-side functions available to the Tera-Fy client library
@@ -19,15 +20,22 @@ export default class TeraFyServer {
19
20
  * @property {Number} subscribeTimeout Acceptable timeout period for subscribers to acklowledge a project change event, failing to respond will result in the subscriber being removed from the available subscriber list
20
21
  * @property {String} restrictOrigin URL to restrict communications to
21
22
  * @property {String} projectId The project to use as the default reference when calling various APIs
23
+ * @property {Number} The current server mode matching `SERVERMODE_*`
22
24
  */
23
25
  settings = {
24
26
  devMode: false,
25
27
  restrictOrigin: '*',
26
28
  subscribeTimeout: 2000,
27
29
  projectId: null,
30
+ serverMode: 0,
28
31
  };
29
32
 
30
- // Contexts - createContext(), messageEvent, senderRpc() {{{
33
+ static SERVERMODE_NONE = 0;
34
+ static SERVERMODE_EMBEDDED = 1;
35
+ static SERVERMODE_WINDOW = 2;
36
+
37
+
38
+ // Contexts - createContext(), getClientContext(), messageEvent, senderRpc() {{{
31
39
  /**
32
40
  * Create a context based on a shallow copy of this instance + additional functionality for the incoming MessageEvent
33
41
  * This is used by acceptMessage to provide a means to reply / send messages to the originator
@@ -37,10 +45,10 @@ export default class TeraFyServer {
37
45
  * @returns {Object} A context, which is this instance extended with additional properties
38
46
  */
39
47
  createContext(e) {
40
- // Rather ugly shallow-copy-of instancr hack from https://stackoverflow.com/a/44782052/1295040
41
- return Object.assign(Object.create(Object.getPrototypeOf(this)), this, {
48
+ // Construct wrapper for sendRaw for this client
49
+ return mixin(this, {
42
50
  messageEvent: e,
43
- sendRaw(message) { // Override sendRaw because we can't do this inline for security reasons
51
+ sendRaw(message) {
44
52
  let payload;
45
53
  try {
46
54
  payload = {
@@ -49,8 +57,7 @@ export default class TeraFyServer {
49
57
  };
50
58
  e.source.postMessage(payload, this.settings.restrictOrigin);
51
59
  } catch (e) {
52
- this.debug('ERROR', 'Message compose/reply via server->cient:', e);
53
- this.debug('ERROR', 'Attempted to dispatch payload server(via reply)->client', payload);
60
+ this.debug('ERROR', 'Attempted to dispatch payload server(via reply)->client', {payload, e});
54
61
  throw e;
55
62
  }
56
63
  },
@@ -58,6 +65,58 @@ export default class TeraFyServer {
58
65
  }
59
66
 
60
67
 
68
+ /**
69
+ * Create a new client context from the server to the client even if the client hasn't requested the communication
70
+ * This function is used to send unsolicited communications from the server->client in contrast to createContext() which _replies_ from client->server->client
71
+ *
72
+ * @returns {Object} A context, which is this instance extended with additional properties
73
+ */
74
+ getClientContext() {
75
+ switch (this.settings.serverMode) {
76
+ case TeraFyServer.SERVERMODE_NONE:
77
+ throw new Error('Client has not yet initiated communication');
78
+ case TeraFyServer.SERVERMODE_EMBEDDED:
79
+ // Server is inside an iFrame so we need to send messages to the window parent
80
+ return mixin(this, {
81
+ sendRaw(message) {
82
+ let payload;
83
+ try {
84
+ payload = {
85
+ TERA: 1,
86
+ ...cloneDeep(message), // Need to clone to resolve promise nasties
87
+ };
88
+ window.parent.postMessage(payload, this.settings.restrictOrigin);
89
+ } catch (e) {
90
+ this.debug('ERROR', 'Attempted to dispatch payload server(iframe)->cient(top level window)', {payload, e});
91
+ throw e;
92
+ }
93
+ },
94
+ });
95
+ case TeraFyServer.SERVERMODE_WINDOW:
96
+ // Server is the top-level window so we need to send messages to an embedded iFrame
97
+ debugger; // FIXME: THIS IS ALL UNTESTED
98
+ let iFrame = document.querySelector('iframe#tera-fy');
99
+ if (!iFrame) throw new Error('Cannot locate TERA-FY client iFrame');
100
+
101
+ return mixin(this, {
102
+ sendRaw(message) {
103
+ let payload;
104
+ try {
105
+ payload = {
106
+ TERA: 1,
107
+ ...cloneDeep(message), // Need to clone to resolve promise nasties
108
+ };
109
+ iFrame.postMessage(payload, this.settings.restrictOrigin);
110
+ } catch (e) {
111
+ this.debug('ERROR', 'Attempted to dispatch payload server(top level window)->cient(iframe)', {payload, e});
112
+ throw e;
113
+ }
114
+ },
115
+ });
116
+ }
117
+ }
118
+
119
+
61
120
  /**
62
121
  * MessageEvent context
63
122
  * Only available if the context was created via `createContext()`
@@ -87,7 +146,7 @@ export default class TeraFyServer {
87
146
  }
88
147
  // }}}
89
148
 
90
- // Messages - handshake(), sendRaw(), acceptMessage(), requestFocus() {{{
149
+ // Messages - handshake(), send(), sendRaw(), setServerMode(), acceptMessage(), requestFocus(), emitClients() {{{
91
150
 
92
151
  /**
93
152
  * Return basic server information as a form of validation
@@ -130,6 +189,7 @@ export default class TeraFyServer {
130
189
 
131
190
  /**
132
191
  * Send raw message content to the client
192
+ * Unlike send() this method does not expect any response
133
193
  *
134
194
  * @param {Object} message Message object to send
135
195
  * @param {Window} Window context to dispatch the message via if its not the same as the regular window
@@ -141,13 +201,31 @@ export default class TeraFyServer {
141
201
  TERA: 1,
142
202
  ...cloneDeep(message), // Need to clone to resolve promise nasties
143
203
  };
144
- this.debug('INFO', 'Parent reply', message, '<=>', payload);
204
+ this.debug('INFO', 'Parent send', message, '<=>', payload);
145
205
  (sendVia || globalThis.parent).postMessage(payload, this.settings.restrictOrigin);
146
206
  } catch (e) {
147
207
  this.debug('ERROR', 'Attempted to dispatch payload server->client', payload);
148
208
  this.debug('ERROR', 'Message compose server->client:', e);
149
209
  }
210
+ }
211
+
150
212
 
213
+ /**
214
+ * Setter to translate between string inputs and the server modes in SERVERMODE_*
215
+ *
216
+ * @param {String} mode The server mode to set to
217
+ */
218
+ setServerMode(mode) {
219
+ switch (mode) {
220
+ case 'embedded':
221
+ this.settings.serverMode = TeraFyServer.SERVERMODE_EMBEDDED;
222
+ break;
223
+ case 'window':
224
+ this.settings.serverMode = TeraFyServer.SERVERMODE_WINDOW;
225
+ break;
226
+ default:
227
+ throw new Error(`Unsupported server mode "${mode}"`);
228
+ }
151
229
  }
152
230
 
153
231
 
@@ -217,6 +295,24 @@ export default class TeraFyServer {
217
295
  .then(()=> cb.call(this))
218
296
  .finally(()=> this.senderRpc('toggleFocus', false))
219
297
  }
298
+
299
+
300
+ /**
301
+ * Emit messages down into all connected clients
302
+ * Note that emitted messages have no response - they are sent to clients only with no return value
303
+ *
304
+ * @param {String} event The event name to emit
305
+ * @param {*} [args...] Optional event payload to send
306
+ * @returns {Promise} A promise which resolves when the transmission has completed
307
+ */
308
+ emitClients(event, ...args) {
309
+ return this.getClientContext().sendRaw({
310
+ action: 'event',
311
+ id: nanoid(),
312
+ event,
313
+ payload: args,
314
+ });
315
+ }
220
316
  // }}}
221
317
 
222
318
  // Session / User - getUser() {{{
@@ -409,7 +505,7 @@ export default class TeraFyServer {
409
505
 
410
506
  // }}}
411
507
 
412
- // Project State - getProjectState(), setProjectState(), saveProjectState(), applyProjectStatePatch(), subscribeProjectState() {{{
508
+ // Project State - getProjectState(), setProjectState(), saveProjectState(), replaceProjectState() {{{
413
509
 
414
510
  /**
415
511
  * Return the current, full snapshot state of the active project
@@ -483,6 +579,24 @@ export default class TeraFyServer {
483
579
  }
484
580
 
485
581
 
582
+ /**
583
+ * Overwrite the entire project state with a new object
584
+ * You almost never want to use this function directly, see `setProjectState(path, value)` for a nicer wrapper
585
+ *
586
+ * @see setProjectState()
587
+ * @param {Object} newState The new state to replace the current state with
588
+ * @returns {Promise} A promise which resolves when the operation has completed
589
+ */
590
+ replaceProjectState(newState) {
591
+ if (!app.service('$projects').active) throw new Error('No active project');
592
+ if (typeof newState != 'object') throw new Error('Only project state objects are accepted');
593
+
594
+ Object.assign(app.service('$projects').active, newState);
595
+ return this.saveProjectState();
596
+ }
597
+ // }}}
598
+
599
+ // Project State Patching + Subscribing - applyProjectStatePatch(), subscribeProjectState() {{{
486
600
  /**
487
601
  * Apply a computed `just-diff` patch to the current project state
488
602
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iebh/tera-fy",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "TERA website worker",
5
5
  "scripts": {
6
6
  "dev": "esbuild --platform=browser --format=esm --bundle lib/terafy.client.js --outfile=dist/terafy.js --minify --sourcemap --serve --servedir=.",
@@ -12,7 +12,8 @@
12
12
  },
13
13
  "type": "module",
14
14
  "imports": {
15
- "#terafy": "./lib/terafy.client.js"
15
+ "#terafy": "./lib/terafy.client.js",
16
+ "#utils/*": "./utils/*.js"
16
17
  },
17
18
  "exports": {
18
19
  ".": {
@@ -84,5 +85,9 @@
84
85
  "ecmaVersion": 13,
85
86
  "sourceType": "module"
86
87
  }
88
+ },
89
+ "dependencies": {
90
+ "@momsfriendlydevco/supabase-reactive": "^1.0.7",
91
+ "mitt": "^3.0.1"
87
92
  }
88
93
  }
package/plugins/vue2.js CHANGED
@@ -78,19 +78,31 @@ export default class TeraFyPluginVue2 extends TeraFyPluginBase {
78
78
  settings.component[settings.componentKey] = stateObservable;
79
79
 
80
80
  // Watch for remote changes and update
81
- // FIXME: Not yet supported
81
+ let skipUpdate = 0; // How many subsequent WRITE operations to ignore (set when reading)
82
+ if (settings.read) {
83
+ this.events.on(`update:projects/${stateReactive.id}`, newState => {
84
+ skipUpdate++; // Skip next update as we're updating our own state anyway
85
+ Object.assign(stateReactive, newState);
86
+ });
87
+ }
82
88
 
83
89
  // Watch for local writes and react
84
90
  if (settings.write) {
85
91
  if (!settings.component) throw new Error('bindProjectState requires a VueComponent specified as `component`');
86
92
 
87
- // NOTE: The below $watch function returns two copies of the new value of the observed so we have to keep track
88
- // of what changed ourselves by initalizing against the snapshot
93
+ // NOTE: The below $watch function returns two copies of the new value of the observed
94
+ // so we have to keep track of what changed ourselves by initalizing against the
95
+ // snapshot
89
96
  let oldVal = cloneDeep(snapshot);
90
97
 
91
98
  settings.component.$watch(
92
99
  settings.componentKey,
93
100
  newVal => {
101
+ if (skipUpdate > 0) {
102
+ skipUpdate--;
103
+ return;
104
+ }
105
+
94
106
  this.createProjectStatePatch(newVal, oldVal);
95
107
  oldVal = cloneDeep(snapshot);
96
108
  },
package/plugins/vue3.js CHANGED
@@ -30,6 +30,7 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
30
30
  *
31
31
  * @param {Object} [options] Additional options to mutate behaviour
32
32
  * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
33
+ * @param {Boolean} [options.read=true] Allow remote reactivity - update the local state when the server changes
33
34
  * @param {Boolean} [options.write=true] Allow local reactivity to writes - send these to the server
34
35
  *
35
36
  * @returns {Promie<Reactive<Object>>} A reactive object representing the project state
@@ -37,6 +38,7 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
37
38
  bindProjectState(options) {
38
39
  let settings = {
39
40
  autoRequire: true,
41
+ read: true,
40
42
  write: true,
41
43
  ...options,
42
44
  };
@@ -46,19 +48,30 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
46
48
  autoRequire: settings.autoRequire ,
47
49
  }))
48
50
  .then(snapshot => {
49
- this.debug('Got project snapshot', snapshot);
51
+ this.debug('Fetched project snapshot', snapshot);
50
52
 
51
53
  // Create initial reactive
52
54
  let stateReactive = reactive(snapshot);
53
55
 
54
56
  // Watch for remote changes and update
55
- // FIXME: Not yet supported
57
+ let skipUpdate = 0; // How many subsequent WRITE operations to ignore (set when reading)
58
+ if (settings.read) {
59
+ this.events.on(`update:projects/${stateReactive.id}`, newState => {
60
+ skipUpdate++; // Skip next update as we're updating our own state anyway
61
+ Object.assign(stateReactive, newState);
62
+ });
63
+ }
56
64
 
57
65
  // Watch for local writes and react
58
66
  if (settings.write) {
59
67
  watch(
60
68
  stateReactive,
61
69
  (newVal, oldVal) => {
70
+ if (skipUpdate > 0) {
71
+ skipUpdate--;
72
+ return;
73
+ }
74
+
62
75
  this.createProojectStatePatch(newVal, oldVal);
63
76
  },
64
77
  {
package/utils/mixin.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shallow-copy a object instance and inject new properties into the result
3
+ *
4
+ * Rather ugly shallow-copy-of instance hack from https://stackoverflow.com/a/44782052/1295040
5
+ * Keeps the original object instance and overrides the given object of assignments
6
+ *
7
+ * @param {Object} instance Original object class instance to mixin
8
+ * @param {Object} assignments Additional object properties to mix
9
+ * @returns {Object} A shallow copy of the input instance extended with the assignments
10
+ */
11
+ export default function mixin(instance, assignments) {
12
+ let output = Object.assign(
13
+ Object.create(Object.getPrototypeOf(instance)),
14
+ instance,
15
+ assignments,
16
+ );
17
+ return output;
18
+ }