@iebh/tera-fy 1.0.9 → 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.
Files changed (37) hide show
  1. package/README.md +9 -4
  2. package/api.md +445 -431
  3. package/dist/terafy.js +2 -2
  4. package/dist/terafy.js.map +4 -4
  5. package/docs/assets/anchor.js +350 -0
  6. package/docs/assets/bass-addons.css +12 -0
  7. package/docs/assets/bass.css +544 -0
  8. package/docs/assets/fonts/EOT/SourceCodePro-Bold.eot +0 -0
  9. package/docs/assets/fonts/EOT/SourceCodePro-Regular.eot +0 -0
  10. package/docs/assets/fonts/LICENSE.txt +93 -0
  11. package/docs/assets/fonts/OTF/SourceCodePro-Bold.otf +0 -0
  12. package/docs/assets/fonts/OTF/SourceCodePro-Regular.otf +0 -0
  13. package/docs/assets/fonts/TTF/SourceCodePro-Bold.ttf +0 -0
  14. package/docs/assets/fonts/TTF/SourceCodePro-Regular.ttf +0 -0
  15. package/docs/assets/fonts/WOFF/OTF/SourceCodePro-Bold.otf.woff +0 -0
  16. package/docs/assets/fonts/WOFF/OTF/SourceCodePro-Regular.otf.woff +0 -0
  17. package/docs/assets/fonts/WOFF/TTF/SourceCodePro-Bold.ttf.woff +0 -0
  18. package/docs/assets/fonts/WOFF/TTF/SourceCodePro-Regular.ttf.woff +0 -0
  19. package/docs/assets/fonts/WOFF2/OTF/SourceCodePro-Bold.otf.woff2 +0 -0
  20. package/docs/assets/fonts/WOFF2/OTF/SourceCodePro-Regular.otf.woff2 +0 -0
  21. package/docs/assets/fonts/WOFF2/TTF/SourceCodePro-Bold.ttf.woff2 +0 -0
  22. package/docs/assets/fonts/WOFF2/TTF/SourceCodePro-Regular.ttf.woff2 +0 -0
  23. package/docs/assets/fonts/source-code-pro.css +23 -0
  24. package/docs/assets/github.css +123 -0
  25. package/docs/assets/site.js +168 -0
  26. package/docs/assets/split.css +15 -0
  27. package/docs/assets/split.js +782 -0
  28. package/docs/assets/style.css +147 -0
  29. package/docs/index.html +3636 -0
  30. package/{index.html → docs/playground.html} +48 -12
  31. package/documentation.yml +12 -0
  32. package/lib/terafy.client.js +294 -8
  33. package/lib/terafy.server.js +229 -13
  34. package/package.json +13 -7
  35. package/plugins/vue2.js +185 -0
  36. package/plugins/{vue.js → vue3.js} +35 -13
  37. package/utils/mixin.js +18 -0
@@ -1,5 +1,7 @@
1
- import {cloneDeep} from 'lodash-es';
1
+ import {cloneDeep, set as pathSet} from 'lodash-es';
2
+ import {diffApply, jsonPatchPathConverter as jsPatchConverter} from 'just-diff-apply';
2
3
  import {nanoid} from 'nanoid';
4
+ import mixin from '#utils/mixin';
3
5
 
4
6
  /**
5
7
  * Server-side functions available to the Tera-Fy client library
@@ -15,13 +17,25 @@ export default class TeraFyServer {
15
17
  *
16
18
  * @type {Object}
17
19
  * @property {Boolean} devMode Operate in devMode - i.e. force outer refresh when encountering an existing TeraFy instance
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
18
21
  * @property {String} restrictOrigin URL to restrict communications to
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_*`
19
24
  */
20
25
  settings = {
26
+ devMode: false,
21
27
  restrictOrigin: '*',
28
+ subscribeTimeout: 2000,
29
+ projectId: null,
30
+ serverMode: 0,
22
31
  };
23
32
 
24
- // 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() {{{
25
39
  /**
26
40
  * Create a context based on a shallow copy of this instance + additional functionality for the incoming MessageEvent
27
41
  * This is used by acceptMessage to provide a means to reply / send messages to the originator
@@ -31,10 +45,10 @@ export default class TeraFyServer {
31
45
  * @returns {Object} A context, which is this instance extended with additional properties
32
46
  */
