@iebh/tera-fy 1.15.9 → 2.0.1

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.
@@ -105,6 +105,7 @@ export default class TeraFy {
105
105
  // Session
106
106
  'getUser',
107
107
  'requireUser',
108
+ 'getCredentials',
108
109
 
109
110
  // Projects
110
111
  'bindProject',
@@ -114,11 +115,17 @@ export default class TeraFy {
114
115
  'requireProject',
115
116
  'selectProject',
116
117
 
118
+ // Project namespaces
119
+ // 'mountNamespace', // Handled by this library
120
+ // 'unmountNamespace', // Handled by this library
121
+ 'getNamespace',
122
+ 'setNamespace',
123
+ 'listNamespaces',
124
+
117
125
  // Project State
118
126
  'getProjectState',
119
127
  'setProjectState',
120
128
  'setProjectStateDefaults',
121
- 'setProjectStateFlush',
122
129
  'setProjectStateRefresh',
123
130
  'saveProjectState',
124
131
  'replaceProjectState',
@@ -164,6 +171,16 @@ export default class TeraFy {
164
171
  plugins = [];
165
172
 
166
173
 
174
+ /**
175
+ * Active namespaces we are subscribed to
176
+ * Each key is the namespace name with the value as the local reactive \ observer \ object equivelent
177
+ * The key string is always of the form `${ENTITY}::${ID}` e.g. `projects:1234`
178
+ *
179
+ * @type {Object<Object>}
180
+ */
181
+ namespaces = {};
182
+
183
+
167
184
  // Messages - send(), sendRaw(), rpc(), acceptMessage() {{{
168
185
 
169
186
  /**
@@ -303,6 +320,69 @@ export default class TeraFy {
303
320
 
304
321
  // }}}
305
322
 
323
+ // Project namespace - mountNamespace(), unmountNamespace() {{{
324
+ /**
325
+ * Make a namespace available locally
326
+ * This generally creates whatever framework flavoured reactive/observer/object is supported locally - generally with writes automatically synced with the master state
327
+ *
328
+ * @function mountNamespace
329
+ * @param {String} name The alias of the namespace, this should be alphanumeric + hyphens + underscores
330
+ *
331
+ * @returns {Promise<Reactive>} A promise which resolves to the reactive object
332
+ */
333
+ mountNamespace(name) {
334
+ if (!/^[\w-]+$/.test(name)) throw new Error('Namespaces must be alphanumeric + hyphens + underscores');
335
+ if (this.namespaces[name]) return Promise.resolve(this.namespaces[name]); // Already mounted
336
+
337
+ return Promise.resolve()
338
+ .then(()=> this._mountNamespace(name))
339
+ .then(()=> this.namespaces[name] || Promise.reject(`teraFy.mountNamespace('${name}') resolved but no namespace has been mounted`))
340
+ }
341
+
342
+
343
+ /**
344
+ * @interface
345
+ * Actual namespace mounting function designed to be overriden by plugins
346
+ *
347
+ * @param {String} name The alias of the namespace, this should be alphanumeric + hyphens + underscores
348
+ *
349
+ * @returns {Promise} A promise which resolves when the mount operation has completed
350
+ */
351
+ _mountNamespace(name) {
352
+ console.warn('teraFy._mountNamespace() has not been overriden by a TERA-fy plugin, load one to add this functionality for your preferred framework');
353
+ throw new Error('teraFy._mountNamespace() is not supported');
354
+ }
355
+
356
+
357
+ /**
358
+ * Release a locally mounted namespace
359
+ * This function will remove the namespace from `namespaces`, cleaning up any memory / subscription hooks
360
+ *
361
+ * @function unmountNamespace
362
+ *
363
+ * @param {String} name The name of the namespace to unmount
364
+ *
365
+ * @returns {Promise} A promise which resolves when the operation has completed
366
+ */
367
+ unmountNamespace(name) {
368
+ if (!this.namespaces[name]) return Promise.resolve(); // Already unmounted
369
+ return this._unmountNamespace(name);
370
+ }
371
+
372
+
373
+ /**
374
+ * @interface
375
+ * Actual namespace unmounting function designed to be overriden by plugins
376
+ *
377
+ * @param {String} name The name of the namespace to unmount
378
+ *
379
+ * @returns {Promise} A promise which resolves when the operation has completed
380
+ */
381
+ _unmountNamespace(name) {
382
+ console.warn('teraFy.unbindNamespace() has not been overriden by a TERA-fy plugin, load one to add this functionality for your preferred framework');
383
+ }
384
+ // }}}
385
+
306
386
  // Project state - createProjectStatePatch(), applyProjectStatePatchLocal() {{{
307
387
  /**
308
388
  * Create + transmit a new project state patch base on the current and previous states
@@ -838,19 +918,35 @@ export default class TeraFy {
838
918
  * @param {Object} source Initalized source object to extend from
839
919
  */
840
920
  mixin(target, source) {
841
- Object.getOwnPropertyNames(Object.getPrototypeOf(source))
842
- .filter(prop => !['constructor', 'prototype', 'name'].includes(prop))
843
- .filter(prop => prop != 'init') // Don't allow plugin init() to override our version - these all get called during init() anyway
844
- .forEach((prop) => {
845
- Object.defineProperty(
846
- target,
847
- prop,
848
- {
849
- value: source[prop].bind(target),
850
- enumerable: false,
851
- },
852
- );
853
- });
921
+ // Iterate through the source object upwards extracting each prototype
922
+ let prototypeStack = [];
923
+ let node = source;
924
+ do {
925
+ prototypeStack.unshift(node);
926
+ } while (node = Object.getPrototypeOf(node)); // Walk upwards until we hit null (no more inherited classes)
927
+
928
+ // Iterate through stacks inheriting each prop into the target
929
+ prototypeStack.forEach(stack =>
930
+ Object.getOwnPropertyNames(stack)
931
+ .filter(prop =>
932
+ !['constructor', 'init', 'prototype', 'name'].includes(prop) // Ignore forbidden properties
933
+ && !prop.startsWith('__') // Ignore double underscore meta properties
934
+ )
935
+ .forEach(prop => {
936
+ if (typeof source[prop] == 'function') { // Inheriting function - glue onto object as non-editable, non-enumerable property
937
+ Object.defineProperty(
938
+ target,
939
+ prop,
940
+ {
941
+ enumerable: false,
942
+ value: source[prop].bind(target), // Rebind functions
943
+ },
944
+ );
945
+ } else { // Everything else, just glue onto the object
946
+ target[prop] = source[prop];
947
+ }
948
+ })
949
+ )
854
950
  }
855
951
 
856
952
 
@@ -955,6 +1051,14 @@ export default class TeraFy {
955
1051
  */
956
1052
 
957
1053
 
1054
+ /**
1055
+ * Provide an object of credentials for 3rd party services like Firebase/Supabase
1056
+ *
1057
+ * @function getCredentials
1058
+ * @returns {Object} An object containing 3rd party service credentials
1059
+ */
1060
+
1061
+
958
1062
  /**
959
1063
  * Require a user login to TERA
960
1064
  * If there is no user OR they are not logged in a prompt is shown to go and do so
@@ -1038,6 +1142,40 @@ export default class TeraFy {
1038
1142
  */
1039
1143
 
1040
1144
 
1145
+ /**
1146
+ * Get a one-off snapshot of a namespace without mounting it
1147
+ * This can be used for simpler apps which don't have their own reactive / observer equivelent
1148
+ *
1149
+ * @function getNamespace
1150
+ * @param {String} name The alias of the namespace, this should be alphanumeric + hyphens + underscores
1151
+ *
1152
+ * @returns {Promise<Object>} A promise which resolves to the namespace POJO state
1153
+ */
1154
+
1155
+
1156
+ /**
1157
+ * Set (or merge by default) a one-off snapshot over an existing namespace
1158
+ * This can be used for simpler apps which don't have their own reactive / observer equivelent and just want to quickly set something
1159
+ *
1160
+ * @function setNamespace
1161
+ * @param {String} name The name of the namespace
1162
+ * @param {Object} state The state to merge
1163
+ * @param {Object} [options] Additional options to mutate behaviour
1164
+ * @param {'merge'|'set'} [options.method='merge'] How to handle the state. 'merge' (merge a partial state over the existing namespace state), 'set' (completely overwrite the existing namespace)
1165
+ *
1166
+ * @returns {Promise<Object>} A promise which resolves to the namespace POJO state
1167
+ */
1168
+
1169
+
1170
+ /**
1171
+ * Return a list of namespaces available to the current project
1172
+ *
1173
+ * @function listNamespaces
1174
+ * @returns {Promise<Array<Object>>} Collection of available namespaces for the current project
1175
+ * @property {String} name The name of the namespace
1176
+ */
1177
+
1178
+
1041
1179
  /**
1042
1180
  * Return the current, full snapshot state of the active project
1043
1181
  *
@@ -1081,15 +1219,6 @@ export default class TeraFy {
1081
1219
  */
1082
1220
 
1083
1221
 
1084
- /**
1085
- * Force copying local changes to the server
1086
- * This is only ever needed when saving large quantities of data that need to be immediately available
1087
- *
1088
- * @function setProjectStateFlush
1089
- * @returns {Promise} A promise which resolves when the operation has completed
1090
- */
1091
-
1092
-
1093
1222
  /**
1094
1223
  * Force refetching the remote project state into local
1095
1224
  * This is only ever needed when saving large quantities of data that need to be immediately available
@@ -1428,9 +1557,9 @@ export default class TeraFy {
1428
1557
  *
1429
1558
  * @param {Object} [options] Additional options to mutate behaviour
1430
1559
  * @param {String} [options.body] Optional additional body text
1560
+ * @param {Boolean} [options.isHtml=false] If truthy, treat the body as HTML
1431
1561
  * @param {String} [options.value] Current or default value to display pre-filled
1432
1562
  * @param {String} [options.title='Input required'] The dialog title to display
1433
- * @param {Boolean} [options.bodyHtml=false] If truthy, treat the body as HTML
1434
1563
  * @param {String} [options.placeholder] Optional placeholder text
1435
1564
  * @param {Boolean} [options.required=true] Treat nullish or empty inputs as a cancel operation
1436
1565
  *
@@ -469,6 +469,16 @@ export default class TeraFyServer {
469
469
  }
470
470
 
471
471
 
472
+ /**
473
+ * Provide an object of credentials for 3rd party services like Firebase/Supabase
474
+ *
475
+ * @returns {Object} An object containing 3rd party service credentials
476
+ */
477
+ getCredentials() {
478
+ return app.service('$auth').credentials;
479
+ }
480
+
481
+
472
482
  /**
473
483
  * In embed mode only - create a popup window and try to auth via that
474
484
  *
@@ -736,6 +746,54 @@ export default class TeraFyServer {
736
746
 
737
747
  // }}}
738
748
 
749
+ // Project namespaces - getNamespace(), setNamespace(), listNamespaces() {{{
750
+ /**
751
+ * Get a one-off snapshot of a namespace without mounting it
752
+ * This can be used for simpler apps which don't have their own reactive / observer equivelent
753
+ *
754
+ * @param {String} name The alias of the namespace, this should be alphanumeric + hyphens + underscores
755
+ *
756
+ * @returns {Promise<Object>} A promise which resolves to the namespace POJO state
757
+ */
758
+ getNamespace(name) {
759
+ if (!/^[\w-]+$/.test(name)) throw new Error('Namespaces must be alphanumeric + hyphens + underscores');
760
+
761
+ return this.$syncro.getSnapshot(`project_namespaces::${this.$projects.active.id}::${name}`);
762
+ }
763
+
764
+
765
+ /**
766
+ * Set (or merge by default) a one-off snapshot over an existing namespace
767
+ * This can be used for simpler apps which don't have their own reactive / observer equivelent and just want to quickly set something
768
+ *
769
+ * @param {String} name The name of the namespace
770
+ * @param {Object} state The state to merge
771
+ * @param {Object} [options] Additional options to mutate behaviour
772
+ * @param {'merge'|'set'} [options.method='merge'] How to handle the state. 'merge' (merge a partial state over the existing namespace state), 'set' (completely overwrite the existing namespace)
773
+ *
774
+ * @returns {Promise<Object>} A promise which resolves to the namespace POJO state
775
+ */
776
+ setNamespace(name, state, options) {
777
+ if (!/^[\w--]+$/.test(name)) throw new Error('Namespaces must be alphanumeric + hyphens + underscores');
778
+ if (typeof state != 'object') throw new Error('State must be an object');
779
+
780
+ return this.$syncro.setSnapshot(`project_namespaces::${this.$projects.active.id}}::${name}`, state, {
781
+ method: options.method,
782
+ });
783
+ }
784
+
785
+
786
+ /**
787
+ * Return a list of namespaces available to the current project
788
+ *
789
+ * @returns {Promise<Array<Object>>} Collection of available namespaces for the current project
790
+ * @property {String} name The name of the namespace
791
+ */
792
+ listNamespaces() {
793
+ return app.service('$projects').listNamespaces();
794
+ }
795
+ // }}}
796
+
739
797
  // Project State - getProjectState(), setProjectState(), setProjectStateDefaults(), replaceProjectState(), applyProjectStatePatch() {{{
740
798
 
741
799
  /**
@@ -819,13 +877,11 @@ export default class TeraFyServer {
819
877
  * @param {String|Array<String>} [path] The sub-path within the project state to set, if unspecifed the entire target is used as a target and a save operation is forced
820
878
  * @param {*} value The value to set as the default structure
821
879
  * @param {Object} [options] Additional options to mutate behaviour, see setProjectState() for the full list of supported options
822
- * @param {Boolean} [options.flush=true] Force a re-read from the server after setting, prevents race conditions with large objects. Calls `setProjectStateFlush()` after applying defaults
823
880
  *
824
881
  * @returns {Promise<*>} A promise which resolves to the eventual input value after defaults have been applied
825
882
  */
826
883
  setProjectStateDefaults(path, value, options) {
827
884
  let settings = {
828
- flush: true,
829
885
  ...options,
830
886
  };
831
887
  if (!app.service('$projects').active) throw new Error('No active project');
@@ -841,7 +897,6 @@ export default class TeraFyServer {
841
897
  ...settings,
842
898
  },
843
899
  )
844
- .then(()=> settings.flush && this.setProjectStateFlush())
845
900
  .then(()=> pathTools.get(target, path));
846
901
  } else { // Called as (value) - Populate entire project layout
847
902
  pathTools.defaults(target, path);
@@ -850,26 +905,11 @@ export default class TeraFyServer {
850
905
  newState: cloneDeep(target),
851
906
  });
852
907
  return this.saveProjectState()
853
- .then(()=> settings.flush && this.setProjectStateFlush())
854
908
  .then(()=> target);
855
909
  }
856
910
  }
857
911
 
858
912
 
859
- /**
860
- * Force copying local changes to the server
861
- * This is only ever needed when saving large quantities of data that need to be immediately available
862
- *
863
- * @returns {Promise} A promise which resolves when the operation has completed
864
- */
865
- setProjectStateFlush() {
866
- this.debug('INFO', 1, 'Force project state flush!');
867
- if (!app.service('$projects').active) throw new Error('No active project');
868
- return app.service('$projects').active.$touchLocal()
869
- .then(()=> null)
870
- }
871
-
872
-
873
913
  /**
874
914
  * Force refetching the remote project state into local
875
915
  *
@@ -1644,9 +1684,9 @@ export default class TeraFyServer {
1644
1684
  *
1645
1685
  * @param {Object} [options] Additional options to mutate behaviour
1646
1686
  * @param {String} [options.body] Optional additional body text
1687
+ * @param {Boolean} [options.isHtml=false] If truthy, treat the body as HTML
1647
1688
  * @param {String} [options.value] Current or default value to display pre-filled
1648
1689
  * @param {String} [options.title='Input required'] The dialog title to display
1649
- * @param {Boolean} [options.bodyHtml=false] If truthy, treat the body as HTML
1650
1690
  * @param {String} [options.placeholder] Optional placeholder text
1651
1691
  * @param {Boolean} [options.required=true] Treat nullish or empty inputs as a cancel operation
1652
1692
  *
@@ -1655,7 +1695,7 @@ export default class TeraFyServer {
1655
1695
  uiPrompt(text, options) {
1656
1696
  let settings = {
1657
1697
  body: '',
1658
- bodyHtml: false,
1698
+ isHtml: false,
1659
1699
  title: 'Input required',
1660
1700
  value: '',
1661
1701
  placeholder: '',
@@ -1674,7 +1714,7 @@ export default class TeraFyServer {
1674
1714
  component: 'UiPrompt',
1675
1715
  componentProps: {
1676
1716
  body: settings.body,
1677
- bodyHtml: settings.bodyHtml,
1717
+ isHtml: settings.isHtml,
1678
1718
  placeholder: settings.placeholder,
1679
1719
  value: settings.value,
1680
1720
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iebh/tera-fy",
3
- "version": "1.15.9",
3
+ "version": "2.0.1",
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 --serve --servedir=.",
@@ -33,6 +33,7 @@
33
33
  "./projectFile": "./lib/projectFile.js",
34
34
  "./proxy": "./lib/terafy.proxy.js",
35
35
  "./server": "./lib/terafy.server.js",
36
+ "./syncro": "./lib/syncro.js",
36
37
  "./plugins/*": "./plugins/*.js",
37
38
  "./widgets/*": "./widgets/*"
38
39
  },
@@ -72,6 +73,7 @@
72
73
  "node": ">=18"
73
74
  },
74
75
  "dependencies": {
76
+ "@momsfriendlydevco/marshal": "^2.1.2",
75
77
  "detect-port": "^1.6.1",
76
78
  "filesize": "^10.1.4",
77
79
  "http-proxy": "^1.18.1",
@@ -91,6 +93,8 @@
91
93
  "nodemon": "^3.1.7"
92
94
  },
93
95
  "peerDependencies": {
96
+ "@supabase/supabase-js": "^2.48.1",
97
+ "firebase": "^11.3.1",
94
98
  "vue": "^3.0.0"
95
99
  },
96
100
  "optionalDependencies": {
@@ -0,0 +1,122 @@
1
+ import {initializeApp as Firebase} from 'firebase/app';
2
+ import {getFirestore as Firestore} from 'firebase/firestore';
3
+ import {createClient as Supabase} from '@supabase/supabase-js'
4
+ import Syncro from '../lib/syncro.js';
5
+ import TeraFyPluginBase from './base.js';
6
+
7
+ /**
8
+ * Plugin which adds Firebase / Firestore support for namespace mounts
9
+ *
10
+ * @class TeraFyPluginFirebase
11
+ */
12
+ export default class TeraFyPluginFirebase extends TeraFyPluginBase {
13
+
14
+ /**
15
+ * Lookup object of mounted Syncro objects by path
16
+ *
17
+ * @type {Object<Syncro>}
18
+ */
19
+ syncros = {};
20
+
21
+
22
+ /**
23
+ * @interface
24
+ * The Syncro#reactive option to use when creating new Syncro instances
25
+ * This is expected to be overriden by other plugins
26
+ * If falsy the Syncro module will fall back to its internal (POJO only) getReactive() function
27
+ *
28
+ * @name getReactive
29
+ * @type {Function} A reactive function as defined in Syncro
30
+ */
31
+
32
+
33
+ /**
34
+ * Setup Firebase + Firestore + Supabase
35
+ * Default credentials (Firebase + Supabase) will be retrieved from `getCredentials()` unless overriden here
36
+ *
37
+ * @param {Object} options Additional options to mutate behaviour (defaults to the main teraFy settings)
38
+ * @param {String} [options.firebaseApiKey] Firebase API key
39
+ * @param {String} [options.firebaseAuthDomain] Firebase authorized domain
40
+ * @param {String} [options.firebaseProjectId] Firebase project ID
41
+ * @param {String} [options.firebaseAppId] Firebase App ID
42
+ * @param {String} [options.supabaseUrl] Supabase URL
43
+ * @param {String} [options.supabaseKey] Supabase client key
44
+ *
45
+ * @returns {Promise} A Promise which will resolve when the init process has completed
46
+ */
47
+ async init(options) {
48
+ let settings = {
49
+ firebaseApiKey: null,
50
+ firebaseAuthDomain: null,
51
+ firebaseProjectId: null,
52
+ firebaseAppId: null,
53
+ supabaseUrl: null,
54
+ supabaseKey: null,
55
+ ...await this.getCredentials(),
56
+ ...options,
57
+ };
58
+
59
+ let emptyValues = Object.keys(settings).filter(k => k === null);
60
+ if (emptyValues.length > 0)
61
+ throw new Error('Firebase plugin requires mandatory options: ' + emptyValues.join(', '));
62
+
63
+ Syncro.firebase = Firebase({
64
+ apiKey: settings.firebaseApiKey,
65
+ authDomain: settings.firebaseAuthDomain,
66
+ projectId: settings.firebaseProjectId,
67
+ appId: settings.firebaseAppId,
68
+ });
69
+ Syncro.firestore = Firestore(this.firebase);
70
+
71
+ Syncro.supabase = Supabase(settings.supabaseUrl, settings.supabaseKey);
72
+ }
73
+
74
+
75
+ /**
76
+ * Mount the given namespace against `namespaces[name]`
77
+ *
78
+ * @param {'_PROJECT'|String} name The name/Syncro path of the namespace to mount (or '_PROJECT' for the project mountpoint)
79
+ *
80
+ * @returns {Promise} A promise which resolves when the operation has completed
81
+ */
82
+ _mountNamespace(name) {
83
+ let syncro; // The eventually bootstrapped Syncro object
84
+
85
+ return Promise.resolve()
86
+ .then(()=> this.requireProject())
87
+ .then(project => {
88
+ let path = name == '_PROJECT'
89
+ ? `projects::${project.id}`
90
+ : `project_namespaces::${project.id}::${name}`;
91
+
92
+ syncro = this.syncros[name] = new Syncro(path, {
93
+ debug: (...msg) => this.debug(`SYNCRO://${path}`, ...msg),
94
+ getReactive: this.getReactive, // Try to inherit this instances getReactive prop, otherwise Syncro will fall back to its default
95
+ });
96
+
97
+ // Perform the mount action
98
+ return syncro.mount();
99
+ })
100
+ .then(()=> { // Assign local state
101
+ this.namespaces[name] = syncro.value;
102
+ })
103
+ }
104
+
105
+
106
+ /**
107
+ * Unmount the given namespace from `namespaces[name]`
108
+ *
109
+ * @param {String} name The name/Syncro path of the namespace to unmount
110
+ *
111
+ * @returns {Promise} A promise which resolves when the operation has completed
112
+ */
113
+ _unmountNamespace(name) {
114
+ let syncro = this.syncros[name]; // Create local alias for Syncro before we detach it
115
+
116
+ // Detach local state
117
+ delete this.namespaces[name];
118
+ delete this.syncros[name];
119
+
120
+ return syncro.destroy(); // Trigger Syncro distruction
121
+ }
122
+ }