@knowlearning/agents 0.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,164 @@
1
+ import { v1 as uuid } from 'npm/unscoped/uuid/9.0.0'
2
+ import MutableProxy from '../../persistence/json.js'
3
+ import Experience from './experience.js'
4
+
5
+ export default EmbeddedAgent()
6
+
7
+ function EmbeddedAgent() {
8
+ let messageIndex = 0
9
+ let resolveSession
10
+ const session = new Promise(r => resolveSession = r)
11
+ const responses = {}
12
+ const watchers = {}
13
+
14
+ async function send(message) {
15
+ const requestId = message.requestId || uuid()
16
+
17
+ messageIndex += 1
18
+ // default to window opener if present
19
+ const upstream = window.opener ? window.opener : window.parent
20
+ try {
21
+ upstream
22
+ .postMessage({
23
+ ...message,
24
+ session: await session,
25
+ requestId,
26
+ index: messageIndex
27
+ }, '*')
28
+ return new Promise((resolve, reject) => {
29
+ responses[requestId] = { resolve, reject }
30
+ })
31
+ }
32
+ catch (error) {
33
+ console.log('ERROR POSTING MESSAGE UP', message, error)
34
+ }
35
+ }
36
+
37
+ addEventListener('message', async ({ data }) => {
38
+ if (data.type === 'setup') resolveSession(data.session)
39
+ else if (responses[data.requestId]) {
40
+ const { resolve, reject } = responses[data.requestId]
41
+ if (data.error) reject(data.error)
42
+ else resolve(data.response)
43
+ }
44
+ else if (data.ii !== undefined) {
45
+ const { domain, user, scope } = data
46
+ const key = `${domain}/${user}/${scope}`
47
+ if (watchers[key]) watchers[key].forEach(fn => fn(data))
48
+ }
49
+ })
50
+
51
+ function environment() {
52
+ return send({ type: 'environment' })
53
+ }
54
+
55
+ function state(scope, user, domain) {
56
+ let watchFn
57
+ let resolveKey
58
+ const key = new Promise(r => resolveKey = r)
59
+
60
+ const promise = new Promise(async resolve => {
61
+ const { domain: d, auth: { user: u }, scope: s } = await environment()
62
+ domain = domain || d
63
+ user = user || u
64
+ scope = scope || s
65
+ resolveKey(`${domain}/${user}/${scope}`)
66
+ resolve(send({ type: 'state', scope, user, domain }))
67
+ })
68
+
69
+ promise.watch = fn => {
70
+ key
71
+ .then( async k => {
72
+ if (!watchers[k]) watchers[k] = []
73
+ watchers[k].push(fn)
74
+ watchFn = fn
75
+ })
76
+ return promise
77
+ }
78
+
79
+ promise.unwatch = () => {
80
+ key
81
+ .then( k => {
82
+ watchers[k] = watchers[k].filter(x => x !== watchFn)
83
+ if (watchers[k].length === 0) delete watchers[k]
84
+ })
85
+
86
+ return promise
87
+ }
88
+
89
+ return promise
90
+ }
91
+
92
+ async function patch(root, scopes) {
93
+ // TODO: consider watch function added to return to receive progress
94
+ return send({ type: 'patch', root, scopes })
95
+ }
96
+
97
+ async function mutate (scope, initialize=true) {
98
+ const handlePatch = patch => interact(scope, patch)
99
+
100
+ if (!scope) scope = (await environment()).scope
101
+
102
+ if (initialize) {
103
+ // TODO: consider setting up watcher and updating state
104
+ return new MutableProxy(await state(scope) || {}, handlePatch)
105
+ }
106
+ else return new MutableProxy({}, handlePatch)
107
+ }
108
+
109
+ function interact(scope, patch) {
110
+ return send({ type: 'interact', scope, patch })
111
+ }
112
+
113
+ async function upload(name, type, data, id=uuid()) {
114
+ const url = await send({ type: 'upload', name, contentType: type, id })
115
+
116
+ if (!data) return url
117
+ else {
118
+ console.log('UPLOADING TO URL!!!!!!!!!!!!!!', id, url)
119
+ const headers = { 'Content-Type': type }
120
+ const response = await fetch(url, {method: 'PUT', headers, body: data})
121
+ const { ok, statusText } = response
122
+
123
+ console.log(ok, statusText)
124
+
125
+ if (ok) return id
126
+ else throw new Error(statusText)
127
+ }
128
+ }
129
+
130
+ async function download(id, passthrough=false) {
131
+ const url = await send({ type: 'download', id })
132
+ return passthrough ? url : fetch(url)
133
+ }
134
+
135
+ function metadata(id) {
136
+ return state(id, 'metadata', 'core')
137
+ }
138
+
139
+ function login(provider, username, password) {
140
+ return send({ type: 'login', provider, username, password })
141
+ }
142
+
143
+ function logout() { return send({ type: 'logout' }) }
144
+
145
+ function disconnect() { return send({ type: 'disconnect' }) }
146
+
147
+ function reconnect() { return send({ type: 'reconnect' }) }
148
+
149
+ return {
150
+ login,
151
+ logout,
152
+ environment,
153
+ state,
154
+ mutate,
155
+ patch,
156
+ interact,
157
+ metadata,
158
+ upload,
159
+ download,
160
+ disconnect,
161
+ reconnect,
162
+ Experience
163
+ }
164
+ }
@@ -0,0 +1,126 @@
1
+ import { v1 as uuid, validate as validateUUID } from 'npm/unscoped/uuid/9.0.0'
2
+
3
+ const copy = x => JSON.parse(JSON.stringify(x))
4
+
5
+ const watchers = {}
6
+
7
+ export default function Experience(environment, iframe) {
8
+ const postMessageQueue = []
9
+ let frameLoaded = false
10
+ let embeddedAgentInitialized = false
11
+ const session = uuid()
12
+
13
+ const postMessage = m => new Promise((resolve, reject) => {
14
+ const message = { ...copy(m), session }
15
+ postMessageQueue.push({ message, sent: resolve })
16
+ if (frameLoaded) processPostMessageQueue()
17
+ })
18
+
19
+ const processPostMessageQueue = () => {
20
+ while (iframe.parentNode && postMessageQueue.length) {
21
+ const { message, sent } = postMessageQueue.shift()
22
+ iframe.contentWindow.postMessage(message, '*')
23
+ sent()
24
+ }
25
+ }
26
+
27
+ this.remove = () => {
28
+ if (iframe.parentNode) iframe.parentNode.removeChild(iframe)
29
+ }
30
+
31
+ const handleMessage = async message => {
32
+ const { requestId, type } = message
33
+
34
+ const sendDown = response => {
35
+ if (message.type === 'environment') Object.assign(response, environment)
36
+ return postMessage({ requestId, response })
37
+ }
38
+
39
+ if (type === 'error') {
40
+ console.error(message)
41
+ sendDown({})
42
+ }
43
+ else if (type === 'environment') {
44
+ sendDown(await Agent.environment())
45
+ }
46
+ else if (type === 'interact') {
47
+ await Agent.interact(message.scope, message.patch)
48
+ sendDown({}) // TODO: might want to send down the interaction index
49
+ }
50
+ else if (type === 'state') {
51
+ const { scope, user, domain } = message
52
+ const statePromise = Agent.state(scope, user, domain)
53
+
54
+ const key = `${session}/${domain}/${user}/${scope}`
55
+
56
+ // only watch once per key per experience session
57
+ if (!watchers[key]) {
58
+ watchers[key] = statePromise.unwatch // TODO: consider how to use this...
59
+ statePromise.watch(postMessage)
60
+ }
61
+
62
+ sendDown(await statePromise)
63
+ }
64
+ else if (type === 'patch') {
65
+ const { root, scopes } = message
66
+ sendDown(await Agent.patch(root, scopes))
67
+ }
68
+ else if (type === 'upload') {
69
+ const { name, contentType, id } = message
70
+ sendDown(await Agent.upload(name, contentType, false, id))
71
+ }
72
+ else if (type === 'download') {
73
+ sendDown(await Agent.download(message.id, true))
74
+ }
75
+ else if (type === 'login') {
76
+ const { provider, username, password } = message
77
+ sendDown(await Agent.login(provider, username, password))
78
+ }
79
+ else if (type === 'logout') Agent.logout()
80
+ else if (type === 'disconnect') sendDown(await Agent.disconnect())
81
+ else if (type === 'reconnect') sendDown(await Agent.reconnect())
82
+ else {
83
+ console.log('Unknown message type passed up...', message)
84
+ sendDown({})
85
+ }
86
+ }
87
+
88
+ window.addEventListener('message', ({ data }) => {
89
+ if (data.session === session) {
90
+ embeddedAgentInitialized = true
91
+ // TODO: ensure message index ordering!!!!!!!!!!!!!!!!!!!! (no order guarantee given in postMessage protocol, so we need to make a little buffer here)
92
+ handleMessage(data)
93
+ }
94
+ })
95
+
96
+ // write in a temporary loading notification while frame loads
97
+ const cw = iframe.contentWindow
98
+ if (cw) cw.document.body.innerHTML = 'Loading...'
99
+
100
+ // TODO: make sure content security policy headers for embedded domain always restrict iframe
101
+ // src to only self for embedded domain
102
+ Agent
103
+ .environment()
104
+ .then(async ({ embed }) => {
105
+ iframe.onload = () => {
106
+ frameLoaded = true
107
+ processPostMessageQueue()
108
+ }
109
+ const { protocol, pathname } = window.location
110
+ if (validateUUID(environment.content)) {
111
+ // TODO: fix development port workararounds for embed
112
+ if (protocol === 'http:') embed = embed.replace(':32002', ':32020')
113
+ iframe.src = `${protocol}//${embed}${pathname}?s=${session}`
114
+ }
115
+ else {
116
+ iframe.src = environment.content
117
+ }
118
+
119
+ while(!embeddedAgentInitialized) {
120
+ postMessage({ type: 'setup', session })
121
+ await new Promise(r => setTimeout(r, 100))
122
+ }
123
+ })
124
+
125
+ return this
126
+ }
@@ -0,0 +1,60 @@
1
+ import { initializeApp } from 'npm/unscoped/firebase/9.12.1/app'
2
+ import {
3
+ getAuth,
4
+ signOut as firebaseSignOut,
5
+ GoogleAuthProvider,
6
+ OAuthProvider,
7
+ onAuthStateChanged,
8
+ signInWithRedirect,
9
+ signInWithEmailAndPassword,
10
+ signInAnonymously
11
+ } from 'npm/unscoped/firebase/9.12.1/auth'
12
+
13
+ initializeApp({
14
+ "apiKey": "AIzaSyAxjYuF-2JmXxlXnGgNu2CO4Q41EAtUgrY",
15
+ "authDomain": "opensourcelearningplatform.firebaseapp.com",
16
+ "databaseURL": "https://opensourcelearningplatform.firebaseio.com",
17
+ "projectId": "opensourcelearningplatform",
18
+ "storageBucket": "opensourcelearningplatform.appspot.com",
19
+ "messagingSenderId": "831020253582",
20
+ "appId: 1:831020253582": "web:6e1dfc6e89f5c2107164df",
21
+ "measurementId": "G-8QD6034RJ0"
22
+ })
23
+
24
+ let currentUser
25
+ const authClient = getAuth()
26
+ const onAuth = fn => onAuthStateChanged(authClient, user => {
27
+ if (!user) signInAnonymously(authClient)
28
+ else if (user.uid !== currentUser) {
29
+ const { uid } = user
30
+ currentUser = uid
31
+ const provider = user.isAnonymous ? 'anonymous' : user.providerData[0].providerId
32
+ fn({ user: uid, provider, getIdToken: () => user.getIdToken() })
33
+ }
34
+ })
35
+
36
+ const authProviders = {
37
+ google: () => new GoogleAuthProvider(),
38
+ microsoft: () => new OAuthProvider('microsoft.com')
39
+ }
40
+
41
+ const login = async (provider, username, password) => {
42
+ if (authProviders[provider]) {
43
+ signInWithRedirect(authClient, authProviders[provider]())
44
+ return { success: true }
45
+ }
46
+ else if (provider === 'email') {
47
+ try {
48
+ await signInWithEmailAndPassword(authClient, username, password)
49
+ location.reload()
50
+ }
51
+ catch (error) { return { success: false } }
52
+ }
53
+ }
54
+
55
+ const logout = async () => {
56
+ await firebaseSignOut(authClient)
57
+ location.reload()
58
+ }
59
+
60
+ export { onAuth, login, logout }
@@ -0,0 +1,21 @@
1
+ import { v1 as uuid } from 'npm/unscoped/uuid/9.0.0'
2
+ import { applyPatch } from 'npm/unscoped/fast-json-patch/3.1.1'
3
+ import { onAuth, login, logout } from './firebase-auth.js'
4
+ import GenericAgent from '../generic.js'
5
+ import Experience from './experience.js'
6
+
7
+ const Agent = new GenericAgent({
8
+ host: window.location.host,
9
+ protocol: window.location.protocol === 'https:' ? 'wss' : 'ws',
10
+ token: new Promise(r => onAuth(({ getIdToken: g }) => r(g()))),
11
+ WebSocket,
12
+ uuid,
13
+ fetch,
14
+ applyPatch,
15
+ login,
16
+ logout
17
+ })
18
+
19
+ Agent.Experience = Experience
20
+
21
+ export default Agent
package/agents/deno.js ADDED
@@ -0,0 +1,14 @@
1
+ import { applyPatch } from 'https://esm.sh/fast-json-patch@3.1.1'
2
+ import Agent from './generic.js'
3
+
4
+ const SERVE_HOST = Deno.env.get("SERVE_HOST")
5
+ const SERVICE_ACCOUNT_TOKEN = Deno.env.get("SERVICE_ACCOUNT_TOKEN")
6
+
7
+ export default new Agent({
8
+ host: SERVE_HOST,
9
+ token: Deno.readTextFile(SERVICE_ACCOUNT_TOKEN),
10
+ WebSocket,
11
+ uuid: () => crypto.randomUUID(),
12
+ fetch,
13
+ applyPatch
14
+ })
@@ -0,0 +1,305 @@
1
+ import MutableProxy from '../persistence/json.js'
2
+
3
+ // transform our custom path implementation to the standard JSONPatch path
4
+ function standardJSONPatch(patch) {
5
+ return patch.map(p => {
6
+ return {...p, path: '/' + p.path.map(sanitizeJSONPatchPathSegment).join('/')}
7
+ })
8
+ }
9
+
10
+ function sanitizeJSONPatchPathSegment(s) {
11
+ if (typeof s === "string") return s.replaceAll('~', '~0').replaceAll('/', '~1')
12
+ else return s
13
+ }
14
+
15
+ export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fetch, applyPatch, login, logout }) {
16
+ let ws
17
+ let user
18
+ let domain
19
+ const session = uuid()
20
+ let server
21
+ let si = -1
22
+ let authed = false
23
+ const states = {}
24
+ const responses = {}
25
+ const watchers = {}
26
+ const lastInteractionUpdateForWatchedScope = {}
27
+ const messageQueue = []
28
+ let resolveEnvironment
29
+ let disconnected = false
30
+ const environment = new Promise(r => resolveEnvironment = r)
31
+
32
+ const sessionData = new MutableProxy({}, patch => queueMessage({scope: 'sessions', patch}))
33
+
34
+ sessionData[session] = {
35
+ subscriptions: {},
36
+ uploads: {},
37
+ downloads: {},
38
+ patches: {}
39
+ }
40
+
41
+ function removeWatcher(key, fn) {
42
+ const watcherIndex = watchers[key].findIndex(x => x === fn)
43
+ if (watcherIndex > -1) watchers[key].splice(watcherIndex, 1)
44
+ else console.warn('TRIED TO REMOVE WATCHER THAT DOES NOT EXIST')
45
+ }
46
+
47
+ this.environment = function () { return environment }
48
+
49
+ this.state = state
50
+ this.upload = upload
51
+ this.download = download
52
+ this.interact = interact
53
+ this.patch = patch
54
+ this.mutate = mutate
55
+ this.metadata = metadata
56
+
57
+ this.login = login
58
+ this.logout = logout
59
+ this.disconnect = disconnect
60
+ this.reconnect = reconnect
61
+
62
+ initWS()
63
+
64
+ let lastSentSI = -1
65
+ function flushMessageQueue() {
66
+ // TODO: probably want to make this loop async so we don't try and push more to
67
+ // a closed connection
68
+ while (authed && ws.readyState === WebSocket.OPEN && lastSentSI+1 < messageQueue.length) {
69
+ lastSentSI += 1
70
+ ws.send(JSON.stringify(messageQueue[lastSentSI]))
71
+ }
72
+ }
73
+
74
+ function lastMessageResponse() { // TODO: handle error responses
75
+ return new Promise(r => responses[si].push(r))
76
+ }
77
+
78
+ function queueMessage(message) {
79
+ si += 1
80
+ responses[si] = []
81
+ messageQueue.push({...message, si, ts: Date.now()})
82
+ flushMessageQueue()
83
+ }
84
+
85
+ function initWS() {
86
+ ws = new WebSocket(`${protocol}://${host}`)
87
+
88
+ ws.onopen = async () => ws.send(JSON.stringify({ token: await token, session }))
89
+
90
+ ws.onmessage = async ({ data }) => {
91
+ try {
92
+ const message = JSON.parse(data)
93
+ if (message.error) console.warn('ERROR RESPONSE', message)
94
+
95
+ if (!authed) {
96
+ authed = true
97
+ if (!user) { // this is the first authed websocket connection
98
+ user = message.auth.user
99
+ domain = message.domain
100
+ server = message.server
101
+ resolveEnvironment(message)
102
+ }
103
+ else {
104
+ lastSentSI = message.ack
105
+ }
106
+ flushMessageQueue()
107
+ }
108
+ else {
109
+ if (message.si !== undefined) {
110
+ if (responses[message.si]) {
111
+ // TODO: remove "acknowledged" messages fromt queue and do accounting with si
112
+ responses[message.si].forEach(fn => fn(message))
113
+ delete responses[message.si]
114
+ ws.send(JSON.stringify({ack: message.si})) // acknowledgement that we have received the response for this message
115
+ }
116
+ else {
117
+ // TODO: consider what to do here... probably want to throw error if in dev env
118
+ console.warn('received MULTIPLE responses for message with si', message.si, message)
119
+ }
120
+ }
121
+ else {
122
+ const key = `${message.domain}/${message.user}/${message.scope}`
123
+ if (watchers[key]) {
124
+ if (sessionData[session].subscriptions[key] + 1 !== message.ii) {
125
+ // TODO: this seems to be an error that happens with decent regularity (an answer with a given si was skipped/failed)
126
+ // we should be wary of out-of-order ii being passed down (maybe need to wait for older ones???)
127
+ console.warn('UNEXPECTED UPDATE INTERACTION INDEX!!!!!!!!!!! last index in session', sessionData[session].subscriptions[key], ' passed index ', message.ii)
128
+ }
129
+ states[key] = await states[key] || {}
130
+
131
+ const lastResetPatchIndex = message.patch.findLastIndex(p => p.path.length === 0)
132
+ if (lastResetPatchIndex > -1) states[key] = message.patch[lastResetPatchIndex].value
133
+
134
+ applyPatch(states[key], standardJSONPatch(message.patch.slice(lastResetPatchIndex + 1)))
135
+ watchers[key].forEach(fn => fn({ ...message, state: states[key] }))
136
+ sessionData[session].subscriptions[key] = message.ii
137
+ }
138
+ }
139
+ }
140
+ }
141
+ catch (error) {
142
+ console.error('ERROR HANDLING WS MESSAGE', error)
143
+ }
144
+ }
145
+
146
+ ws.onerror = async error => {
147
+ //console.log('WS CONNECTION ERROR', error.message)
148
+ }
149
+
150
+ ws.onclose = async () => {
151
+ authed = false
152
+ console.log(`CLOSED DOMAIN ${domain} USER ${user} SESSION ${session} CONNECTION TO SERVER ${server}`)
153
+ if (!disconnected) initWS() // TODO: don't do this if we are purposefully unloading...
154
+ }
155
+ }
156
+
157
+ function state(scope, u, d) {
158
+ let watchFn
159
+ let resolveKey
160
+ const key = new Promise(r => resolveKey = r)
161
+
162
+ const promise = new Promise(async resolveState => {
163
+ await environment // environment not set until first connection
164
+
165
+ if (!u) u = user
166
+ if (!d) d = domain
167
+
168
+ const k = `${d || domain}/${u || user}/${scope}`
169
+ resolveKey(k)
170
+
171
+ if (states[k] === undefined) {
172
+ watchers[k] = []
173
+ states[k] = new Promise(async resolve => {
174
+ sessionData[session].subscriptions[k] = null
175
+ const { ii, state } = await lastMessageResponse()
176
+ sessionData[session].subscriptions[k] = ii
177
+ resolve(state)
178
+ })
179
+ }
180
+
181
+ // TODO: make sure to get the state that represents the last interaction we sent
182
+ // when this "state" method was called!!!!!!!!!!!!!!!!!!!!!!!
183
+ // Right now this is simply returning whatever we have at the time we get here
184
+ // when some interaction responses may not have been applied yet...
185
+ // POSSIBLE: wait for most recent interaction update response for key k that we sent...
186
+ await lastInteractionUpdateForWatchedScope[k]
187
+ // TODO: probably something like await updateMessageReceivedForLastInteractionWeSentForKey[k]
188
+ resolveState(states[k])
189
+ })
190
+
191
+ promise.watch = fn => {
192
+ key
193
+ .then( k => {
194
+ if (watchFn) throw new Error('Only one watcher allowed') // TODO: consider allowing more watchers per state call
195
+ watchers[k].push(fn)
196
+ watchFn = fn
197
+ })
198
+ return promise
199
+ }
200
+
201
+ promise.unwatch = async () => {
202
+ const k = await key
203
+ removeWatcher(k, watchFn)
204
+ if (watchers[k].length === 0) {
205
+ delete sessionData[session].subscriptions[k]
206
+ delete watchers[k]
207
+ }
208
+ }
209
+
210
+ return promise
211
+ }
212
+
213
+ // TODO: if no data, set up streaming upload
214
+ async function upload(name, type, data, id=uuid()) {
215
+ // TODO: include data size info...
216
+ sessionData[session].uploads[id] = { url: null, sent: 0, name, type }
217
+ const { url } = await lastMessageResponse()
218
+ sessionData[session].uploads[id].url = url
219
+
220
+ if (!data) return url
221
+ else {
222
+ const headers = { 'Content-Type': type }
223
+ const response = await fetch(url, {method: 'PUT', headers, body: data})
224
+ const { ok, statusText } = response
225
+
226
+ if (ok) return id
227
+ else throw new Error(statusText)
228
+ }
229
+ }
230
+
231
+ async function download(id, passthrough=false) {
232
+ sessionData[session].downloads[id] = { url: null, size: null, sent: 0 }
233
+ const { url } = await lastMessageResponse()
234
+ sessionData[session].downloads[id].url = url
235
+
236
+ if (passthrough) return url
237
+ else {
238
+ const response = await fetch(url)
239
+ const { ok, statusText } = response
240
+
241
+ if (ok) return response
242
+ else throw new Error(statusText)
243
+ }
244
+ }
245
+
246
+ async function patch(root, scopes) {
247
+ sessionData[session].patches[uuid()] = { root, scopes }
248
+ const { swaps } = await lastMessageResponse()
249
+ return { swaps }
250
+ }
251
+
252
+ async function interact(scope, patch) {
253
+ queueMessage({scope, patch})
254
+ const response = lastMessageResponse()
255
+
256
+ await environment
257
+
258
+ const key = `${domain}/${user}/${scope}`
259
+
260
+ // if we are watching this key, we want to keep track of last interaction we fired
261
+ if (states[key] !== undefined) {
262
+ let resolve
263
+ lastInteractionUpdateForWatchedScope[key] = new Promise(r => resolve = r)
264
+
265
+ const { ii } = await response
266
+
267
+ const resolveAndUnwatch = (update) => {
268
+ if (update.ii === ii) {
269
+ resolve(ii)
270
+ removeWatcher(key, resolveAndUnwatch)
271
+ }
272
+ }
273
+
274
+ watchers[key].push(resolveAndUnwatch)
275
+
276
+ return { ii }
277
+ }
278
+ else {
279
+ const { ii } = await response
280
+ return { ii }
281
+ }
282
+ }
283
+
284
+ async function mutate(scope, initialize=true) {
285
+ const initial = initialize ? await this.state(scope) || {} : {}
286
+ return new MutableProxy(initial, patch => interact(scope, patch))
287
+ }
288
+
289
+ function metadata(id) {
290
+ return state(id, 'metadata', 'core')
291
+ }
292
+
293
+ function disconnect() {
294
+ console.log('DISCONNECTED AGENT!!!!!!!!!!!!!!!')
295
+ disconnected = true
296
+ ws.close()
297
+ }
298
+
299
+ function reconnect() {
300
+ console.log('RECONNECTED AGENT!!!!!!!!!!!!!!!')
301
+ initWS()
302
+ }
303
+
304
+ return this
305
+ }
package/agents/node.js ADDED
@@ -0,0 +1,17 @@
1
+ import fs from 'fs'
2
+ import WebSocket from 'ws'
3
+ import { v1 as uuid } from 'uuid'
4
+ import fastJSONPatch from 'fast-json-patch'
5
+ import fetch from 'node-fetch'
6
+ import Agent from './generic.js'
7
+
8
+ const { SERVE_HOST, SERVICE_ACCOUNT_TOKEN } = process.env
9
+
10
+ export default new Agent({
11
+ host: SERVE_HOST,
12
+ token: fs.promises.readFile(SERVICE_ACCOUNT_TOKEN).then(f => f.toString()),
13
+ WebSocket,
14
+ uuid,
15
+ fetch,
16
+ applyPatch: fastJSONPatch.applyPatch
17
+ })
@@ -0,0 +1,67 @@
1
+ import { extensionToType } from './metadata-utils.js'
2
+
3
+ // these utility functions work in a web environment
4
+ const fileFromEntry = entry => new Promise((s,e) => entry.file(s,e))
5
+ const getTypeFromEntry = entry => fileFromEntry(entry).then(file => file.type)
6
+ const getDataFromEntry = entry => fileFromEntry(entry).then(file => file.arrayBuffer())
7
+
8
+ // entries is of form: [{ fullPath, name }, ....]
9
+ export default async function createFolderContent(entries, upload, uuid, getType=getTypeFromEntry, getData=getDataFromEntry) {
10
+ // TODO: swap out internal references in files
11
+ const swaps = {}
12
+ const roots = {}
13
+ const filenames = []
14
+ entries.forEach(e => {
15
+ const id = uuid(e.fullPath)
16
+ swaps[e.fullPath] = id
17
+ roots[id] = true
18
+ filenames.push(e.name)
19
+ })
20
+ const metadata = []
21
+ const body = []
22
+ await Promise.all(
23
+ entries.map(async entry => {
24
+ const id = swaps[entry.fullPath]
25
+ const type = await getType(entry)
26
+ let data = await getData(entry)
27
+ if (!type.startsWith('image/')) {
28
+ // TODO: this is probably the most fragile part and we probably want unit tests
29
+ // for reasonable return values for getData
30
+ if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
31
+ data = new TextDecoder("utf-8").decode(data)
32
+ }
33
+ Object
34
+ .entries(swaps)
35
+ .forEach(([from, to]) => {
36
+ let reference = ''
37
+ if (from === entry.fullPath) reference = `./${entry.name}`
38
+ else {
39
+ const e = entry.fullPath.split('/')
40
+ const f = from.split('/')
41
+ while (e[0] !== undefined && f[0] !== undefined && e[0] === f[0]) {
42
+ e.shift()
43
+ f.shift()
44
+ }
45
+ // for every entry dir past the common prefix, add a ../ for the reference
46
+ if (e.length > 1) e.slice(0,-1).forEach(() => reference += '../')
47
+ else reference = './'
48
+
49
+ reference += f.join('/')
50
+ }
51
+ if (data.includes(reference)) {
52
+ // if found a reference to this swap, then
53
+ // the from reference cannot be a root
54
+ // TODO: fix the bug where this will delete root content that is part of a cycle
55
+ delete roots[to]
56
+ data = data.replaceAll(reference, to)
57
+ }
58
+ })
59
+ }
60
+ const segments = entry.name.split('.')
61
+ const contentType = extensionToType(segments[segments.length - 1])
62
+
63
+ await upload(entry.fullPath, contentType, data, id)
64
+ })
65
+ )
66
+ return Object.keys(roots)
67
+ }
@@ -0,0 +1,20 @@
1
+ const tePairs = [
2
+ ["application/javascript;syntax=vue-template", "vue"],
3
+ ["application/javascript", "js"],
4
+ ["application/json", "json"],
5
+ ["text/html", "html"],
6
+ ["text/css", "css"],
7
+ ["text/plain", "txt"],
8
+ ["text/yaml", "yaml"],
9
+ ["image/png", "png"],
10
+ ["text/plain;syntax=Dockerfile", "Dockerfile"]
11
+ ]
12
+
13
+ const typeToExtensionMap = tePairs.reduce((o,[t,e]) => (o[t]=e, o), {})
14
+ const extensionToTypeMap = tePairs.reduce((o,[t,e]) => (o[e]=t, o), {})
15
+
16
+ // TODO: do better fallback matching (do a smarter look at params/subtypes)
17
+ const typeToExtension = t => typeToExtensionMap[t] || typeToExtensionMap[t.split(';')[0]]
18
+ const extensionToType = e => extensionToTypeMap[e] || "text/plain"
19
+
20
+ export { typeToExtension, extensionToType }
package/package.js ADDED
@@ -0,0 +1,6 @@
1
+ // TODO: export browser, node, deno, and generic agents
2
+ export { default as BrowserAgent } from './lib/agents/browser/embedded.js'
3
+ export { default as NodeAgent } from './lib/agents/node.js'
4
+ export { default as DenoAgent } from './lib/agents/deno.js'
5
+ export { default as GenericAgent } from './lib/agents/generic.js'
6
+ export { default as createPersistentVuexStore } from './persistence/vuex.js'
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@knowlearning/agents",
3
+ "version": "0.0.1",
4
+ "description": "API for embedding applications in KnowLearning systems.",
5
+ "main": "package.js",
6
+ "type": "module",
7
+ "directories": {
8
+ "example": "examples",
9
+ "test": "test"
10
+ },
11
+ "scripts": {
12
+ "test": "echo \"Error: no test specified\" && exit 1"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/knowlearning/core.git"
17
+ },
18
+ "author": "KnowLearning",
19
+ "license": "MPL-2.0",
20
+ "bugs": {
21
+ "url": "https://github.com/knowlearning/core/issues"
22
+ },
23
+ "homepage": "https://github.com/knowlearning/core#readme",
24
+ "dependencies": {
25
+ "fast-json-patch": "^3.1.1",
26
+ "uuid": "^8.3.2"
27
+ }
28
+ }
29
+
@@ -0,0 +1,92 @@
1
+ const proxies = new Set()
2
+
3
+ // TODO make serialization more reliable... basically: handle if prop includes "
4
+ function serializePath(path) {
5
+ return `/${path.join('/')}`
6
+ }
7
+
8
+ // TODO: add extra argument for "ignorePrefixes" to ignore interactions at certain paths
9
+ export default function MutableProxy(state, interact, ephemeralPaths={}, parentPath=[], rootState) {
10
+ if (ephemeralPaths[serializePath(parentPath)]) return state
11
+ if (!rootState) rootState = state
12
+ if (proxies.has(state)) {
13
+ // TODO: simply return with no effect if redundantly setting state like a[x] = a[x]
14
+ throw new Error(`Cannot add mutable state to multiple mutable parents. Attempted Path: ${serializePath(parentPath)}`)
15
+ }
16
+
17
+ const isArray = Array.isArray(state)
18
+
19
+ const childMutableProxy = (prop, value) => MutableProxy(
20
+ value,
21
+ interact,
22
+ ephemeralPaths,
23
+ [...parentPath, prop],
24
+ rootState
25
+ )
26
+
27
+ // recursively ensure child objects are converted to proxies (unless they are in an ephemeral path)
28
+ Object
29
+ .entries(state)
30
+ .filter(([, value]) => value instanceof Object)
31
+ .forEach(([key, value]) => state[key] = childMutableProxy(key, value))
32
+
33
+ const traps = {
34
+ set(target, prop, value) {
35
+ const path = [...parentPath, prop]
36
+ const serializedPath = serializePath(path)
37
+
38
+ if (ephemeralPaths[serializedPath]) {
39
+ target[prop] = value
40
+ return true
41
+ }
42
+
43
+ if (isArray) {
44
+ if (!/^\d+$/.test(prop)) {
45
+ target[prop] = value
46
+ return true
47
+ }
48
+ else prop = parseInt(prop)
49
+ }
50
+
51
+ if (value === undefined) {
52
+ console.log('EXTRA ERROR INFO. target, prop, value', target, prop, value)
53
+ throw new Error(`Setting properties to undefined is not supported. Please use a delete statement. Attempted to set ${serializedPath}`)
54
+ }
55
+
56
+ // TODO: probably want to batch interactions that represent array mutations... and/or do
57
+ // something smart around insertions/deletions
58
+ // TODO: if is array and prop is last element, consider passing -1 as prop
59
+ interact([{
60
+ op: target[prop] ? 'replace' : 'add',
61
+ value: JSON.parse(JSON.stringify(value)), // TODO: more efficient sanitization
62
+ path
63
+ }])
64
+
65
+ if (value instanceof Object) target[prop] = childMutableProxy(prop, value)
66
+ else target[prop] = value
67
+
68
+ return true
69
+ },
70
+ deleteProperty(target, prop) {
71
+ if (prop in target) {
72
+ proxies.delete(proxy)
73
+ delete target[prop]
74
+
75
+ if (isArray) {
76
+ if (!/^\d+$/.test(prop)) return true
77
+ else prop = parseInt(prop)
78
+ }
79
+
80
+ // TODO: if is array and prop is last element, consider passing -1 as prop
81
+ const path = [...parentPath, prop]
82
+ if (!ephemeralPaths[serializePath(path)]) interact([{ op: 'remove', path }])
83
+ }
84
+ return true
85
+ }
86
+ }
87
+
88
+ const proxy = new Proxy(state, traps)
89
+ proxies.add(proxy)
90
+
91
+ return proxy
92
+ }
@@ -0,0 +1,84 @@
1
+ import { createStore } from 'vuex'
2
+ import MutableProxy from './json.js'
3
+
4
+ // TODO: consider path serialization approach. Also consider just using a mutable proxy from agent.state...
5
+
6
+ const copy = x => JSON.parse(JSON.stringify(x))
7
+
8
+ export default async function (storeDefinition) {
9
+ // Set up persistance
10
+ const scope ='whatevah' // TODO: resolve what scope should be...
11
+ // TODO: assess the function of this from the old implementation
12
+ const savedState = await Agent.state(scope)
13
+
14
+ const scopedPaths = getScopedPaths(storeDefinition)
15
+ const stateAttachedStore = await attachModuleState(savedState, storeDefinition, scopedPaths)
16
+ const s = stateAttachedStore.state
17
+ const originalState = s instanceof Function ? s() : s
18
+ const handlePatch = patch => {
19
+ console.log('PATCHING STATE', patch)
20
+ Agent.interact(scope, patch)
21
+ }
22
+
23
+ if (savedState === null) handlePatch([{ op: 'add', path: [], value: copy(originalState) }])
24
+
25
+ // ephemeral paths for the root mutable proxy are any paths with a scope specified (including null scopes)
26
+ const ephemeralPaths = {}
27
+ Object.keys(scopedPaths).forEach(key => ephemeralPaths[key] = true)
28
+
29
+ stateAttachedStore.state = () => MutableProxy(savedState || originalState, handlePatch, ephemeralPaths)
30
+ return createStore(stateAttachedStore)
31
+ }
32
+
33
+ // TODO: sanitize module names for path serialization approach
34
+ function descendantPaths(path, pathSet) {
35
+ return (
36
+ Object
37
+ .keys(pathSet)
38
+ .filter(p => p.startsWith(path + '/')) // filter for descendant paths
39
+ .reduce((acc, p) => {
40
+ const descendantPart = p.slice(path.length)
41
+ acc[descendantPart] = pathSet[p]
42
+ return acc
43
+ }, {})
44
+ )
45
+ }
46
+
47
+ async function attachModuleState(state, module, scopedPaths, path='') {
48
+ await Promise.all(
49
+ Object
50
+ .entries(module.modules || {})
51
+ .map(async ([subModuleName, subModule]) => {
52
+ const subModuleStartState = state ? state[subModuleName] : null
53
+
54
+ // vuex expects to initialize state from submodules
55
+ if (subModuleStartState) delete state[subModuleName]
56
+
57
+ module.modules[subModuleName] = await attachModuleState(subModuleStartState, subModule, scopedPaths, `${path}/${subModuleName}`)
58
+ })
59
+ )
60
+
61
+ // if our path is in scoped paths, return new Mutable proxy attached to scope
62
+ const scope = scopedPaths[path]
63
+ if (scope) {
64
+ const handlePatch = patch => Agent.interact(scope, patch)
65
+ const initState = await Agent.state(scope)
66
+ const ephemeralPaths = descendantPaths(path, scopedPaths)
67
+ state = MutableProxy(initState || {}, handlePatch, ephemeralPaths)
68
+ if (!initState) Object.assign(state, module.state())
69
+ }
70
+
71
+ return state ? { ...module, state: () => state } : module
72
+ }
73
+
74
+ function getScopedPaths(module, path="", paths={}) {
75
+ if (module.scope !== undefined) paths[path] = module.scope
76
+
77
+ Object
78
+ .entries(module.modules || {})
79
+ .forEach(([subModuleName, subModule]) => {
80
+ getScopedPaths(subModule, `${path}/${subModuleName}`, paths)
81
+ })
82
+
83
+ return paths
84
+ }