@knowlearning/agents 0.7.7 → 0.8.0

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,4 +1,4 @@
1
- import { v1 as uuid } from 'uuid'
1
+ import { validate as isUUID, v1 as uuid } from 'uuid'
2
2
  import MutableProxy from '../../persistence/json.js'
3
3
 
4
4
  export default function EmbeddedAgent() {
@@ -8,6 +8,12 @@ export default function EmbeddedAgent() {
8
8
  const responses = {}
9
9
  const watchers = {}
10
10
 
11
+ function removeWatcher(key, fn) {
12
+ const watcherIndex = watchers[key].findIndex(x => x === fn)
13
+ if (watcherIndex > -1) watchers[key].splice(watcherIndex, 1)
14
+ else console.warn('TRIED TO REMOVE WATCHER THAT DOES NOT EXIST')
15
+ }
16
+
11
17
  async function send(message) {
12
18
  const requestId = message.requestId || uuid()
13
19
 
@@ -46,9 +52,8 @@ export default function EmbeddedAgent() {
46
52
  else resolve(data.response)
47
53
  }
48
54
  else if (data.ii !== undefined) {
49
- const { domain, user, scope } = data
50
- const key = `${domain}/${user}/${scope}`
51
- if (watchers[key]) watchers[key].forEach(fn => fn(data))
55
+ const { scope } = data
56
+ if (watchers[scope]) watchers[scope].forEach(fn => fn(data))
52
57
  }
53
58
  })
54
59
 
@@ -60,44 +65,29 @@ export default function EmbeddedAgent() {
60
65
  // TODO: collapse into 1 patch and 1 interact call
61
66
  interact(id, [{ op: 'add', path: ['active_type'], value: active_type }])
62
67
  interact(id, [{ op: 'add', path: ['active'], value: active }])
68
+ return id
63
69
  }
64
70
 
65
- function state(scope, user, domain) {
66
- let watchFn
67
- let resolveKey
68
- const key = new Promise(r => resolveKey = r)
71
+ const tagTypeToTargetCache = {}
72
+ async function tagIfNotYetTaggedInSession(tag_type, target) {
73
+ const targetCache = tagTypeToTargetCache[tag_type]
74
+ if (targetCache && targetCache[target]) return
69
75
 
70
- const promise = new Promise(async resolve => {
71
- const { domain: d, auth: { user: u }, scope: s } = await environment()
72
- domain = domain || d
73
- user = user || u
74
- scope = scope || s
75
- resolveKey(`${domain}/${user}/${scope}`)
76
- resolve(send({ type: 'state', scope, user, domain }))
77
- })
76
+ // always use absolute referene when tagging
77
+ if (!isUUID(target)) target = (await metadata(target)).id
78
78
 
79
- promise.watch = fn => {
80
- key
81
- .then( async k => {
82
- if (watchFn) throw new Error('Only one watcher allowed')
83
- if (!watchers[k]) watchers[k] = []
84
- watchers[k].push(fn)
85
- watchFn = fn
86
- })
87
- return promise
88
- }
79
+ if (!tagTypeToTargetCache[tag_type]) tagTypeToTargetCache[tag_type] = {}
80
+ tagTypeToTargetCache[tag_type][target] = true
89
81
 
90
- promise.unwatch = () => {
91
- key
92
- .then( k => {
93
- watchers[k] = watchers[k].filter(x => x !== watchFn)
94
- if (watchers[k].length === 0) delete watchers[k]
95
- })
82
+ await tag(tag_type, target)
83
+ }
96
84
 
97
- return promise
98
- }
99
85
 
100
- return promise
86
+ function watch(id, fn) {
87
+ tagIfNotYetTaggedInSession('connected', id)
88
+ if (!watchers[id]) watchers[id] = []
89
+ watchers[id].push(fn)
90
+ return () => removeWatcher(id, fn)
101
91
  }
102
92
 
103
93
  async function patch(root, scopes) {
@@ -105,23 +95,23 @@ export default function EmbeddedAgent() {
105
95
  return send({ type: 'patch', root, scopes })
106
96
  }
107
97
 
108
- async function mutate (scope, initialize=true) {
109
- const handlePatch = patch => interact(scope, patch)
110
-
111
- if (!scope) scope = (await environment()).scope
98
+ async function state(scope) {
99
+ tagIfNotYetTaggedInSession('connected', scope)
100
+ const startState = await send({ type: 'state', scope })
101
+ return new MutableProxy(startState, patch => {
102
+ const activePatch = structuredClone(patch)
103
+ activePatch.forEach(entry => entry.path.unshift('active'))
104
+ interact(scope, activePatch)
105
+ })
112
106
 
113
- if (initialize) {
114
- // TODO: consider setting up watcher and updating state
115
- return new MutableProxy(await state(scope) || {}, handlePatch)
116
- }
117
- else return new MutableProxy({}, handlePatch)
118
107
  }
119
108
 
120
109
  function reset(scope) {
121
- return interact(scope, [{ op: 'add', path:[], value: null }])
110
+ return interact(scope, [{ op: 'add', path:['active'], value: null }])
122
111
  }
123
112
 
124
113
  function interact(scope, patch) {
114
+ tagIfNotYetTaggedInSession('mutated', scope)
125
115
  return send({ type: 'interact', scope, patch })
126
116
  }
127
117
 
@@ -181,8 +171,27 @@ export default function EmbeddedAgent() {
181
171
  return promise
182
172
  }
183
173
 
184
- function metadata(id) {
185
- return state(id, 'metadata', 'core')
174
+ function isValidMetadataMutation({ path, op, value }) {
175
+ return (
176
+ ['active_type', 'name'].includes(path[0])
177
+ && path.length === 1
178
+ && typeof value === 'string' || op === 'remove'
179
+ )
180
+ }
181
+
182
+ async function metadata(scope) {
183
+ const md = await send({ type: 'metadata', scope })
184
+ return new MutableProxy(md, patch => {
185
+ const activePatch = structuredClone(patch)
186
+ activePatch.forEach(entry => {
187
+ if (!isValidMetadataMutation(entry)) throw new Error('You may only modify the type or name for a scope\'s metadata')
188
+ })
189
+ interact(scope, activePatch)
190
+ })
191
+ }
192
+
193
+ function tag(tag_type, target, context=[]) {
194
+ return send({ type: 'tag', tag_type, target, context })
186
195
  }
187
196
 
188
197
  function login(provider, username, password) {
@@ -190,25 +199,28 @@ export default function EmbeddedAgent() {
190
199
  }
191
200
 
192
201
  function logout() { return send({ type: 'logout' }) }
193
-
194
202
  function disconnect() { return send({ type: 'disconnect' }) }
195
-
196
203
  function reconnect() { return send({ type: 'reconnect' }) }
204
+ function synced() { return send({ type: 'synced' }) }
197
205
 
198
206
  return {
207
+ embedded: true,
208
+ uuid,
199
209
  environment,
200
210
  login,
201
211
  logout,
202
212
  create,
203
213
  state,
214
+ watch,
204
215
  upload,
205
216
  download,
206
217
  interact,
207
218
  patch,
208
- mutate,
209
219
  reset,
210
220
  metadata,
211
221
  disconnect,
212
- reconnect
222
+ reconnect,
223
+ synced,
224
+ tag
213
225
  }
214
226
  }
@@ -1,19 +1,169 @@
1
1
  import RootAgent from './root.js'
2
2
  import EmbeddedAgent from './embedded.js'
3
- import embed from './embed.js'
3
+ import { v1 as uuid, validate as validateUUID } from 'uuid'
4
4
 
5
- let agentInstance
5
+ let Agent
6
6
 
7
- export default function() {
8
- if (agentInstance) return agentInstance
7
+ export default function browserAgent() {
8
+ if (Agent) return Agent
9
9
 
10
10
  let embedded
11
11
 
12
12
  try { embedded = window.self !== window.top }
13
13
  catch (e) { embedded = true }
14
14
 
15
- agentInstance = embedded ? EmbeddedAgent() : RootAgent()
16
- agentInstance.embed = embed
15
+ Agent = embedded ? EmbeddedAgent() : RootAgent()
16
+ Agent.embed = embed
17
17
 
18
- return agentInstance
18
+ return Agent
19
+ }
20
+
21
+ const copy = x => JSON.parse(JSON.stringify(x))
22
+
23
+ const watchers = {}
24
+
25
+ function embed(environment, iframe) {
26
+ const postMessageQueue = []
27
+ const listeners = {}
28
+ let frameLoaded = false
29
+ let embeddedAgentInitialized = false
30
+
31
+ const session = uuid()
32
+
33
+ const postMessage = m => new Promise((resolve, reject) => {
34
+ const message = { ...copy(m), session }
35
+ postMessageQueue.push({ message, sent: resolve })
36
+ if (frameLoaded) processPostMessageQueue()
37
+ })
38
+
39
+ const processPostMessageQueue = () => {
40
+ while (iframe.parentNode && postMessageQueue.length) {
41
+ const { message, sent } = postMessageQueue.shift()
42
+ iframe.contentWindow.postMessage(message, '*')
43
+ sent()
44
+ }
45
+ }
46
+
47
+ const handleMessage = async message => {
48
+ const { requestId, type } = message
49
+
50
+ const sendDown = response => postMessage({ requestId, response })
51
+
52
+ if (type === 'error') {
53
+ console.error(message)
54
+ sendDown({})
55
+ }
56
+ else if (type === 'close') {
57
+ if (listeners.close) listeners.close()
58
+ }
59
+ else if (type === 'environment') {
60
+ const env = await Agent.environment()
61
+ const context = [...env.context, environment.id]
62
+ Object.assign(env, { ...env, context })
63
+ sendDown(env)
64
+ }
65
+ else if (type === 'interact') {
66
+ const { scope, patch } = message
67
+ await Agent.interact(scope, patch)
68
+ if (listeners.mutate) listeners.mutate({ scope })
69
+ sendDown({}) // TODO: might want to send down the interaction index
70
+ }
71
+ else if (type === 'metadata') {
72
+ sendDown(await Agent.metadata(message.scope))
73
+ }
74
+ else if (type === 'tag') {
75
+ const { tag_type, target, context } = message
76
+ const prependedContext = [environment.id, ...context]
77
+ sendDown(await Agent.tag(tag_type, target, prependedContext))
78
+ }
79
+ else if (type === 'state') {
80
+ const { scope } = message
81
+
82
+ const statePromise = Agent.state(scope)
83
+
84
+ if (!watchers[scope]) watchers[scope] = Agent.watch(scope, postMessage)
85
+
86
+ if (listeners.state) listeners.state({ scope })
87
+ sendDown(await statePromise)
88
+ }
89
+ else if (type === 'patch') {
90
+ const { root, scopes } = message
91
+ sendDown(await Agent.patch(root, scopes))
92
+ }
93
+ else if (type === 'upload') {
94
+ const { name, contentType, id } = message
95
+ sendDown(await Agent.upload(name, contentType, undefined, id))
96
+ }
97
+ else if (type === 'download') {
98
+ sendDown(await Agent.download(message.id).url())
99
+ }
100
+ else if (type === 'login') {
101
+ const { provider, username, password } = message
102
+ sendDown(await Agent.login(provider, username, password))
103
+ }
104
+ else if (type === 'logout') Agent.logout()
105
+ else if (type === 'disconnect') sendDown(await Agent.disconnect())
106
+ else if (type === 'reconnect') sendDown(await Agent.reconnect())
107
+ else if (type === 'synced') sendDown(await Agent.synced())
108
+ else {
109
+ console.log('Unknown message type passed up...', message)
110
+ sendDown({})
111
+ }
112
+ }
113
+
114
+ window.addEventListener('message', ({ data }) => {
115
+ if (data.session === session) {
116
+ embeddedAgentInitialized = true
117
+ // TODO: ensure message index ordering!!!!!!!!!!!!!!!!!!!! (no order guarantee given in postMessage protocol, so we need to make a little buffer here)
118
+ handleMessage(data)
119
+ }
120
+ })
121
+
122
+ // write in a temporary loading notification while frame loads
123
+ const cw = iframe.contentWindow
124
+ if (cw) cw.document.body.innerHTML = 'Loading...'
125
+
126
+ // TODO: make sure content security policy headers for embedded domain always restrict iframe
127
+ // src to only self for embedded domain
128
+ iframe.onload = () => {
129
+ frameLoaded = true
130
+ processPostMessageQueue()
131
+ }
132
+
133
+ setUpEmbeddedFrame()
134
+
135
+ async function setUpEmbeddedFrame() {
136
+ browserAgent()
137
+
138
+ const { protocol } = window.location
139
+ const { id } = environment
140
+ if (validateUUID(id)) {
141
+ const { domain } = await Agent.metadata(id)
142
+ iframe.src = `${protocol}//${domain}/${id}`
143
+ }
144
+ else iframe.src = id // TODO: ensure is url
145
+
146
+ while(!embeddedAgentInitialized) {
147
+ postMessage({ type: 'setup', session })
148
+ await new Promise(r => setTimeout(r, 100))
149
+ }
150
+ }
151
+
152
+ function remove () {
153
+ if (iframe.parentNode) iframe.parentNode.removeChild(iframe)
154
+ }
155
+
156
+ function on(event, fn) {
157
+ listeners[event] = fn
158
+ }
159
+
160
+ function auth(token, state) {
161
+ postMessage({ type: 'auth', token, state })
162
+ }
163
+
164
+ return {
165
+ auth,
166
+ remove,
167
+ on
168
+ }
19
169
  }
package/agents/generic.js CHANGED
@@ -5,6 +5,7 @@ const HEARTBEAT_TIMEOUT = 10000
5
5
  const UPLOAD_TYPE = 'application/json;type=upload'
6
6
  const SUBSCRIPTION_TYPE = 'application/json;type=subscription'
7
7
  const POSTGRES_QUERY_TYPE = 'application/json;type=postgres-query'
8
+ const TAG_TYPE = 'application/json;type=tag'
8
9
 
9
10
  // transform our custom path implementation to the standard JSONPatch path
10
11
  function standardJSONPatch(patch) {
@@ -13,6 +14,10 @@ function standardJSONPatch(patch) {
13
14
  })
14
15
  }
15
16
 
17
+ function isUUID(string) {
18
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(string)
19
+ }
20
+
16
21
  function sanitizeJSONPatchPathSegment(s) {
17
22
  if (typeof s === "string") return s.replaceAll('~', '~0').replaceAll('/', '~1')
18
23
  else return s
@@ -22,8 +27,9 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
22
27
  let ws
23
28
  let user
24
29
  let domain
25
- const session = uuid()
30
+ let session
26
31
  let server
32
+ let isSynced = false
27
33
  let si = -1
28
34
  let authed = false
29
35
  const states = {}
@@ -31,6 +37,7 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
31
37
  const watchers = {}
32
38
  const keyToSubscriptionId = {}
33
39
  const lastInteractionResponse = {}
40
+ const tagTypeToTargetCache = {}
34
41
  const messageQueue = []
35
42
  let resolveEnvironment
36
43
  let disconnected = false
@@ -41,7 +48,6 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
41
48
  let lastHeartbeat
42
49
  const syncedPromiseResolutions = []
43
50
 
44
- const subscriptions = {}
45
51
  const patches = state('patches')
46
52
 
47
53
  log('INITIALIZING AGENT CONNECTION')
@@ -52,9 +58,8 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
52
58
  }
53
59
 
54
60
  function resolveSyncPromises() {
55
- while (syncedPromiseResolutions.length) {
56
- syncedPromiseResolutions.shift()()
57
- }
61
+ while (syncedPromiseResolutions.length) syncedPromiseResolutions.shift()()
62
+ isSynced = true
58
63
  }
59
64
 
60
65
  function removeWatcher(key, fn) {
@@ -76,6 +81,7 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
76
81
  }
77
82
 
78
83
  function queueMessage(message) {
84
+ isSynced = false
79
85
  si += 1
80
86
  const promise = new Promise((resolve, reject) => responses[si] = [[resolve, reject]])
81
87
  messageQueue.push({...message, si, ts: Date.now()})
@@ -136,6 +142,7 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
136
142
  authed = true
137
143
  if (!user) { // this is the first authed websocket connection
138
144
  user = message.auth.user
145
+ session = message.session
139
146
  domain = message.domain
140
147
  server = message.server
141
148
  resolveEnvironment(message)
@@ -152,14 +159,13 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
152
159
  else {
153
160
  if (message.si !== undefined) {
154
161
  if (responses[message.si]) {
155
- // TODO: remove "acknowledged" messages fromt queue and do accounting with si
162
+ // TODO: remove "acknowledged" messages from queue and do accounting with si
156
163
  responses[message.si]
157
164
  .forEach(([resolve, reject]) => {
158
165
  message.error ? reject(message) : resolve(message)
159
166
  })
160
167
  delete responses[message.si]
161
168
  ws.send(JSON.stringify({ack: message.si})) // acknowledgement that we have received the response for this message
162
-
163
169
  if (Object.keys(responses).length === 0) {
164
170
  resolveSyncPromises()
165
171
  }
@@ -170,14 +176,7 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
170
176
  }
171
177
  }
172
178
  else {
173
- const sub = subscriptions[keyToSubscriptionId[message.scope]]
174
179
  if (watchers[message.scope]) {
175
- // TODO: debug case where sub is not defined
176
- if (sub && sub.ii + 1 !== message.ii) {
177
- // TODO: this seems to be an error that happens with decent regularity (an answer with a given si was skipped/failed)
178
- // we should be wary of out-of-order ii being passed down (maybe need to wait for older ones???)
179
- console.warn('UNEXPECTED UPDATE INTERACTION INDEX!!!!!!!!!!! last index in session', sub, ' passed index ', message.ii)
180
- }
181
180
  states[message.scope] = await states[message.scope]
182
181
 
183
182
  const lastResetPatchIndex = message.patch.findLastIndex(p => p.path.length === 0)
@@ -190,7 +189,6 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
190
189
  const state = structuredClone(states[message.scope].active)
191
190
  fn({ ...message, state })
192
191
  })
193
- if (sub) sub.ii = message.ii
194
192
  }
195
193
  }
196
194
  }
@@ -214,25 +212,39 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
214
212
 
215
213
  function create({ id=uuid(), active_type, active }) {
216
214
  // TODO: collapse into 1 patch and 1 interact call
217
- interact(id, [{ op: 'add', path: ['active_type'], value: active_type }])
218
- interact(id, [{ op: 'add', path: ['active'], value: active }])
215
+ interact(id, [{ op: 'add', path: ['active_type'], value: active_type }], false)
216
+ interact(id, [{ op: 'add', path: ['active'], value: active }], false)
217
+ return id
218
+ }
219
+
220
+ async function tagIfNotYetTaggedInSession(tag_type, target) {
221
+ const targetCache = tagTypeToTargetCache[tag_type]
222
+ if (targetCache && targetCache[target]) return
223
+
224
+ if (!targetCache) tagTypeToTargetCache[tag_type] = {}
225
+ tagTypeToTargetCache[tag_type][target] = true
226
+
227
+ // always use absolute referene when tagging
228
+ if (!isUUID(target)) target = (await metadata(target)).id
229
+
230
+ await tag(tag_type, target)
219
231
  }
220
232
 
221
- function environment() { return environmentPromise }
233
+ async function environment() {
234
+ return { ...(await environmentPromise), context: [] }
235
+ }
222
236
 
223
237
  function state(scope) {
224
- let watchFn
225
- const promise = new Promise(async (resolveState, rejectState) => {
238
+ tagIfNotYetTaggedInSession('connected', scope)
239
+ return new Promise(async (resolveState, rejectState) => {
226
240
  if (!keyToSubscriptionId[scope]) {
227
241
  const id = uuid()
228
242
 
229
243
  keyToSubscriptionId[scope] = id
230
244
  watchers[scope] = []
231
245
  states[scope] = new Promise(async (resolve, reject) => {
232
-
233
- const subscriptionId = uuid()
234
246
  create({
235
- id: subscriptionId,
247
+ id,
236
248
  active_type: SUBSCRIPTION_TYPE,
237
249
  active: { session, scope, ii: null }
238
250
  })
@@ -240,7 +252,7 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
240
252
  try {
241
253
  const state = await lastMessageResponse()
242
254
  // TODO: replace with editing scope of type
243
- interact(subscriptionId, [{
255
+ interact(id, [{
244
256
  op: 'add',
245
257
  path: ['active', 'ii'],
246
258
  value: 1 // TODO: use state.ii when is coming down properly...
@@ -253,7 +265,6 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
253
265
  // ii does not apply (we clear out the subscription to not cache value)
254
266
  delete states[scope]
255
267
  delete keyToSubscriptionId[scope]
256
- delete subscriptions[id]
257
268
  }
258
269
  }
259
270
  catch (error) {
@@ -277,26 +288,6 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
277
288
  rejectState(error)
278
289
  }
279
290
  })
280
-
281
- promise.watch = fn => {
282
- if (watchFn) throw new Error('Only one watcher allowed')
283
- if (!watchers[scope]) watchers[scope] = []
284
- watchers[scope].push(fn)
285
- watchFn = fn
286
-
287
- return promise
288
- }
289
-
290
- promise.unwatch = async () => {
291
- removeWatcher(scope, watchFn)
292
- if (watchers[scope].length === 0) {
293
- delete subscriptions[keyToSubscriptionId[scope]]
294
- delete keyToSubscriptionId[scope]
295
- delete watchers[scope]
296
- }
297
- }
298
-
299
- return promise
300
291
  }
301
292
 
302
293
  function watch(id, fn) {
@@ -352,7 +343,9 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
352
343
  return { swaps }
353
344
  }
354
345
 
355
- async function interact(scope, patch) {
346
+ // TODO: addTag option should probably not be exposed
347
+ async function interact(scope, patch, addTag=true) {
348
+ if (addTag) tagIfNotYetTaggedInSession('mutated', scope)
356
349
  // TODO: ensure user is owner of scope
357
350
  const response = queueMessage({scope, patch})
358
351
 
@@ -409,7 +402,7 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
409
402
  }
410
403
 
411
404
  async function synced() {
412
- return new Promise(resolve => syncedPromiseResolutions.push(resolve))
405
+ return isSynced ? null : new Promise(resolve => syncedPromiseResolutions.push(resolve))
413
406
  }
414
407
 
415
408
  function disconnect() {
@@ -436,6 +429,13 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
436
429
  return lastMessageResponse()
437
430
  }
438
431
 
432
+ function tag(tag_type, target, context=[]) {
433
+ return create({
434
+ active_type: TAG_TYPE,
435
+ active: { tag_type, target, context }
436
+ })
437
+ }
438
+
439
439
  return {
440
440
  uuid,
441
441
  environment,
@@ -455,6 +455,7 @@ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fet
455
455
  synced,
456
456
  disconnect,
457
457
  reconnect,
458
+ tag,
458
459
  debug
459
460
  }
460
461
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowlearning/agents",
3
- "version": "0.7.7",
3
+ "version": "0.8.0",
4
4
  "description": "API for embedding applications in KnowLearning systems.",
5
5
  "main": "node.js",
6
6
  "browser": "browser.js",
@@ -1,146 +0,0 @@
1
- import { v1 as uuid, validate as validateUUID } from 'uuid'
2
-
3
- const copy = x => JSON.parse(JSON.stringify(x))
4
-
5
- const watchers = {}
6
-
7
- export default function embed(environment, iframe) {
8
- const postMessageQueue = []
9
- const listeners = {}
10
- let frameLoaded = false
11
- let embeddedAgentInitialized = false
12
-
13
- const session = uuid()
14
-
15
- const postMessage = m => new Promise((resolve, reject) => {
16
- const message = { ...copy(m), session }
17
- postMessageQueue.push({ message, sent: resolve })
18
- if (frameLoaded) processPostMessageQueue()
19
- })
20
-
21
- const processPostMessageQueue = () => {
22
- while (iframe.parentNode && postMessageQueue.length) {
23
- const { message, sent } = postMessageQueue.shift()
24
- iframe.contentWindow.postMessage(message, '*')
25
- sent()
26
- }
27
- }
28
-
29
- const handleMessage = async message => {
30
- const { requestId, type } = message
31
-
32
- const sendDown = response => {
33
- if (message.type === 'environment') Object.assign(response, environment)
34
- return postMessage({ requestId, response })
35
- }
36
-
37
- if (type === 'error') {
38
- console.error(message)
39
- sendDown({})
40
- }
41
- else if (type === 'close') {
42
- if (listeners.close) listeners.close()
43
- }
44
- else if (type === 'environment') {
45
- sendDown(await Agent.environment())
46
- }
47
- else if (type === 'interact') {
48
- const { scope, patch } = message
49
- await Agent.interact(scope, patch)
50
- if (listeners.mutate) listeners.mutate({ scope })
51
- sendDown({}) // TODO: might want to send down the interaction index
52
- }
53
- else if (type === 'state') {
54
- const { scope, user, domain } = message
55
-
56
- const statePromise = Agent.state(scope, user, domain)
57
-
58
- const key = `${session}/${domain}/${user}/${scope}`
59
-
60
- // only watch once per key per experience session
61
- if (!watchers[key]) {
62
- watchers[key] = statePromise.unwatch // TODO: consider how to use this...
63
- statePromise.watch(postMessage)
64
- }
65
-
66
- if (listeners.state) listeners.state({ scope, user, domain })
67
- sendDown(await statePromise)
68
- }
69
- else if (type === 'patch') {
70
- const { root, scopes } = message
71
- sendDown(await Agent.patch(root, scopes))
72
- }
73
- else if (type === 'upload') {
74
- const { name, contentType, id } = message
75
- sendDown(await Agent.upload(name, contentType, false, id))
76
- }
77
- else if (type === 'download') {
78
- sendDown(await Agent.download(message.id).url())
79
- }
80
- else if (type === 'login') {
81
- const { provider, username, password } = message
82
- sendDown(await Agent.login(provider, username, password))
83
- }
84
- else if (type === 'logout') Agent.logout()
85
- else if (type === 'disconnect') sendDown(await Agent.disconnect())
86
- else if (type === 'reconnect') sendDown(await Agent.reconnect())
87
- else {
88
- console.log('Unknown message type passed up...', message)
89
- sendDown({})
90
- }
91
- }
92
-
93
- window.addEventListener('message', ({ data }) => {
94
- if (data.session === session) {
95
- embeddedAgentInitialized = true
96
- // TODO: ensure message index ordering!!!!!!!!!!!!!!!!!!!! (no order guarantee given in postMessage protocol, so we need to make a little buffer here)
97
- handleMessage(data)
98
- }
99
- })
100
-
101
- // write in a temporary loading notification while frame loads
102
- const cw = iframe.contentWindow
103
- if (cw) cw.document.body.innerHTML = 'Loading...'
104
-
105
- // TODO: make sure content security policy headers for embedded domain always restrict iframe
106
- // src to only self for embedded domain
107
- iframe.onload = () => {
108
- frameLoaded = true
109
- processPostMessageQueue()
110
- }
111
-
112
- setUpEmbeddedFrame()
113
-
114
- async function setUpEmbeddedFrame() {
115
- const { protocol } = window.location
116
- const { id } = environment
117
- if (validateUUID(id)) {
118
- const { domain } = await Agent.metadata(id)
119
- iframe.src = `${protocol}//${domain}/${id}`
120
- }
121
- else iframe.src = id // TODO: ensure is url
122
-
123
- while(!embeddedAgentInitialized) {
124
- postMessage({ type: 'setup', session })
125
- await new Promise(r => setTimeout(r, 100))
126
- }
127
- }
128
-
129
- function remove () {
130
- if (iframe.parentNode) iframe.parentNode.removeChild(iframe)
131
- }
132
-
133
- function on(event, fn) {
134
- listeners[event] = fn
135
- }
136
-
137
- function auth(token, state) {
138
- postMessage({ type: 'auth', token, state })
139
- }
140
-
141
- return {
142
- auth,
143
- remove,
144
- on
145
- }
146
- }