@iebh/tera-fy 1.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.
@@ -0,0 +1,345 @@
1
+ import {nanoid} from 'nanoid';
2
+ import {reactive, watch} from 'vue';
3
+ import diff from 'just-diff';
4
+
5
+
6
+ /**
7
+ * Main Tera-Fy Client (class singleton) to be used in a frontend browser
8
+ *
9
+ * @class TeraFy
10
+ */
11
+ export default class TeraFy {
12
+ /**
13
+ * Various settings to configure behaviour
14
+ *
15
+ * @type {Object}
16
+ * @property {Boolean} devMode Operate in devMode - i.e. force outer refresh when encountering an existing TeraFy instance
17
+ * @property {String} siteUrl The TERA URL to connect to
18
+ * @property {String} restrictOrigin URL to restrict communications to
19
+ */
20
+ settings = {
21
+ devMode: true,
22
+ siteUrl: 'http://localhost:5173/embed',
23
+ restrictOrigin: '*', // DEBUG: Need to restrict this to TERA site
24
+ };
25
+
26
+
27
+ /**
28
+ * DOMElements for this TeraFy instance
29
+ *
30
+ * @type {Object}
31
+ * @property {DOMElement} el The main tera-fy div wrapper
32
+ * @property {DOMElement} iframe The internal iFrame element
33
+ * @property {DOMElement} stylesheet The corresponding stylesheet
34
+ */
35
+ dom = {
36
+ el: null,
37
+ iframe: null,
38
+ stylesheet: null,
39
+ };
40
+
41
+
42
+ /**
43
+ * List of function stubs mapped here from the server
44
+ * This array is forms the reference of `TeraFy.METHOD()` objects to provide locally which will be mapped via `TeraFy.rpc(METHOD, ...args)`
45
+ *
46
+ * @type {Array<String>}
47
+ */
48
+ methods = [
49
+ // Basics
50
+ 'handshake',
51
+
52
+ // Session
53
+ 'getUser',
54
+
55
+ // Projects
56
+ 'bindProject', 'getProject', 'getProjects', 'requireProject', 'selectProject',
57
+
58
+ // Project state
59
+ 'getProjectStateSnapshot', 'applyProjectStatePatch',
60
+ // bindProjectState() - See below
61
+
62
+ // Project Libraries
63
+ 'getProjectLibrary', 'setProjectLibrary',
64
+ ];
65
+
66
+
67
+ // Messages - send(), sendRaw(), rpc(), acceptMessage() {{{
68
+
69
+ /**
70
+ * Send a message + wait for a response object
71
+ *
72
+ * @param {Object} message Message object to send
73
+ * @returns {Promise<*>} A promise which resolves when the operation has completed with the remote reply
74
+ */
75
+ send(message) {
76
+ let id = nanoid();
77
+
78
+ this.acceptPostboxes[id] = {}; // Stub for the deferred promise
79
+ this.acceptPostboxes[id].promise = new Promise((resolve, reject) => {
80
+ Object.assign(this.acceptPostboxes[id], {
81
+ resolve, reject,
82
+ });
83
+ this.sendRaw({
84
+ id,
85
+ ...message,
86
+ });
87
+ });
88
+
89
+ return this.acceptPostboxes[id].promise;
90
+ }
91
+
92
+
93
+ /**
94
+ * Send raw message content to the server
95
+ * This function does not return or wait for a reply - use `send()` for that
96
+ *
97
+ * @param {Object} message Message object to send
98
+ */
99
+ sendRaw(message) {
100
+ this.dom.iframe.contentWindow.postMessage(
101
+ {
102
+ TERA: 1,
103
+ id: message.id || nanoid(),
104
+ ...message,
105
+ },
106
+ this.settings.restrictOrigin
107
+ );
108
+ }
109
+
110
+
111
+ /**
112
+ * Call an RPC function in the server instance
113
+ *
114
+ * @param {String} method The method name to call
115
+ * @param {*} [...] Optional arguments to pass to the function
116
+ * @returns {Promise<*>} The resolved output of the server function
117
+ */
118
+ rpc(method, ...args) {
119
+ return this.send({
120
+ action: 'rpc',
121
+ method,
122
+ args,
123
+ });
124
+ }
125
+
126
+
127
+ /**
128
+ * Accept an incoming message
129
+ *
130
+ * @param {MessageEvent} Raw message event to process
131
+ */
132
+ acceptMessage(rawMessage) {
133
+ let message = rawMessage.data;
134
+ if (!message.TERA) return; // Ignore non-TERA signed messages
135
+
136
+ if (message?.id && this.acceptPostboxes[message.id]) { // Postbox waiting for reply
137
+ if (message.isError === true) {
138
+ this.acceptPostboxes[message.id].reject(message.response);
139
+ } else {
140
+ this.acceptPostboxes[message.id].resolve(message.response);
141
+ }
142
+ } else if (message?.id) {
143
+ console.info(`Ignoring message ID ${message.id} - was meant for someone else?`);
144
+ } else {
145
+ console.log('Unexpected incoming TERA-FY CLIENT message', {message});
146
+ }
147
+ }
148
+
149
+
150
+ /**
151
+ * Listening postboxes, these correspond to outgoing message IDs that expect a response
152
+ */
153
+ acceptPostboxes = {};
154
+
155
+ // }}}
156
+
157
+ // Init - constructor(), toggleDevMode(), init(), injectMain(), injectStylesheet(), injectMethods() {{{
158
+
159
+ /**
160
+ * Setup the TERA-fy client singleton
161
+ *
162
+ * @param {Object} [options] Additional options to merge into `settings`
163
+ */
164
+ constructor(options) {
165
+ Object.assign(this.settings, options);
166
+ }
167
+
168
+
169
+ /**
170
+ * Set or toggle devMode
171
+ *
172
+ * @param {String|Boolean} [devModeEnabled='toggle'] Optional boolean to force dev mode
173
+ *
174
+ * @returns {TeraFy} This chainable terafy instance
175
+ */
176
+ toggleDevMode(devModeEnabled = 'toggle') {
177
+ this.settings.devMode = devModeEnabled === 'toggle'
178
+ ? !this.settings.devMode
179
+ : devModeEnabled;
180
+
181
+ this.dom.el.classList.toggle('dev-mode', this.settings.devMode);
182
+ return this;
183
+ }
184
+
185
+
186
+ /**
187
+ * Initalize the TERA client singleton
188
+ */
189
+ init() {
190
+ window.addEventListener('message', this.acceptMessage.bind(this));
191
+
192
+ this.injectMain();
193
+ this.injectStylesheet();
194
+ this.injectMethods();
195
+ }
196
+
197
+
198
+ /**
199
+ * Find an existing active TERA server OR initalize one
200
+ */
201
+ injectMain() {
202
+ this.dom.el = document.createElement('div')
203
+ this.dom.el.id = 'tera-fy';
204
+ this.dom.el.classList.toggle('dev-mode', this.settings.devMode);
205
+ document.body.append(this.dom.el);
206
+
207
+ this.dom.iframe = document.createElement('iframe')
208
+
209
+ // Queue up event chain when document loads
210
+ this.dom.iframe.setAttribute('sandbox', 'allow-downloads allow-scripts allow-same-origin');
211
+ this.dom.iframe.addEventListener('load', ()=> {
212
+ console.log('TERA EMBED FRAME READY');
213
+ });
214
+
215
+ // Start document load sequence + append to DOM
216
+ this.dom.iframe.src = this.settings.siteUrl;
217
+ this.dom.el.append(this.dom.iframe);
218
+ }
219
+
220
+
221
+ /**
222
+ * Inject a local stylesheet to handle TERA server functionality
223
+ */
224
+ injectStylesheet() {
225
+ this.dom.stylesheet = document.createElement('style');
226
+ this.dom.stylesheet.innerHTML = [
227
+ ':root {',
228
+ '--TERA-accent: #4d659c;',
229
+ '}',
230
+
231
+ '#tera-fy {',
232
+ 'display: none;',
233
+ 'position: fixed;',
234
+ 'right: 50px;',
235
+ 'bottom: 50px;',
236
+ 'width: 300px;',
237
+ 'height: 150px;',
238
+ 'background: transparent;',
239
+
240
+ '&.dev-mode {',
241
+ 'display: flex;',
242
+ 'border: 5px solid var(--TERA-accent);',
243
+ 'background: #FFF;',
244
+ '}',
245
+
246
+ '& > iframe {',
247
+ 'width: 100%;',
248
+ 'height: 100%;',
249
+ '}',
250
+ '}',
251
+
252
+ // Fullscreen functionality {{{
253
+ 'body.tera-fy-fullscreen {',
254
+ 'overflow: hidden;',
255
+
256
+ '& #tera-fy {',
257
+ 'display: flex !important;',
258
+ 'position: fixed !important;',
259
+ 'top: 0px !important;',
260
+ 'width: 100vw !important;',
261
+ 'height: 100vh !important;',
262
+ 'left: 0px !important;',
263
+ 'z-index: 10000 !important;',
264
+ '}',
265
+ '}',
266
+ // }}}
267
+ ].join('\n');
268
+ document.head.appendChild(this.dom.stylesheet);
269
+ }
270
+
271
+
272
+ /**
273
+ * Inject all server methods defined in `methods` as local functions wrapped in the `rpc` function
274
+ */
275
+ injectMethods() {
276
+ this.methods.forEach(method =>
277
+ this[method] = this.rpc.bind(this, method)
278
+ );
279
+ }
280
+ // }}}
281
+
282
+ // Client unique functions - bindProjectState(), toggleFullscreen() {{{
283
+
284
+ /**
285
+ * Return a Vue reactive object that can be read/written which whose changes will transparently be written back to the TERA server instance
286
+ *
287
+ * @param {Object} [options] Additional options to mutate behaviour
288
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
289
+ * @param {Boolean} [options.write=true] Allow local reactivity to writes - send these to the server
290
+ * @param {Array<String>} Paths to subscribe to e.g. ['/users/'],
291
+ *
292
+ * @returns {Promies<Reactive<Object>>} A reactive object representing the project state
293
+ */
294
+ bindProjectState(options) {
295
+ let settings = {
296
+ autoRequire: true,
297
+ write: true,
298
+ ...options,
299
+ };
300
+
301
+ return Promise.resolve()
302
+ .then(()=> settings.autoRequire && this.requireProject())
303
+ .then(()=> this.getProjectStateSnapshot({
304
+ autoRequire: false, // already handled this
305
+ paths: settings.paths,
306
+ }))
307
+ .then(snapshot => {
308
+ // Create initial reactive
309
+ let stateReactive = reactive(snapshot);
310
+
311
+ // Watch for remote changes and update
312
+ // FIXME: Not yet supported
313
+
314
+ // Watch for local writes and react
315
+ if (settings.write) {
316
+ watch(
317
+ stateReactive,
318
+ (newVal, oldVal) => {
319
+ let diff = diff(newVal, oldVal);
320
+ console.log('DEBUG APPLY DIFF', diff);
321
+ this.applyProjectStatePatch(diff);
322
+ },
323
+ {
324
+ deep: true,
325
+ },
326
+ );
327
+ }
328
+
329
+ // Return Vue Reactive
330
+ return stateReactive;
331
+ })
332
+ }
333
+
334
+
335
+ /**
336
+ * Fit the nested TERA server to a full-screen context
337
+ * 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
339
+ */
340
+ toggleFullscreen(isFullscreen) {
341
+ globalThis.document.body.classList.toggle('tera-fy-fullscreen', isFullscreen === 'toggle' ? undefined : isFullscreen);
342
+ }
343
+
344
+ // }}}
345
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Server-side functions available to the Tera-Fy client library
3
+ *
4
+ * @class TeraFyServer
5
+ */
6
+
7
+ /* globals globalThis, app */
8
+ export default class TeraFyServer {
9
+
10
+ /**
11
+ * Various settings to configure behaviour
12
+ *
13
+ * @type {Object}
14
+ * @property {Boolean} devMode Operate in devMode - i.e. force outer refresh when encountering an existing TeraFy instance
15
+ * @property {String} restrictOrigin URL to restrict communications to
16
+ */
17
+ settings = {
18
+ restrictOrigin: '*',
19
+ };
20
+
21
+ // Messages - acceptMessage() {{{
22
+
23
+ /**
24
+ * Send raw message content to the client
25
+ *
26
+ * @param {Object} message Message object to send
27
+ */
28
+ sendRaw(message) {
29
+ globalThis.parent.postMessage(
30
+ {
31
+ TERA: 1,
32
+ ...message,
33
+ },
34
+ this.settings.restrictOrigin
35
+ );
36
+ }
37
+
38
+
39
+ /**
40
+ * Accept a message from the parent event listener
41
+ *
42
+ * @param {MessageEvent} Raw message event to process
43
+ */
44
+ acceptMessage(rawMessage) {
45
+ let message = rawMessage.data;
46
+ if (!message.TERA) return; // Ignore non-TERA signed messages
47
+ console.log('TERA-FY Server message', {message});
48
+
49
+ Promise.resolve()
50
+ .then(()=> {
51
+ if (message.action == 'rpc') { // Relay RPC calls
52
+ if (!this[message.method]) throw new Error(`Unknown RPC method "${message.method}"`);
53
+ return this[message.method].call(this, message.args);
54
+ } else {
55
+ console.log('Unexpected incoming TERA-FY SERVER message', {message});
56
+ throw new Error('Unknown message format');
57
+ }
58
+ })
59
+ .then(res => this.sendRaw({
60
+ id: message.id,
61
+ action: 'response',
62
+ response: res,
63
+ }))
64
+ .catch(e => {
65
+ console.warn(`TERA-FY server threw on RPC:${message.method}:`, e);
66
+ this.sendRaw({
67
+ id: message.id,
68
+ action: 'response',
69
+ isError: true,
70
+ response: e.toString(),
71
+ });
72
+ })
73
+ }
74
+ // }}}
75
+ // Basics - handshake() {{{
76
+
77
+ /**
78
+ * Return basic server information as a form of validation
79
+ *
80
+ * @returns {Promise<Object>} Basic promise result
81
+ * @property {Date} date Server date
82
+ */
83
+ handshake() {
84
+ return {
85
+ date: new Date(),
86
+ };
87
+ }
88
+ // }}}
89
+
90
+ // Session / user - getUser() {{{
91
+
92
+ /**
93
+ * User / active session within TERA
94
+ * @class User
95
+ * @property {String} id Unique identifier of the user
96
+ * @property {String} email The email address of the current user
97
+ * @property {String} name The provided full name of the user
98
+ * @property {Boolean} isSubscribed Whether the active user has a TERA subscription
99
+ */
100
+
101
+ /**
102
+ * Fetch the current session user
103
+ *
104
+ * @returns {Promise<User>} The current logged in user or null if none
105
+ */
106
+ getUser() {
107
+ let $auth = app.service('$auth');
108
+ let $subscriptions = app.service('$subscriptions');
109
+
110
+ return Promise.all([
111
+ $auth.promise(),
112
+ $subscriptions.promise(),
113
+ ])
114
+ .then(()=> ({
115
+ id: $auth.user.id,
116
+ email: $auth.user.email,
117
+ name: [
118
+ $auth.user.given_name,
119
+ $auth.user.family_name,
120
+ ].filter(Boolean).join(' '),
121
+ isSubscribed: $subscriptions.isSubscribed,
122
+ }))
123
+ }
124
+
125
+ // }}}
126
+
127
+ // Projects - getProject(), getProjects(), requireProject(), selectProject() {{{
128
+
129
+ /**
130
+ * Project entry within TERA
131
+ * @class Project
132
+ * @property {String} id The Unique ID of the project
133
+ * @property {String} name The name of the project
134
+ * @property {String} created The creation date of the project as an ISO string
135
+ * @property {Boolean} isOwner Whether the current session user is the owner of the project
136
+ */
137
+
138
+
139
+ /**
140
+ * Get the currently active project, if any
141
+ *
142
+ * @returns {Promise<Project|null>} The currently active project, if any
143
+ */
144
+ getProject() {
145
+ let $projects = app.service('$projects');
146
+
147
+ return $projects.promise()
148
+ .then(()=> $projects.active
149
+ ? {
150
+ id: $projects.active.id,
151
+ name: $projects.active.name,
152
+ created: $projects.active.created,
153
+ isOwner: $projects.active.$isOwner,
154
+ }
155
+ : null
156
+ )
157
+ }
158
+
159
+
160
+ /**
161
+ * Get a list of projects the current session user has access to
162
+ *
163
+ * @returns {Promise<Array<Project>>} Collection of projects the user has access to
164
+ */
165
+ getProjects() {
166
+ let $projects = app.service('$projects');
167
+
168
+ return $projects.promise()
169
+ .then(()=> $projects.list.map(project => ({
170
+ id: project.id,
171
+ name: project.name,
172
+ created: project.created,
173
+ isOwner: project.$isOwner,
174
+ })))
175
+ }
176
+
177
+
178
+ /**
179
+ * Ask the user to select a project from those available - if one isn't already active
180
+ * Note that this function will percist in asking the uesr even if they try to cancel
181
+ *
182
+ * @returns {Promise<Project>} The active project
183
+ */
184
+ requireProject() {
185
+ let $prompt = app.service('$prompt');
186
+ return this.getProject()
187
+ .then(active => {
188
+ if (active) return active; // Use active project
189
+
190
+ return new Promise((resolve, reject) => {
191
+ let askProject = ()=> Promise.resolve()
192
+ .then(()=> this.selectProject({
193
+ allowCancel: false,
194
+ }))
195
+ .then(project => resolve(project))
196
+ .catch(e => {
197
+ 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
+ })
203
+ .then(()=> askProject())
204
+ .catch(reject)
205
+ } else {
206
+ reject(e);
207
+ }
208
+ })
209
+ });
210
+ })
211
+ }
212
+
213
+
214
+ /**
215
+ * Prompt the user to select a project from those available
216
+ *
217
+ * @param {Object} [options] Additional options to mutate behaviour
218
+ * @param {String} [options.title="Select a project to work with"] The title of the dialog to display
219
+ * @param {Boolean} [options.allowCancel=true] Advertise cancelling the operation, the dialog can still be cancelled by closing it
220
+ *
221
+ * @returns {Promise<Project>} The active project
222
+ */
223
+ selectProject(options) {
224
+ let settings = {
225
+ title: 'Select a project to work with',
226
+ allowCancel: true,
227
+ ...options,
228
+ };
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
+ }))
238
+ }
239
+
240
+
241
+ // }}}
242
+
243
+ // Project State {{{
244
+
245
+ /**
246
+ * Return the current, full snapshot state of the active project
247
+ *
248
+ * @param {Object} [options] Additional options to mutate behaviour
249
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
250
+ * @param {Array<String>} Paths to subscribe to e.g. ['/users/'],
251
+ *
252
+ * @returns {Promise<Object>} The current project state snapshot
253
+ */
254
+ getProjectStateSnapshot(options) {
255
+ let settings = {
256
+ autoRequire: true,
257
+ paths: null,
258
+ ...options,
259
+ };
260
+
261
+ return Promise.resolve()
262
+ .then(()=> settings.autoRequire && this.requireProject())
263
+ }
264
+
265
+
266
+ /**
267
+ * Apply a computed `just-diff` patch to the current project state
268
+ */
269
+ applyProjectStatePatch(patch) {
270
+ console.log('Applying sever state patch', {patch});
271
+ }
272
+
273
+ // }}}
274
+
275
+ // Project Libraries {{{
276
+
277
+ /**
278
+ * Fetch the active projects citation library
279
+ *
280
+ * @param {Object} [options] Additional options to mutate behaviour
281
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
282
+ * @param {Boolean} [options.multiple=false] Allow selection of multiple libraries
283
+ * @param {String|Array<String>} [options.hint] Hints to identify the library to select in array order of preference. Generally corresponds to the previous stage - e.g. 'deduped', 'review1', 'review2', 'dedisputed'
284
+ *
285
+ * @returns {Promise<Array<RefLibRef>>} Collection of references for the selected library
286
+ */
287
+ getProjectLibrary(options) {
288
+ let settings = {
289
+ autoRequire: true,
290
+ multiple: false,
291
+ hint: null,
292
+ ...options,
293
+ };
294
+
295
+ return Promise.resolve()
296
+ .then(()=> settings.autoRequire && this.requireProject())
297
+ // FIXME: Stub
298
+ }
299
+
300
+
301
+ /**
302
+ * Save back a projects citation library
303
+ *
304
+ * @param {Array<RefLibRef>} Collection of references for the selected library
305
+ *
306
+ * @param {Object} [options] Additional options to mutate behaviour
307
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
308
+ * @param {String} [options.hint] Hint to store against the library. Generally corresponds to the current operation being performed - e.g. 'deduped'
309
+ *
310
+ * @returns {Promise} A promise which resolves when the save operation has completed
311
+ */
312
+ setProjectLibrary(refs, options) {
313
+ let settings = {
314
+ autoRequire: true,
315
+ hint: null,
316
+ ...options,
317
+ };
318
+
319
+ return Promise.resolve()
320
+ .then(()=> settings.autoRequire && this.requireProject())
321
+ // FIXME: Stub
322
+ }
323
+
324
+ // }}}
325
+
326
+ // Init - constructor(), init() {{{
327
+
328
+ /**
329
+ * Setup the TERA-fy client singleton
330
+ *
331
+ * @param {Object} [options] Additional options to merge into `settings`
332
+ */
333
+ constructor(options) {
334
+ Object.assign(this.settings, options);
335
+ }
336
+
337
+
338
+ /**
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
344
+ */
345
+ toggleDevMode(devModeEnabled = 'toggle') {
346
+ this.settings.devMode = devModeEnabled === 'toggle'
347
+ ? !this.settings.devMode
348
+ : devModeEnabled;
349
+
350
+ return this;
351
+ }
352
+
353
+
354
+ /**
355
+ * Initialize the browser listener
356
+ */
357
+ init() {
358
+ console.log('TERA server init');
359
+ globalThis.addEventListener('message', this.acceptMessage.bind(this));
360
+ }
361
+ // }}}
362
+ }