33
47
  createContext(e) {
34
- // Rather ugly shallow-copy-of instancr hack from https://stackoverflow.com/a/44782052/1295040
35
- return Object.assign(Object.create(Object.getPrototypeOf(this)), this, {
48
+ // Construct wrapper for sendRaw for this client
49
+ return mixin(this, {
36
50
  messageEvent: e,
37
- sendRaw(message) { // Override sendRaw because we can't do this inline for security reasons
51
+ sendRaw(message) {
38
52
  let payload;
39
53
  try {
40
54
  payload = {
@@ -43,8 +57,7 @@ export default class TeraFyServer {
43
57
  };
44
58
  e.source.postMessage(payload, this.settings.restrictOrigin);
45
59
  } catch (e) {
46
- this.debug('ERROR', 'Message compose/reply via server->cient:', e);
47
- 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});
48
61
  throw e;
49
62
  }
50
63
  },
@@ -52,6 +65,58 @@ export default class TeraFyServer {
52
65
  }
53
66
 
54
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
+
55
120
  /**
56
121
  * MessageEvent context
57
122
  * Only available if the context was created via `createContext()`
@@ -81,7 +146,7 @@ export default class TeraFyServer {
81
146
  }
82
147
  // }}}
83
148
 
84
- // Messages - handshake(), sendRaw(), acceptMessage(), requestFocus() {{{
149
+ // Messages - handshake(), send(), sendRaw(), setServerMode(), acceptMessage(), requestFocus(), emitClients() {{{
85
150
 
86
151
  /**
87
152
  * Return basic server information as a form of validation
@@ -124,6 +189,7 @@ export default class TeraFyServer {
124
189
 
125
190
  /**
126
191
  * Send raw message content to the client
192
+ * Unlike send() this method does not expect any response
127
193
  *
128
194
  * @param {Object} message Message object to send
129
195
  * @param {Window} Window context to dispatch the message via if its not the same as the regular window
@@ -135,13 +201,31 @@ export default class TeraFyServer {
135
201
  TERA: 1,
136
202
  ...cloneDeep(message), // Need to clone to resolve promise nasties
137
203
  };
138
- this.debug('INFO', 'Parent reply', message, '<=>', payload);
204
+ this.debug('INFO', 'Parent send', message, '<=>', payload);
139
205
  (sendVia || globalThis.parent).postMessage(payload, this.settings.restrictOrigin);
140
206
  } catch (e) {
141
207
  this.debug('ERROR', 'Attempted to dispatch payload server->client', payload);
142
208
  this.debug('ERROR', 'Message compose server->client:', e);
143
209
  }
210
+ }
144
211
 
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
+ }
145
229
  }
146
230
 
147
231
 
@@ -211,6 +295,24 @@ export default class TeraFyServer {
211
295
  .then(()=> cb.call(this))
212
296
  .finally(()=> this.senderRpc('toggleFocus', false))
213
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
+ }
214
316
  // }}}
215
317
 
216
318
  // Session / User - getUser() {{{
@@ -237,7 +339,7 @@ export default class TeraFyServer {
237
339
  $auth.promise(),
238
340
  $subscriptions.promise(),
239
341
  ])
240
- .then(()=> ({
342
+ .then(()=> $auth.user.id ? {
241
343
  id: $auth.user.id,
242
344
  email: $auth.user.email,
243
345
  name: [
@@ -245,7 +347,7 @@ export default class TeraFyServer {
245
347
  $auth.user.family_name,
246
348
  ].filter(Boolean).join(' '),
247
349
  isSubscribed: $subscriptions.isSubscribed,
248
- }))
350
+ } : null)
249
351
  }
250
352
 
251
353
  // }}}
@@ -403,7 +505,7 @@ export default class TeraFyServer {
403
505
 
404
506
  // }}}
405
507
 
406
- // Project State - getProjectState(), applyProjectStatePatch() {{{
508
+ // Project State - getProjectState(), setProjectState(), saveProjectState(), replaceProjectState() {{{
407
509
 
408
510
  /**
409
511
  * Return the current, full snapshot state of the active project
@@ -427,14 +529,128 @@ export default class TeraFyServer {
427
529
  }
428
530
 
429
531
 
532
+ /**
533
+ * Set a nested value within the project state
534
+ * Paths can be any valid Lodash.set() value such as:
535
+ *
536
+ * - Dotted notation - e.g. `foo.bar.1.baz`
537
+ * - Array path segments e.g. `['foo', 'bar', 1, 'baz']`
538
+ *
539
+ *
540
+ * @param {String|Array<String>} path The sub-path within the project state to set
541
+ * @param {*} value The value to set
542
+ *
543
+ * @param {Object} [options] Additional options to mutate behaviour
544
+ * @param {Boolean} [options.save=true] Save the changes to the server immediately, disable to queue up multiple writes
545
+ * @param {Boolean} [options.sync=false] Wait for the server to acknowledge the write, you almost never need to do this
546
+ *
547
+ * @returns {Promise} A promise which resolves when the operation has synced with the server
548
+ */
549
+ setProjectState(path, value, options) {
550
+ let settings = {
551
+ save: true,
552
+ sync: false,
553
+ ...options,
554
+ };
555
+
556
+ if (!app.service('$projects').active) throw new Error('No active project');
557
+
558
+ pathSet(app.service('$projects').active, path, value)
559
+
560
+ return (
561
+ this.save && this.sync ? this.saveProjectState()
562
+ : this.save ? void this.saveProjectState()
563
+ : (()=> { throw new Error('setProjectState({sync: true, save: false}) makes no sense') })()
564
+ );
565
+ }
566
+
567
+
568
+ /**
569
+ * Force-Save the currently active project state
570
+ *
571
+ * @returns {Promise} A promise which resolves when the operation has completed
572
+ */
573
+ saveProjectState() {
574
+ if (!app.service('$projects').active) throw new Error('No active project');
575
+
576
+ // TODO: Would be nice if we compared against a sanity hash or something before just clobbering
577
+ this.debug('FIXME: Force saving projects is not yet supported - this should occur in realtime anyway');
578
+ return Promise.resolve();
579
+ }
580
+
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() {{{
430
600
  /**
431
601
  * Apply a computed `just-diff` patch to the current project state
602
+ *
603
+ * @param {Object} Patch to apply
604
+ * @returns {Promise} A promise which resolves when the operation has completed
432
605
  */
433
606
  applyProjectStatePatch(patch) {
434
- this.debug('Applying sever state patch', {patch});
607
+ if (!app.service('$projects').active) throw new Error('No active project to patch');
608
+ this.debug('Applying', patch.length, 'project state patches', {patch});
609
+ diffApply(app.service('$projects').active, patch, jsPatchConverter);
610
+
611
+ return Promise.resolve();
435
612
  }
436
613
 
437
614
 
615
+ /**
616
+ * Subscribe to project state changes
617
+ * This will dispatch an RPC call to the source object `applyProjectStatePatchLocal()` function with the patch
618
+ * If the above call fails the subscriber is assumed as dead and unsubscribed from the polling list
619
+ *
620
+ * @returns {Promise<Function>} A promise which resolves when a subscription has been created, call the resulting function to unsubscribe
621
+ */
622
+ subscribeProjectState() {
623
+ if (!this.messageEvent) throw new Error('senderRpc() can only be used if given a context from `createContext()`');
624
+
625
+ let subscriber = {
626
+ id: nanoid(),
627
+ origin: this.messageEvent.origin,
628
+ sendPatch: patch => new Promise((resolve, reject) => {
629
+ let senderTimeout = setTimeout(()=> {
630
+ reject(`Timed out sending to project-state subscriber "${subscriber.origin}"`);
631
+ }, this.subscribeTimeout);
632
+
633
+ return this.senderRpc.call(this, 'applyProjectStatePatchLocal', patch)
634
+ .then(()=> {
635
+ clearTimeout(senderTimeout);
636
+ resolve()
637
+ })
638
+ .catch(e => {
639
+ subscriber.unsubscribe();
640
+ reject(`Rejected calling RPC:applyProjectStatePatchLocal() with project-state subscriber "${subscriber.origin}" -`, e)
641
+
642
+ })
643
+ }),
644
+ unsubscribe: ()=> {
645
+ this.debug('Unsubscribing project-state subscriber', subscriber.origin);
646
+ this.projectStateSubscribers = this.projectStateSubscribers.filter(ps => ps.id != subscriber.id);
647
+ },
648
+ };
649
+
650
+ // Append to subscriber list
651
+ this.projectStateSubscribers.push(subscriber)
652
+ }
653
+
438
654
 
439
655
  /**
440
656
  * Subscribers to server project state changes
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "@iebh/tera-fy",
3
- "version": "1.0.9",
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=.",
7
7
  "build": "concurrently 'npm:build:*'",
8
8
  "build:client": "esbuild --platform=browser --format=esm --bundle lib/terafy.client.js --outfile=dist/terafy.js --minify --sourcemap",
9
- "build:docs": "jsdoc2md --files lib/terafy.*.js plugins/*.js >api.md",
9
+ "build:docs:api": "documentation build lib/terafy.client.js --format html --config documentation.yml --output docs/",
10
+ "build:docs:markdown": "documentation build lib/terafy.client.js --format md --markdown-toc --output api.md",
10
11
  "lint": "eslint ."
11
12
  },
12
13
  "type": "module",
13
14
  "imports": {
14
- "#terafy": "./lib/terafy.client.js"
15
+ "#terafy": "./lib/terafy.client.js",
16
+ "#utils/*": "./utils/*.js"
15
17
  },
16
18
  "exports": {
17
19
  ".": {
@@ -59,19 +61,19 @@
59
61
  "node": ">=18"
60
62
  },
61
63
  "peerDependencies": {
62
- "just-diff": "^6.0.2",
63
64
  "lodash-es": "^4.17.21",
64
65
  "nanoid": "^5.0.2"
65
66
  },
66
67
  "optionalDependencies": {
68
+ "just-diff": "^6.0.2",
69
+ "just-diff-apply": "^5.5.0",
67
70
  "vue": "^3.3.7"
68
71
  },
69
72
  "devDependencies": {
70
73
  "@momsfriendlydevco/eslint-config": "^1.0.7",
71
74
  "concurrently": "^8.2.2",
72
- "esbuild": "^0.19.5",
73
- "eslint": "^8.47.0",
74
- "jsdoc-to-markdown": "^8.0.0"
75
+ "documentation": "^14.0.2",
76
+ "esbuild": "^0.19.5"
75
77
  },
76
78
  "eslintConfig": {
77
79
  "extends": "@momsfriendlydevco",
@@ -83,5 +85,9 @@
83
85
  "ecmaVersion": 13,
84
86
  "sourceType": "module"
85
87
  }
88
+ },
89
+ "dependencies": {
90
+ "@momsfriendlydevco/supabase-reactive": "^1.0.7",
91
+ "mitt": "^3.0.1"
86
92
  }
87
93
  }
@@ -0,0 +1,185 @@
1
+ import {cloneDeep} from 'lodash-es';
2
+ import TeraFyPluginBase from './base.js';
3
+ import Vue from 'vue';
4
+
5
+ /**
6
+ * Vue2 observables plugin
7
+ * Provides the `bindProjectState()` function for Vue based projects
8
+ *
9
+ * This function is expected to be included via the `terafy.use(MODULE, OPTIONS)` syntax rather than directly
10
+ *
11
+ * @class TeraFyPluginVue
12
+ *
13
+ * @example Implementation within a Vue2 project `src/main.js`:
14
+ * // Include the main Tera-Fy core
15
+ * import TeraFy from '@iebh/tera-fy';
16
+ * import TerafyVue from '@iebh/tera-fy/plugins/vue2';
17
+ * let terafy = new TeraFy()
18
+ * .set('devMode', true) // Uncomment this line if you want TeraFy to be chatty
19
+ * .set('siteUrl', 'http://localhost:8000/embed') // Uncomment this line if running TERA locally
20
+ * .use(TerafyVue) // Add the Vue plugin
21
+ *
22
+ * // Include after app boot
23
+ * const app = new Vue({ ... })
24
+ * app.$mount("#app");
25
+ * await terafy.init({app});
26
+ */
27
+ export default class TeraFyPluginVue2 extends TeraFyPluginBase {
28
+
29
+ /**
30
+ * Return a Vue Observable object that can be read/written which whose changes will transparently be written back to the TERA server instance
31
+ *
32
+ * @param {Object} [options] Additional options to mutate behaviour
33
+ * @param {VueComponent} [options.component] Component to use to bind $watch events
34
+ * @param {String} [options.componentKey] Key within the component to attach the state. Defaults to a random string
35
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
36
+ * @param {String|Boolean} [options.bindKey='project'] If set, creates the binding also as the specified key within the main Tera object, if falsy just returns the observable
37
+ * @param {Boolean} [options.write=true] Allow local reactivity to writes - send these to the server
38
+ *
39
+ * @returns {Promie<VueObservable<Object>>} A Vue.Observable object representing the project state
40
+ */
41
+ bindProjectState(options) {
42
+ let settings = {
43
+ component: null,
44
+ componentKey: null,
45
+ autoRequire: true,
46
+ write: true,
47
+ ...options,
48
+ };
49
+
50
+ return Promise.resolve()
51
+ .then(()=> Promise.all([
52
+ // Fetch initial state {{{
53
+ this.getProjectState({
54
+ autoRequire: settings.autoRequire,
55
+ }),
56
+ // }}}
57
+ // Allocate component[componentKey] to stash our observable {{{
58
+ (()=> {
59
+ if (settings.componentKey) return; // Already allocated by user
60
+ for (let x = 0; x < 50; x++) {
61
+ let key = `terafy_${x}`;
62
+ if (!Object.hasOwnProperty(settings.component, key)) { // eslint-disable-line
63
+ settings.componentKey = key;
64
+ return;
65
+ }
66
+ }
67
+ throw new Error('Unable to find unique key to allocate against Vue2 component');
68
+ })(),
69
+ // }}}
70
+ ]))
71
+ .then(([snapshot]) => {
72
+ this.debug('Got project snapshot', snapshot);
73
+
74
+ // Create initial Observable
75
+ let stateObservable = Vue.observable(snapshot);
76
+
77
+ // Allocate to component
78
+ settings.component[settings.componentKey] = stateObservable;
79
+
80
+ // Watch for remote changes and update
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
+ }
88
+
89
+ // Watch for local writes and react
90
+ if (settings.write) {
91
+ if (!settings.component) throw new Error('bindProjectState requires a VueComponent specified as `component`');
92
+
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
96
+ let oldVal = cloneDeep(snapshot);
97
+
98
+ settings.component.$watch(
99
+ settings.componentKey,
100
+ newVal => {
101
+ if (skipUpdate > 0) {
102
+ skipUpdate--;
103
+ return;
104
+ }
105
+
106
+ this.createProjectStatePatch(newVal, oldVal);
107
+ oldVal = cloneDeep(snapshot);
108
+ },
109
+ {
110
+ deep: true,
111
+ },
112
+ );
113
+ }
114
+
115
+ // Return Vue Reactive
116
+ return stateObservable;
117
+ })
118
+ }
119
+
120
+
121
+ /**
122
+ * List of available projects for the current session
123
+ * @type {VueReactive<Array<Object>>}
124
+ */
125
+ projects = Vue.observable([]);
126
+
127
+
128
+ /**
129
+ * The bound, reactive state of a Vue project
130
+ * When loaded this represents the state of a project as an object
131
+ * @type {Object}
132
+ */
133
+ state = null;
134
+
135
+
136
+ /**
137
+ * Install into Vue@2
138
+ *
139
+ * @param {Object} [options] Additional options to mutate behaviour (defaults to the main teraFy settings)
140
+ * @param {String} [options.globalName='$tera'] Global property to allocate this service as within Vue2
141
+ * @param {Boolean} [options.subscribeState=true] Setup `vm.$tera.state` as a live binding on init
142
+ * @param {Boolean} [options.subscribeList=true] Setup `vm.$tera.projects` as a list of accesible projects on init
143
+ * @param {Objecct} [options.stateOptions] Options passed to `bindProjectState()` when setting up the main state
144
+ *
145
+ * @returns {Promise} A Promise which will resolve when the init process has completed
146
+ */
147
+ init(options) {
148
+ let settings = {
149
+ globalName: '$tera',
150
+ subscribeState: true,
151
+ subscribeProjects: true,
152
+ stateOptions: {
153
+ write: true,
154
+ },
155
+ ...options,
156
+ };
157
+ if (!this.settings.app) throw new Error('Need to specify the root level Vue2 app during init');
158
+ settings.stateOptions.app = this.settings.app;
159
+
160
+ // Make this module available globally
161
+ if (settings.globalName)
162
+ Vue.prototype[settings.globalName] = this;
163
+
164
+ // Bind `state` to the active project
165
+ // Initialize state to null
166
+ this.state = null;
167
+
168
+ // this.statePromisable becomes the promise we are waiting on to resolve
169
+ return Promise.resolve()
170
+ .then(()=> Promise.all([
171
+ // Bind available project and wait on it
172
+ settings.subscribeState && this.bindProjectState({
173
+ ...settings.stateOptions,
174
+ component: this.settings.app.$root,
175
+ })
176
+ .then(state => this.state = state)
177
+ .then(()=> this.debug('INFO', 'Loaded project state', this.state)),
178
+
179
+ // Fetch available projects
180
+ settings.subscribeProjects && this.getProjects()
181
+ .then(projects => this.projects = Vue.observable(projects))
182
+ .then(()=> this.debug('INFO', 'Loaded projects', this.projects)),
183
+ ]))
184
+ }
185
+ }
@@ -1,5 +1,4 @@
1
1
  import TeraFyPluginBase from './base.js';
2
- import {diff} from 'just-diff';
3
2
  import {reactive, watch} from 'vue';
4
3
 
5
4
  /**
@@ -9,6 +8,20 @@ import {reactive, watch} from 'vue';
9
8
  * This function is expected to be included via the `terafy.use(MODULE, OPTIONS)` syntax rather than directly
10
9
  *
11
10
  * @class TeraFyPluginVue
11
+ *
12
+ * @example Implementation within a Vue3 / Vite project within `src/main.js`:
13
+ * import TeraFy from '@iebh/tera-fy';
14
+ * import TerafyVue from '@iebh/tera-fy/plugins/vue';
15
+ * let terafy = new TeraFy()
16
+ * .set('devMode', import.meta.env.DEV)
17
+ * .set('siteUrl', 'http://localhost:8000/embed') // Uncomment this line if running TERA locally
18
+ * .use(TerafyVue) // Add the Vue plugin
19
+ *
20
+ * terafy.init(); // Initialize everything
21
+ *
22
+ * app.use(terafy.vuePlugin({
23
+ * globalName: '$tera', // Install as vm.$tera into every component
24
+ * }));
12
25
  */
13
26
  export default class TeraFyPluginVue extends TeraFyPluginBase {
14
27
 
@@ -17,41 +30,49 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
17
30
  *
18
31
  * @param {Object} [options] Additional options to mutate behaviour
19
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
20
34
  * @param {Boolean} [options.write=true] Allow local reactivity to writes - send these to the server
21
- * @param {Array<String>} Paths to subscribe to e.g. ['/users/'],
22
35
  *
23
36
  * @returns {Promie<Reactive<Object>>} A reactive object representing the project state
24
37
  */
25
38
  bindProjectState(options) {
26
39
  let settings = {
27
40
  autoRequire: true,
41
+ read: true,
28
42
  write: true,
29
43
  ...options,
30
44
  };
31
45
 
32
46
  return Promise.resolve()
33
- .then(()=> settings.autoRequire && this.requireProject())
34
47
  .then(()=> this.getProjectState({
35
- autoRequire: false, // already handled this
36
- paths: settings.paths,
48
+ autoRequire: settings.autoRequire ,
37
49
  }))
38
50
  .then(snapshot => {
39
- this.debug('Got project snapshot', snapshot);
51
+ this.debug('Fetched project snapshot', snapshot);
40
52
 
41
53
  // Create initial reactive
42
54
  let stateReactive = reactive(snapshot);
43
55
 
44
56
  // Watch for remote changes and update
45
- // 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
+ }
46
64
 
47
65
  // Watch for local writes and react
48
66
  if (settings.write) {
49
67
  watch(
50
68
  stateReactive,
51
69
  (newVal, oldVal) => {
52
- let diff = diff(newVal, oldVal);
53
- this.debug('APPLY DIFF', diff);
54
- this.applyProjectStatePatch(diff);
70
+ if (skipUpdate > 0) {
71
+ skipUpdate--;
72
+ return;
73
+ }
74
+
75
+ this.createProojectStatePatch(newVal, oldVal);
55
76
  },
56
77
  {
57
78
  deep: true,
@@ -121,6 +142,8 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
121
142
  let settings = {
122
143
  autoInit: true,
123
144
  globalName: '$tera',
145
+ subscribeState: true,
146
+ subscribeProjects: true,
124
147
  stateOptions: {
125
148
  write: true,
126
149
  },
@@ -136,13 +159,12 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
136
159
  .then(()=> settings.autoInit && $tera.init())
137
160
  .then(()=> Promise.all([
138
161
  // Bind available project and wait on it
139
- $tera.bindProjectState(settings.stateOptions)
162
+ settings.subscribeState && $tera.bindProjectState(settings.stateOptions)
140
163
  .then(state => $tera.state = state)
141
164
  .then(()=> $tera.debug('INFO', 'Loaded project state', $tera.state)),
142
165
 
143
166
  // Fetch available projects
144
- // TODO: It would be nice if this was responsive to remote changes
145
- $tera.getProjects()
167
+ settings.subscribeProjects && $tera.getProjects()
146
168
  .then(projects => $tera.projects = reactive(projects))
147
169
  .then(()=> $tera.debug('INFO', 'Loaded projects', $tera.projects)),
148
170
  ]))