@iebh/tera-fy 1.0.1 → 1.0.2

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,7 +1,10 @@
1
+ import {cloneDeep} from 'lodash-es';
1
2
  import {nanoid} from 'nanoid';
2
3
  import {reactive, watch} from 'vue';
3
4
  import diff from 'just-diff';
4
5
 
6
+ /* globals globalThis */
7
+
5
8
 
6
9
  /**
7
10
  * Main Tera-Fy Client (class singleton) to be used in a frontend browser
@@ -46,14 +49,14 @@ export default class TeraFy {
46
49
  * @type {Array<String>}
47
50
  */
48
51
  methods = [
49
- // Basics
52
+ // Messages
50
53
  'handshake',
51
54
 
52
55
  // Session
53
56
  'getUser',
54
57
 
55
58
  // Projects
56
- 'bindProject', 'getProject', 'getProjects', 'requireProject', 'selectProject',
59
+ 'bindProject', 'getProject', 'getProjects', 'setActiveProject', 'requireProject', 'selectProject',
57
60
 
58
61
  // Project state
59
62
  'getProjectStateSnapshot', 'applyProjectStatePatch',
@@ -101,7 +104,7 @@ export default class TeraFy {
101
104
  {
102
105
  TERA: 1,
103
106
  id: message.id || nanoid(),
104
- ...message,
107
+ ...cloneDeep(message), // Need to clone to resolve promise nasties
105
108
  },
106
109
  this.settings.restrictOrigin
107
110
  );
@@ -113,6 +116,7 @@ export default class TeraFy {
113
116
  *
114
117
  * @param {String} method The method name to call
115
118
  * @param {*} [...] Optional arguments to pass to the function
119
+ *
116
120
  * @returns {Promise<*>} The resolved output of the server function
117
121
  */
118
122
  rpc(method, ...args) {
@@ -131,18 +135,36 @@ export default class TeraFy {
131
135
  */
132
136
  acceptMessage(rawMessage) {
133
137
  let message = rawMessage.data;
134
- if (!message.TERA) return; // Ignore non-TERA signed messages
138
+ if (!message.TERA || !message.id) return; // Ignore non-TERA signed messages
139
+ this.debug('Recieved', message);
135
140
 
136
- if (message?.id && this.acceptPostboxes[message.id]) { // Postbox waiting for reply
141
+ if (message?.action == 'response' && this.acceptPostboxes[message.id]) { // Postbox waiting for reply
137
142
  if (message.isError === true) {
138
143
  this.acceptPostboxes[message.id].reject(message.response);
139
144
  } else {
140
145
  this.acceptPostboxes[message.id].resolve(message.response);
141
146
  }
147
+ } else if (message?.action == 'rpc') {
148
+ return Promise.resolve()
149
+ .then(()=> this[message.method].apply(this, message.args))
150
+ .then(res => this.sendRaw({
151
+ id: message.id,
152
+ action: 'response',
153
+ response: res,
154
+ }))
155
+ .catch(e => {
156
+ console.warn(`TERA-FY client threw on RPC:${message.method}:`, e);
157
+ this.sendRaw({
158
+ id: message.id,
159
+ action: 'response',
160
+ isError: true,
161
+ response: e.toString(),
162
+ });
163
+ })
142
164
  } else if (message?.id) {
143
- console.info(`Ignoring message ID ${message.id} - was meant for someone else?`);
165
+ this.debug(`Ignoring message ID ${message.id} - was meant for someone else?`);
144
166
  } else {
145
- console.log('Unexpected incoming TERA-FY CLIENT message', {message});
167
+ this.debug('Unexpected incoming TERA-FY CLIENT message', {message});
146
168
  }
147
169
  }
148
170
 
@@ -209,7 +231,7 @@ export default class TeraFy {
209
231
  // Queue up event chain when document loads
210
232
  this.dom.iframe.setAttribute('sandbox', 'allow-downloads allow-scripts allow-same-origin');
211
233
  this.dom.iframe.addEventListener('load', ()=> {
212
- console.log('TERA EMBED FRAME READY');
234
+ this.debug('TERA EMBED FRAME READY');
213
235
  });
214
236
 
215
237
  // Start document load sequence + append to DOM
@@ -250,7 +272,7 @@ export default class TeraFy {
250
272
  '}',
251
273
 
252
274
  // Fullscreen functionality {{{
253
- 'body.tera-fy-fullscreen {',
275
+ 'body.tera-fy-focus {',
254
276
  'overflow: hidden;',
255
277
 
256
278
  '& #tera-fy {',
@@ -279,7 +301,23 @@ export default class TeraFy {
279
301
  }
280
302
  // }}}
281
303
 
282
- // Client unique functions - bindProjectState(), toggleFullscreen() {{{
304
+ // Utility - debug(), bindProjectState(), toggleFullscreen() {{{
305
+
306
+ /**
307
+ * Debugging output function
308
+ * This function will only act if `settings.devMode` is truthy
309
+ *
310
+ * @param {String} [msg...] Output to show
311
+ */
312
+ debug(...msg) {
313
+ if (!this.settings.devMode) return;
314
+ console.log(
315
+ '%c[TERA-FY CLIENT]',
316
+ 'font-weight: bold; color: #ff5722;',
317
+ ...msg,
318
+ );
319
+ }
320
+
283
321
 
284
322
  /**
285
323
  * Return a Vue reactive object that can be read/written which whose changes will transparently be written back to the TERA server instance
@@ -317,7 +355,7 @@ export default class TeraFy {
317
355
  stateReactive,
318
356
  (newVal, oldVal) => {
319
357
  let diff = diff(newVal, oldVal);
320
- console.log('DEBUG APPLY DIFF', diff);
358
+ this.debug('APPLY DIFF', diff);
321
359
  this.applyProjectStatePatch(diff);
322
360
  },
323
361
  {
@@ -333,12 +371,13 @@ export default class TeraFy {
333
371
 
334
372
 
335
373
  /**
336
- * Fit the nested TERA server to a full-screen context
374
+ * Fit the nested TERA server to a full-screen
337
375
  * This is usually because the server component wants to perform some user activity like calling $prompt
338
- * @param {String|Boolean} [isFullscreen='toggle'] Whether to fullscreen the embedded component
376
+ * @param {String|Boolean} [isFocused='toggle'] Whether to fullscreen the embedded component
339
377
  */
340
- toggleFullscreen(isFullscreen) {
341
- globalThis.document.body.classList.toggle('tera-fy-fullscreen', isFullscreen === 'toggle' ? undefined : isFullscreen);
378
+ toggleFocus(isFocused = 'toggle') {
379
+ this.debug('Request focus', {isFocused});
380
+ globalThis.document.body.classList.toggle('tera-fy-focus', isFocused === 'toggle' ? undefined : isFocused);
342
381
  }
343
382
 
344
383
  // }}}
@@ -1,3 +1,6 @@
1
+ import {cloneDeep} from 'lodash-es';
2
+ import {nanoid} from 'nanoid';
3
+
1
4
  /**
2
5
  * Server-side functions available to the Tera-Fy client library
3
6
  *
@@ -18,7 +21,102 @@ export default class TeraFyServer {
18
21
  restrictOrigin: '*',
19
22
  };
20
23
 
21
- // Messages - acceptMessage() {{{
24
+ // Contexts - createContext(), messageEvent, senderRpc() {{{
25
+ /**
26
+ * Create a context based on a shallow copy of this instance + additional functionality for the incoming MessageEvent
27
+ * This is used by acceptMessage to provide a means to reply / send messages to the originator
28
+ *
29
+ * @param {MessageEvent} e Original message event to base the new context on
30
+ *
31
+ * @returns {Object} A context, which is this instance extended with additional properties
32
+ */
33
+ createContext(e) {
34
+ // Rather ugly shallow-copy-of instance hack from https://stackoverflow.com/a/44782052/1295040
35
+ return Object.assign(Object.create(Object.getPrototypeOf(this)), this, {
36
+ messageEvent: e,
37
+ sendRaw(message) { // Override sendRaw because we can't do this inline for security reasons
38
+ this.debug('Send to message source', e.origin, {message});
39
+ e.source.postMessage(
40
+ {
41
+ TERA: 1,
42
+ ...cloneDeep(message), // Need to clone to resolve promise nasties
43
+ },
44
+ this.settings.restrictOrigin
45
+ );
46
+ },
47
+ });
48
+ }
49
+
50
+
51
+ /**
52
+ * MessageEvent context
53
+ * Only available if the context was created via `createContext()`
54
+ *
55
+ * @type {MessageEvent}
56
+ */
57
+ messageEvent = null;
58
+
59
+
60
+ /**
61
+ * Request an RPC call from the original sender of a mesasge
62
+ * This function only works if the context was sub-classed via `createContext()`
63
+ *
64
+ * @param {String} method The method name to call
65
+ * @param {*} [...] Optional arguments to pass to the function
66
+ *
67
+ * @returns {Promise<*>} The resolved output of the server function
68
+ */
69
+ senderRpc(method, ...args) {
70
+ if (!this.messageEvent) throw new Error('senderRpc() can only be used if given a context from `createContext()`');
71
+
72
+ return this.send({
73
+ action: 'rpc',
74
+ method,
75
+ args,
76
+ });
77
+ }
78
+ // }}}
79
+
80
+ // Messages - handshake(), sendRaw(), acceptMessage(), requestFocus() {{{
81
+
82
+ /**
83
+ * Return basic server information as a form of validation
84
+ *
85
+ * @returns {Promise<Object>} Basic promise result
86
+ * @property {Date} date Server date
87
+ */
88
+ handshake() {
89
+ return {
90
+ date: new Date(),
91
+ };
92
+ }
93
+
94
+
95
+ /**
96
+ * Send a message + wait for a response object
97
+ *
98
+ * @param {Object} message Message object to send
99
+ * @returns {Promise<*>} A promise which resolves when the operation has completed with the remote reply
100
+ */
101
+ send(message) {
102
+ if (!this.messageEvent) throw new Error('send() can only be used if given a context from `createContext()`');
103
+
104
+ let id = nanoid();
105
+
106
+ this.acceptPostboxes[id] = {}; // Stub for the deferred promise
107
+ this.acceptPostboxes[id].promise = new Promise((resolve, reject) => {
108
+ Object.assign(this.acceptPostboxes[id], {
109
+ resolve, reject,
110
+ });
111
+ this.sendRaw({
112
+ id,
113
+ ...message,
114
+ });
115
+ });
116
+
117
+ return this.acceptPostboxes[id].promise;
118
+ }
119
+
22
120
 
23
121
  /**
24
122
  * Send raw message content to the client
@@ -26,10 +124,11 @@ export default class TeraFyServer {
26
124
  * @param {Object} message Message object to send
27
125
  */
28
126
  sendRaw(message) {
127
+ this.debug('SendRaw', {message});
29
128
  globalThis.parent.postMessage(
30
129
  {
31
130
  TERA: 1,
32
- ...message,
131
+ ...cloneDeep(message), // Need to clone to resolve promise nasties
33
132
  },
34
133
  this.settings.restrictOrigin
35
134
  );
@@ -44,15 +143,21 @@ export default class TeraFyServer {
44
143
  acceptMessage(rawMessage) {
45
144
  let message = rawMessage.data;
46
145
  if (!message.TERA) return; // Ignore non-TERA signed messages
47
- console.log('TERA-FY Server message', {message});
146
+ this.debug('Recieved', message);
48
147
 
49
148
  Promise.resolve()
50
149
  .then(()=> {
51
- if (message.action == 'rpc') { // Relay RPC calls
150
+ if (message?.action == 'response' && this.acceptPostboxes[message.id]) { // Postbox waiting for reply
151
+ if (message.isError === true) {
152
+ this.acceptPostboxes[message.id].reject(message.response);
153
+ } else {
154
+ this.acceptPostboxes[message.id].resolve(message.response);
155
+ }
156
+ } else if (message.action == 'rpc') { // Relay RPC calls
52
157
  if (!this[message.method]) throw new Error(`Unknown RPC method "${message.method}"`);
53
- return this[message.method].call(this, message.args);
158
+ return this[message.method].apply(this.createContext(rawMessage), message.args);
54
159
  } else {
55
- console.log('Unexpected incoming TERA-FY SERVER message', {message});
160
+ this.debug('Unexpected incoming TERA-FY SERVER message', {message});
56
161
  throw new Error('Unknown message format');
57
162
  }
58
163
  })
@@ -71,23 +176,32 @@ export default class TeraFyServer {
71
176
  });
72
177
  })
73
178
  }
74
- // }}}
75
- // Basics - handshake() {{{
179
+
76
180
 
77
181
  /**
78
- * Return basic server information as a form of validation
182
+ * Listening postboxes, these correspond to outgoing message IDs that expect a response
183
+ */
184
+ acceptPostboxes = {};
185
+
186
+
187
+ /**
188
+ * Wrapper function which runs a callback after the frontend UI has obtained focus
189
+ * This is to fix the issue where the front-end needs to switch between a regular webpage and a focused TERA iFrame wrapper
190
+ * Any use of $prompt or other UI calls should be wrapped here
79
191
  *
80
- * @returns {Promise<Object>} Basic promise result
81
- * @property {Date} date Server date
192
+ * @param {Function} cb Async function to run in focused mode
193
+ *
194
+ * @returns {Promise<*>} A promise which resolves with the resulting inner callback payload
82
195
  */
83
- handshake() {
84
- return {
85
- date: new Date(),
86
- };
196
+ requestFocus(cb) {
197
+ return Promise.resolve()
198
+ .then(()=> this.senderRpc('toggleFocus', true))
199
+ .then(()=> cb.call(this))
200
+ .finally(()=> this.senderRpc('toggleFocus', false))
87
201
  }
88
202
  // }}}
89
203
 
90
- // Session / user - getUser() {{{
204
+ // Session / User - getUser() {{{
91
205
 
92
206
  /**
93
207
  * User / active session within TERA
@@ -175,14 +289,37 @@ export default class TeraFyServer {
175
289
  }
176
290
 
177
291
 
292
+ /**
293
+ * Set the currently active project within TERA
294
+ *
295
+ * @param {Object|String} project The project to set as active - either the full Project object or its ID
296
+ */
297
+ setActiveProject(project) {
298
+ return app.service('$projects').setActive(project)
299
+ }
300
+
301
+
178
302
  /**
179
303
  * Ask the user to select a project from those available - if one isn't already active
180
304
  * Note that this function will percist in asking the uesr even if they try to cancel
181
305
  *
306
+ * @param {Object} [options] Additional options to mutate behaviour
307
+ * @param {Boolean} [options.autoSetActiveProject=true] After selecting a project set that project as active in TERA
308
+ * @param {String} [options.title="Select a project to work with"] The title of the dialog to display
309
+ * @param {String} [options.noSelectTitle='Select project'] Dialog title when warning the user they need to select something
310
+ * @param {String} [options.noSelectBody='A project needs to be selected to continue'] Dialog body when warning the user they need to select something
311
+ *
182
312
  * @returns {Promise<Project>} The active project
183
313
  */
184
- requireProject() {
185
- let $prompt = app.service('$prompt');
314
+ requireProject(options) {
315
+ let settings = {
316
+ autoSetActiveProject: true,
317
+ title: 'Select a project to work with',
318
+ noSelectTitle: 'Select project',
319
+ noSelectBody: 'A project needs to be selected to continue',
320
+ ...options,
321
+ };
322
+
186
323
  return this.getProject()
187
324
  .then(active => {
188
325
  if (active) return active; // Use active project
@@ -195,18 +332,25 @@ export default class TeraFyServer {
195
332
  .then(project => resolve(project))
196
333
  .catch(e => {
197
334
  if (e == 'cancel') {
198
- return $prompt.dialog({
199
- title: 'Select project',
200
- body: 'A project needs to be selected to continue',
201
- buttons: ['ok'],
202
- })
335
+ return this.requestFocus(()=>
336
+ app.service('$prompt').dialog({
337
+ title: settings.noSelectTitle,
338
+ body: settings.noSelectBody,
339
+ buttons: ['ok'],
340
+ })
341
+ )
203
342
  .then(()=> askProject())
204
343
  .catch(reject)
205
344
  } else {
206
345
  reject(e);
207
346
  }
208
347
  })
209
- });
348
+ askProject(); // Kick off intial project loop
349
+ })
350
+ .then(async (project) => {
351
+ if (settings.autoSetActiveProject) await this.setActiveProject(project);
352
+ return project;
353
+ })
210
354
  })
211
355
  }
212
356
 
@@ -226,21 +370,21 @@ export default class TeraFyServer {
226
370
  allowCancel: true,
227
371
  ...options,
228
372
  };
229
- let $projects = app.service('$projects');
230
- let $prompt = app.service('$prompt');
231
- return $projects.promise()
232
- .then(()=> $prompt.dialog({
233
- title: 'Select project',
234
- component: 'projectsSelect',
235
- dialogClose: 'reject',
236
- buttons: settings.allowCancel && ['cancel'],
237
- }))
373
+
374
+ return app.service('$projects').promise()
375
+ .then(()=> this.requestFocus(()=>
376
+ app.service('$prompt').dialog({
377
+ title: settings.title,
378
+ component: 'projectsSelect',
379
+ buttons: settings.allowCancel && ['cancel'],
380
+ })
381
+ ))
238
382
  }
239
383
 
240
384
 
241
385
  // }}}
242
386
 
243
- // Project State {{{
387
+ // Project State - getProjectStateSnapshot(), applyProjectStatePatch() {{{
244
388
 
245
389
  /**
246
390
  * Return the current, full snapshot state of the active project
@@ -267,12 +411,12 @@ export default class TeraFyServer {
267
411
  * Apply a computed `just-diff` patch to the current project state
268
412
  */
269
413
  applyProjectStatePatch(patch) {
270
- console.log('Applying sever state patch', {patch});
414
+ this.debug('Applying sever state patch', {patch});
271
415
  }
272
416
 
273
417
  // }}}
274
418
 
275
- // Project Libraries {{{
419
+ // Project Libraries - getProjectLibrary(), setProjectLibrary() {{{
276
420
 
277
421
  /**
278
422
  * Fetch the active projects citation library
@@ -336,27 +480,29 @@ export default class TeraFyServer {
336
480
 
337
481
 
338
482
  /**
339
- * Set or toggle devMode
340
- *
341
- * @param {String|Boolean} [devModeEnabled='toggle'] Optional boolean to force dev mode
342
- *
343
- * @returns {TeraFy} This chainable terafy instance
483
+ * Initialize the browser listener
344
484
  */
345
- toggleDevMode(devModeEnabled = 'toggle') {
346
- this.settings.devMode = devModeEnabled === 'toggle'
347
- ? !this.settings.devMode
348
- : devModeEnabled;
349
-
350
- return this;
485
+ init() {
486
+ globalThis.addEventListener('message', this.acceptMessage.bind(this));
351
487
  }
488
+ // }}}
352
489
 
490
+ // Utility - debug() {{{
353
491
 
354
492
  /**
355
- * Initialize the browser listener
493
+ * Debugging output function
494
+ * This function will only act if `settings.devMode` is truthy
495
+ *
496
+ * @param {String} [msg...] Output to show
356
497
  */
357
- init() {
358
- console.log('TERA server init');
359
- globalThis.addEventListener('message', this.acceptMessage.bind(this));
498
+ debug(...msg) {
499
+ if (!this.settings.devMode) return;
500
+ console.log(
501
+ '%c[TERA-FY SERVER]',
502
+ 'font-weight: bold; color: #4d659c;',
503
+ ...msg,
504
+ );
360
505
  }
506
+
361
507
  // }}}
362
508
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iebh/tera-fy",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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=.",
@@ -53,6 +53,7 @@
53
53
  "node": ">=18"
54
54
  },
55
55
  "peerDependencies": {
56
+ "lodash-es": "^4.17.21",
56
57
  "just-diff": "^6.0.2",
57
58
  "nanoid": "^5.0.2",
58
59
  "vue": "^3.3.7"