-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathstore.ts
More file actions
759 lines (636 loc) · 22.1 KB
/
store.ts
File metadata and controls
759 lines (636 loc) · 22.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
import {
removeCookieAuthentication,
setCookieAuthentication,
} from './authentication.js';
import { EventManager } from './EventManager.js';
import {
Agent,
Datatype,
datatypeFromUrl,
Client,
Resource,
unknownSubject,
urls,
Commit,
JSONADParser,
} from './index.js';
import { authenticate, fetchWebSocket, startWebsocket } from './websockets.js';
/** Function called when a resource is updated or removed */
type ResourceCallback = (resource: Resource) => void;
/** Callback called when the stores agent changes */
type AgentCallback = (agent: Agent | undefined) => void;
type ErrorCallback = (e: Error) => void;
type Fetch = typeof fetch;
export interface StoreOpts {
/** The default store URL, where to send commits and where to create new instances */
serverUrl?: string;
/** Default Agent, used for signing commits. Is required for posting things. */
agent?: Agent;
}
export enum StoreEvents {
/**
* Whenever `Resource.save()` is called, so only when the user of this library
* performs a save action.
*/
ResourceSaved = 'resource-saved',
/** User perform a Remove action */
ResourceRemoved = 'resource-removed',
/**
* User explicitly created a Resource through a conscious action, e.g. through
* the SideBar.
*/
ResourceManuallyCreated = 'resource-manually-created',
/** Event that gets called whenever the stores agent changes */
AgentChanged = 'agent-changed',
/** Event that gets called whenever the store encounters an error */
Error = 'error',
}
/**
* Handlers are functions that are called when a certain event occurs.
*/
type StoreEventHandlers = {
[StoreEvents.ResourceSaved]: ResourceCallback;
[StoreEvents.ResourceRemoved]: ResourceCallback;
[StoreEvents.ResourceManuallyCreated]: ResourceCallback;
[StoreEvents.AgentChanged]: AgentCallback;
[StoreEvents.Error]: ErrorCallback;
};
/** Returns True if the client has WebSocket support */
const supportsWebSockets = () => typeof WebSocket !== 'undefined';
/**
* An in memory store that has a bunch of usefful methods for retrieving Atomic
* Data Resources. It is also resposible for keeping the Resources in sync with
* Subscribers (components that use the Resource), and for managing the current
* Agent (User).
*/
export class Store {
/** A list of all functions that need to be called when a certain resource is updated */
public subscribers: Map<string, Array<ResourceCallback>>;
private injectedFetch: Fetch;
/**
* The base URL of an Atomic Server. This is where to send commits, create new
* instances, search, etc.
*/
private serverUrl: string;
/** All the resources of the store */
private _resources: Map<string, Resource>;
/** Current Agent, used for signing commits. Is required for posting things. */
private agent?: Agent;
/** Mapped from origin to websocket */
private webSockets: Map<string, WebSocket>;
private eventManager = new EventManager<StoreEvents, StoreEventHandlers>();
private client: Client;
public constructor(opts: StoreOpts = {}) {
this._resources = new Map();
this.webSockets = new Map();
this.subscribers = new Map();
opts.serverUrl && this.setServerUrl(opts.serverUrl);
opts.agent && this.setAgent(opts.agent);
this.client = new Client(this.injectedFetch);
// We need to bind this method because it is passed down by other functions
this.getAgent = this.getAgent.bind(this);
this.setAgent = this.setAgent.bind(this);
}
/** All the resources of the store */
public get resources(): Map<string, Resource> {
return this._resources;
}
/** Inject a custom fetch implementation to use when fetching resources over http */
public injectFetch(fetchOverride: Fetch) {
this.injectedFetch = fetchOverride;
this.client.setFetch(fetchOverride);
}
public addResources(...resources: Resource[]): void {
for (const resource of resources) {
this.addResource(resource);
}
}
/**
* @deprecated Will be marked private in the future, please use `addResources`
*
* Adds a Resource to the store and notifies subscribers. Replaces existing
* resources, unless this new resource is explicitly incomplete.
*/
public addResource(resource: Resource): void {
// Incomplete resources may miss some properties
if (resource.get(urls.properties.incomplete)) {
// If there is a resource with the same subject, we won't overwrite it with an incomplete one
const existing = this.resources.get(resource.getSubject());
if (existing && !existing.loading) {
return;
}
}
this.resources.set(resource.getSubject(), resource);
this.notify(resource.clone());
}
/** Checks if a subject is free to use */
public async checkSubjectTaken(subject: string): Promise<boolean> {
const r = this.resources.get(subject);
if (r?.isReady() && !r.new) {
return true;
}
try {
const resp = await this.fetchResourceFromServer(subject);
if (resp.isReady()) {
return true;
}
} catch (e) {
// If the resource doesn't exist, we can use it
}
return false;
}
/**
* Checks is a set of URL parts can be combined into an available subject.
* Will retry until it works.
*/
public async buildUniqueSubjectFromParts(
...parts: string[]
): Promise<string> {
const path = parts.join('/');
return this.findAvailableSubject(path);
}
/** Creates a random URL. Add a classnme (e.g. 'persons') to make a nicer name */
public createSubject(className?: string): string {
const random = this.randomPart();
className = className ? className : 'things';
return `${this.getServerUrl()}/${className}/${random}`;
}
/**
* Always fetches resource from the server then adds it to the store.
*/
public async fetchResourceFromServer(
/** The resource URL to be fetched */
subject: string,
opts: {
/**
* Fetch it from the `/path` endpoint of your server URL. This effectively
* is a proxy / cache.
*/
fromProxy?: boolean;
/** Overwrites the existing resource and sets it to loading. */
setLoading?: boolean;
/** Do not use WebSockets, use HTTP(S) */
noWebSocket?: boolean;
/** HTTP Method, defaults to GET */
method?: 'GET' | 'POST';
/** HTTP Body for POSTing */
body?: ArrayBuffer | string;
} = {},
): Promise<Resource> {
if (opts.setLoading) {
const newR = new Resource(subject);
newR.loading = true;
this.addResources(newR);
}
// Use WebSocket if available, else use HTTP(S)
const ws = this.getWebSocketForSubject(subject);
if (
!opts.fromProxy &&
!opts.noWebSocket &&
supportsWebSockets() &&
ws?.readyState === WebSocket.OPEN
) {
// Use WebSocket
await fetchWebSocket(ws, subject);
} else {
// Use HTTPS
const signInfo = this.agent
? { agent: this.agent, serverURL: this.getServerUrl() }
: undefined;
const { createdResources } = await this.client.fetchResourceHTTP(
subject,
{
from: opts.fromProxy ? this.getServerUrl() : undefined,
method: opts.method,
body: opts.body,
signInfo,
},
);
this.addResources(...createdResources);
}
return this.resources.get(subject)!;
}
public getAllSubjects(): string[] {
return Array.from(this.resources.keys());
}
/** Returns the WebSocket for the current Server URL */
public getDefaultWebSocket(): WebSocket | undefined {
return this.webSockets.get(this.getServerUrl());
}
/** Opens a Websocket for some subject URL, or returns the existing one. */
public getWebSocketForSubject(subject: string): WebSocket | undefined {
const url = new URL(subject);
const found = this.webSockets.get(url.origin);
if (found) {
return found;
} else {
if (typeof window !== 'undefined') {
this.webSockets.set(url.origin, startWebsocket(url.origin, this));
}
}
return;
}
/** Returns the base URL of the companion server */
public getServerUrl(): string {
return this.serverUrl;
}
/**
* Returns the Currently set Agent, returns null if there is none. Make sure
* to first run `store.setAgent()`.
*/
public getAgent(): Agent | undefined {
return this.agent ?? undefined;
}
/**
* Gets a resource by URL. Fetches and parses it if it's not available in the
* store. Instantly returns an empty loading resource, while the fetching is
* done in the background . If the subject is undefined, an empty non-saved
* resource will be returned.
*/
public getResourceLoading(
subject: string = unknownSubject,
opts: FetchOpts = {},
): Resource {
// This is needed because it can happen that the useResource react hook is called while there is no subject passed.
if (subject === unknownSubject || subject === null) {
const newR = new Resource(unknownSubject, opts.newResource);
return newR;
}
const found = this.resources.get(subject);
if (!found) {
const newR = new Resource(subject, opts.newResource);
newR.loading = true;
this.addResources(newR);
if (!opts.newResource) {
this.fetchResourceFromServer(subject, opts);
}
return newR;
} else if (!opts.allowIncomplete && found.loading === false) {
// In many cases, a user will always need a complete resource.
// This checks if the resource is incomplete and fetches it if it is.
if (found.get(urls.properties.incomplete)) {
found.loading = true;
this.addResources(found);
this.fetchResourceFromServer(subject, opts);
}
}
return found;
}
/**
* Gets a resource by URL. Fetches and parses it if it's not available in the
* store. Not recommended to use this for rendering, because it might cause
* resources to be fetched multiple times.
*/
public async getResourceAsync(subject: string): Promise<Resource> {
const found = this.resources.get(subject);
if (found && found.isReady()) {
return found;
}
/** Fix the case where a resource was previously requested but still not ready */
if (found && !found.isReady()) {
return new Promise((resolve, reject) => {
const defaultTimeout = 5000;
const cb = res => {
this.unsubscribe(subject, cb);
resolve(res);
};
this.subscribe(subject, cb);
setTimeout(() => {
this.unsubscribe(subject, cb);
reject(
new Error(
`Async Request for subject "${subject}" timed out after ${defaultTimeout}ms.`,
),
);
}, defaultTimeout);
});
}
return this.fetchResourceFromServer(subject);
}
/** Gets a property by URL. */
public async getProperty(subject: string): Promise<Property> {
// This leads to multiple fetches!
const resource = await this.getResourceAsync(subject);
if (resource === undefined) {
throw Error(`Property ${subject} is not found`);
}
if (resource.error) {
throw Error(`Property ${subject} cannot be loaded: ${resource.error}`);
}
const prop = new Property();
const datatypeUrl = resource.get(urls.properties.datatype);
if (datatypeUrl === undefined) {
throw Error(
`Property ${subject} has no datatype: ${resource.getPropVals()}`,
);
}
const shortname = resource.get(urls.properties.shortname);
if (shortname === undefined) {
throw Error(
`Property ${subject} has no shortname: ${resource.getPropVals()}`,
);
}
const description = resource.get(urls.properties.description);
if (description === undefined) {
throw Error(
`Property ${subject} has no description: ${resource.getPropVals()}`,
);
}
const classTypeURL = resource.get(urls.properties.classType)?.toString();
prop.classType = classTypeURL;
prop.shortname = shortname.toString();
prop.description = description.toString();
prop.datatype = datatypeFromUrl(datatypeUrl.toString());
return prop;
}
/**
* This is called when Errors occur in some of the library functions. Set your
* errorhandler function to `store.errorHandler`.
*/
public notifyError(e: Error | string): void {
const error = e instanceof Error ? e : new Error(e);
if (this.eventManager.hasSubscriptions(StoreEvents.Error)) {
this.eventManager.emit(StoreEvents.Error, error);
} else {
throw error;
}
}
/**
* If the store does not have an active internet connection, will return
* false. This may affect some functionality. For example, some checks will
* not be performed client side when offline.
*/
public isOffline(): boolean {
// If we are in a node/server environment assume we are online.
if (typeof window === 'undefined') {
return false;
}
return !window?.navigator?.onLine;
}
/** Let's subscribers know that a resource has been changed. Time to update your views! */
public async notify(resource: Resource): Promise<void> {
const subject = resource.getSubject();
const callbacks = this.subscribers.get(subject);
if (callbacks === undefined) {
return;
}
Promise.allSettled(callbacks.map(async cb => cb(resource)));
}
public notifyResourceSaved(resource: Resource): void {
this.eventManager.emit(StoreEvents.ResourceSaved, resource);
}
public notifyResourceManuallyCreated(resource: Resource): void {
this.eventManager.emit(StoreEvents.ResourceManuallyCreated, resource);
}
/** Parses the HTML document for `JSON-AD` data in <meta> tags, adds it to the store */
public parseMetaTags(): void {
const metaTags = document.querySelectorAll(
'meta[property="json-ad-initial"]',
);
const parser = new JSONADParser();
metaTags.forEach(tag => {
const content = tag.getAttribute('content');
if (content === null) {
return;
}
// convert base64 content to JSON
const json = JSON.parse(atob(content));
const [_, resources] = parser.parseObject(json);
this.addResources(...resources);
});
}
/**
* Fetches all Classes and Properties from your current server, including external resources.
* This helps to speed up time to interactive, but may not be necessary for all applications.
*/
public async preloadPropsAndClasses(): Promise<void> {
// TODO: use some sort of CollectionBuilder for this.
const classesUrl = new URL('/classes', this.serverUrl);
const propertiesUrl = new URL('/properties', this.serverUrl);
classesUrl.searchParams.set('include_external', 'true');
propertiesUrl.searchParams.set('include_external', 'true');
classesUrl.searchParams.set('include_nested', 'true');
propertiesUrl.searchParams.set('include_nested', 'true');
classesUrl.searchParams.set('page_size', '999');
propertiesUrl.searchParams.set('page_size', '999');
await Promise.all([
this.fetchResourceFromServer(classesUrl.toString()),
this.fetchResourceFromServer(propertiesUrl.toString()),
]);
}
/** Sends an HTTP POST request to the server to the Subject. Parses the returned Resource and adds it to the store. */
public async postToServer(
parent: string,
data: ArrayBuffer | string,
): Promise<Resource> {
const url = new URL(parent);
url.searchParams.set('parent', parent);
url.pathname = '/import';
return this.fetchResourceFromServer(url.toString(), {
body: data,
noWebSocket: true,
method: 'POST',
});
}
/** Removes (destroys / deletes) resource from this store */
public removeResource(subject: string): void {
const resource = this.resources.get(subject);
this.resources.delete(subject);
resource && this.eventManager.emit(StoreEvents.ResourceRemoved, resource);
}
/**
* Changes the Subject of a Resource. Checks if the new name is already taken,
* errors if so.
*/
public async renameSubject(
resource: Resource,
newSubject: string,
): Promise<void> {
Client.tryValidSubject(newSubject);
const oldSubject = resource.getSubject();
if (await this.checkSubjectTaken(newSubject)) {
throw Error(`New subject name is already taken: ${newSubject}`);
}
resource.setSubject(newSubject);
this.addResources(resource);
this.resources.set(newSubject, resource);
this.removeResource(oldSubject);
}
/**
* Sets the current Agent, used for signing commits. Authenticates all open
* websockets, and retries previously failed fetches.
*
* Warning: doing this stores the Private Key of the Agent in memory. This
* might have security implications for your application.
*/
public setAgent(agent: Agent | undefined): void {
this.agent = agent;
if (agent) {
setCookieAuthentication(this.serverUrl, agent);
this.webSockets.forEach(ws => {
authenticate(ws, this);
});
this.resources.forEach(r => {
if (r.isUnauthorized()) {
this.fetchResourceFromServer(r.getSubject());
}
});
} else {
removeCookieAuthentication();
}
this.eventManager.emit(StoreEvents.AgentChanged, agent);
}
/** Sets the Server base URL, without the trailing slash. */
public setServerUrl(url: string): void {
Client.tryValidSubject(url);
if (url.substring(-1) === '/') {
throw Error('baseUrl should not have a trailing slash');
}
this.serverUrl = url;
// TODO This is not the right place
supportsWebSockets() && this.openWebSocket(url);
}
/** Opens a WebSocket for this Atomic Server URL */
public openWebSocket(url: string) {
// Check if we're running in a webbrowser
if (supportsWebSockets()) {
if (this.webSockets.has(url)) {
return;
}
this.webSockets.set(url, startWebsocket(url, this));
} else {
console.warn('WebSockets not supported, no window available');
}
}
/**
* Registers a callback for when the a resource is updated. When you call
* this, you should probably also call .unsubscribe some time later.
*/
// TODO: consider subscribing to properties, maybe add a second subscribe function, use that in useValue
public subscribe(subject: string, callback: ResourceCallback): void {
if (subject === undefined) {
throw Error('Cannot subscribe to undefined subject');
}
let callbackArray = this.subscribers.get(subject);
if (callbackArray === undefined) {
// Only subscribe once
this.subscribeWebSocket(subject);
callbackArray = [];
}
callbackArray.push(callback);
this.subscribers.set(subject, callbackArray);
}
public subscribeWebSocket(subject: string): void {
if (subject === unknownSubject) {
return;
}
// TODO: check if there is a websocket for this server URL or not
try {
const ws = this.getWebSocketForSubject(subject);
// Only subscribe if there's a websocket. When it's opened, all subject will be iterated and subscribed
if (ws?.readyState === 1) {
ws?.send(`SUBSCRIBE ${subject}`);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}
public unSubscribeWebSocket(subject: string): void {
if (subject === unknownSubject) {
return;
}
try {
this.getDefaultWebSocket()?.send(`UNSUBSCRIBE ${subject}`);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}
/** Unregisters the callback (see `subscribe()`) */
public unsubscribe(subject: string, callback: ResourceCallback): void {
if (subject === undefined) {
return;
}
let callbackArray = this.subscribers.get(subject);
if (callbackArray) {
// Remove the function from the callBackArray
callbackArray = callbackArray?.filter(item => item !== callback);
this.subscribers.set(subject, callbackArray);
}
}
public on<T extends StoreEvents>(event: T, callback: StoreEventHandlers[T]) {
return this.eventManager.register(event, callback);
}
public async uploadFiles(files: File[], parent: string): Promise<string[]> {
const agent = this.getAgent();
if (!agent) {
throw Error('No agent set, cannot upload files');
}
const resources = await this.client.uploadFiles(
files,
this.getServerUrl(),
agent,
parent,
);
this.addResources(...resources);
return resources.map(r => r.getSubject());
}
public async postCommit(commit: Commit, endpoint: string): Promise<Commit> {
return this.client.postCommit(commit, endpoint);
}
private randomPart(): string {
return Math.random().toString(36).substring(2);
}
private async findAvailableSubject(
path: string,
firstTry = true,
): Promise<string> {
let url = `${this.getServerUrl()}/${path}`;
if (!firstTry) {
const randomPart = this.randomPart();
url += `-${randomPart}`;
}
const taken = await this.checkSubjectTaken(url);
if (taken) {
return this.findAvailableSubject(path, false);
}
return url;
}
}
/**
* A Property represents a relationship between a Subject and its Value.
* https://atomicdata.dev/classes/Property
*/
export class Property {
public subject: string;
/** https://atomicdata.dev/properties/datatype */
public datatype: Datatype;
/** https://atomicdata.dev/properties/shortname */
public shortname: string;
/** https://atomicdata.dev/properties/description */
public description: string;
/** https://atomicdata.dev/properties/classType */
public classType?: string;
/** If the Property cannot be found or parsed, this will contain the error */
public error?: Error;
/** https://atomicdata.dev/properties/isDynamic */
public isDynamic?: boolean;
/** When the Property is still awaiting a server response */
public loading?: boolean;
}
export interface FetchOpts {
/**
* If this is true, incomplete resources will not be automatically fetched.
* Incomplete resources are faster to process server-side, but they need to be
* fetched again when all properties are needed.
*/
allowIncomplete?: boolean;
/** Do not fetch over WebSockets, always fetch over HTTP(S) */
noWebSocket?: boolean;
/**
* If true, will not send a request to a server - it will simply create a new
* local resource.
*/
newResource?: boolean;
}