@jpmorganchase/mosaic-core 0.1.0-beta.3

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,2 @@
1
+ @jpmorganchase/mosaic-core:build: cache hit, replaying output 51fa83327ed9be55
2
+ @jpmorganchase/mosaic-core:build: $ tsc
@@ -0,0 +1,2 @@
1
+ @jpmorganchase/mosaic-core:clean: cache hit, replaying output bc081733e7257886
2
+ @jpmorganchase/mosaic-core:clean: $ rm -fr dist
package/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # @jpmorganchase/mosaic-core
2
+
3
+ ## 0.1.0-beta.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 457df5e: switch to public package
8
+ - Updated dependencies [457df5e]
9
+ - @jpmorganchase/mosaic-types@0.1.0-beta.3
10
+
11
+ ## 0.1.0-beta.2
12
+
13
+ ### Patch Changes
14
+
15
+ - e1bbbe7: Initial release of Mosaic Core file-system.
16
+
17
+ Mosaic is a content aggregating service which pulls content from heterogeneous data sources.
18
+
19
+ - 4b2bc51: pipped to 0.1.0-beta.1 for publishing to NPM
20
+ - Updated dependencies [e1bbbe7]
21
+ - Updated dependencies [4b2bc51]
22
+ - @jpmorganchase/mosaic-types@0.1.0-beta.2
23
+
24
+ ## 0.1.0
25
+
26
+ ### Patch Changes
27
+
28
+ - e1bbbe7: Initial release of Mosaic Core file-system.
29
+
30
+ Mosaic is a content aggregating service which pulls content from heterogeneous data sources.
31
+
32
+ - Updated dependencies [e1bbbe7]
33
+ - Updated dependencies [bbc853c]
34
+ - @jpmorganchase/mosaic-types@0.1.0
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@jpmorganchase/mosaic-core",
3
+ "version": "0.1.0-beta.3",
4
+ "author": "",
5
+ "description": "",
6
+ "license": "Apache-2.0",
7
+ "main": "./dist/index.js",
8
+ "engines": {
9
+ "node": ">= 12.0.0"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "clean": "rm -fr dist",
14
+ "start": "node --trace-warnings ./dist/example",
15
+ "start:debug": "node --inspect-brk ./dist/example"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git@github.com:jpmorganchase/mosaic.git"
20
+ },
21
+ "devDependencies": {
22
+ "express": "^4.17.1",
23
+ "cors": "^2.8.5",
24
+ "@types/express": "^4.17.13"
25
+ },
26
+ "dependencies": {
27
+ "gray-matter": "^4.0.3",
28
+ "@jpmorganchase/mosaic-types": "^0.1.0-beta.3",
29
+ "md5": "*",
30
+ "unionfs": "^4.4.0",
31
+ "lodash": "^4.17.21",
32
+ "fast-glob": "*",
33
+ "memfs": "^3.3.0",
34
+ "rxjs": "^7.5.5"
35
+ }
36
+ }
@@ -0,0 +1,3 @@
1
+ export default class PluginError extends Error {
2
+
3
+ }
@@ -0,0 +1,124 @@
1
+ import type { IUnionFs } from 'unionfs';
2
+ import { Union } from 'unionfs';
3
+ import type { IFS } from 'unionfs/lib/fs';
4
+
5
+ import type PluginModuleDefinition from '@jpmorganchase/mosaic-types/dist/PluginModuleDefinition';
6
+ import type SerialiserModuleDefinition from '@jpmorganchase/mosaic-types/dist/SerialiserModuleDefinition';
7
+ import type SourceModuleDefinition from '@jpmorganchase/mosaic-types/dist/SourceModuleDefinition';
8
+
9
+ import SourceManager from './SourceManager';
10
+
11
+ // TODO:
12
+ // Remove $ref /index resolution
13
+ import MutableVolume from './filesystems/MutableVolume';
14
+ import UnionFileAccess from './filesystems/UnionFileAccess';
15
+ import UnionVolume from './filesystems/UnionVolume';
16
+ import { Volume } from 'memfs';
17
+ import FileAccess from './filesystems/FileAccess';
18
+
19
+ export default class PullDocs {
20
+ #sourceDefinitions: SourceModuleDefinition[];
21
+ #sourceManager: SourceManager;
22
+ #ufs = new Union() as IUnionFs & { fss: MutableVolume[] };
23
+ #vfs: UnionVolume;
24
+
25
+ /**
26
+ *
27
+ * @param config.sources Sources are observables that emit pages
28
+ * @param config.plugins Plugins are modules with lifecycles methods for manipulating files and/or the virtual filesystem
29
+ * @param config.serialisers Serialisers are a form of plugin that tell PullDocs how to turn a file from/to a storable form for the filesystem - or back into a `Page` object
30
+ * @param config.ignorePages Page names to exclude from lazy loading / `$ref`s / `$tag`s / serialisers and also any plugins that expect pages as input. Example input would be "ignore-me.xml"
31
+ * @param config.pageExtensions Exts of files to treat as pages. Pages contain metadata and content, in any file format (as long as a serialiser exists to encode/decode them). They can be referenced via `$ref`s / `$tag`s and also support lazy loading
32
+ */
33
+ constructor(config: {
34
+ ignorePages?: string[];
35
+ plugins?: PluginModuleDefinition[];
36
+ serialisers?: SerialiserModuleDefinition[];
37
+ sources: SourceModuleDefinition[];
38
+ pageExtensions: string[];
39
+ }) {
40
+ const sharedFilesystem = new MutableVolume(new FileAccess(new Volume()), '*');
41
+ this.#ufs.use(sharedFilesystem as unknown as any);
42
+ const {
43
+ ignorePages = [],
44
+ sources = [],
45
+ plugins = [],
46
+ serialisers = [],
47
+ pageExtensions = ['.mdx']
48
+ } = config;
49
+ this.#sourceDefinitions = sources;
50
+ this.#vfs = new UnionVolume(new UnionFileAccess(this.#ufs), '*');
51
+ this.#sourceManager = new SourceManager(
52
+ // Refs and aliases should be applied after all other plugins, so we add them manually with a negative priority
53
+ plugins
54
+ .concat(
55
+ {
56
+ modulePath: require.resolve('@jpmorganchase/mosaic-plugins/dist/$TagPlugin'),
57
+ options: {}
58
+ },
59
+ {
60
+ modulePath: require.resolve('@jpmorganchase/mosaic-plugins/dist/$CodeModPlugin'),
61
+ options: {},
62
+ // Make sure this happens as the very first plugin, so it can fix any page issues
63
+ priority: Number.POSITIVE_INFINITY
64
+ },
65
+ {
66
+ modulePath: require.resolve('@jpmorganchase/mosaic-plugins/dist/$AliasPlugin'),
67
+ options: {},
68
+ priority: -1
69
+ },
70
+ {
71
+ modulePath: require.resolve('@jpmorganchase/mosaic-plugins/dist/$RefPlugin'),
72
+ options: {},
73
+ priority: -1
74
+ }
75
+ )
76
+ .sort(({ priority: priorityA = 0 }, { priority: priorityB = 0 }) => priorityB - priorityA),
77
+ // Auto add JSON serialiser
78
+ serialisers.concat({
79
+ modulePath: require.resolve('@jpmorganchase/mosaic-serialisers/dist/json'),
80
+ filter: /\.json$/,
81
+ options: {}
82
+ }),
83
+ pageExtensions,
84
+ ignorePages,
85
+ this.#vfs,
86
+ sharedFilesystem
87
+ );
88
+ }
89
+
90
+ get filesystem() {
91
+ return this.#vfs;
92
+ }
93
+
94
+ onSourceUpdate(callback) {
95
+ return this.#sourceManager.onSourceUpdate(callback);
96
+ }
97
+
98
+ async start() {
99
+ return Promise.all(this.#sourceDefinitions.map(source => this.#addSource(source)));
100
+ }
101
+
102
+ async stop() {
103
+ return this.#sourceManager.destroyAll();
104
+ }
105
+
106
+ async #addSource(sourceDefinition) {
107
+ const source = await this.#sourceManager.addSource(sourceDefinition, {});
108
+ source.onError(error => {
109
+ console.error(new Error(`Source '${source.id.description}' threw an exception. See below:`));
110
+ console.error(error);
111
+ });
112
+ source.onExit(() => {
113
+ this.#ufs.fss.splice(this.#ufs.fss.indexOf(source.filesystem), 1);
114
+
115
+ if (this.#ufs.fss.length < 1) {
116
+ console.debug("[PullDocs] All of my source have been terminated. That's sad :-(");
117
+ }
118
+ });
119
+
120
+ this.#ufs.use(source.filesystem as unknown as IFS);
121
+
122
+ return source;
123
+ }
124
+ }
package/src/Source.ts ADDED
@@ -0,0 +1,178 @@
1
+ import EventEmitter from 'events';
2
+ import { Volume } from 'memfs';
3
+ import util from 'util';
4
+ import md5 from 'md5';
5
+ import path from 'path';
6
+
7
+ import type PluginModuleDefinition from '@jpmorganchase/mosaic-types/dist/PluginModuleDefinition';
8
+ import type SerialiserModuleDefinition from '@jpmorganchase/mosaic-types/dist/SerialiserModuleDefinition';
9
+ import type { IUnionVolume, IVolumeImmutable } from '@jpmorganchase/mosaic-types/dist/Volume';
10
+ import type Serialiser from '@jpmorganchase/mosaic-types/dist/Serialiser';
11
+ import type MutableData from '@jpmorganchase/mosaic-types/dist/MutableData';
12
+ import type Plugin from '@jpmorganchase/mosaic-types/dist/Plugin';
13
+ import type SourceModuleDefinition from '@jpmorganchase/mosaic-types/dist/SourceModuleDefinition';
14
+
15
+ import { bindSerialiser, bindPluginMethods } from './plugin';
16
+ import WorkerSubscription from './WorkerSubscription';
17
+ import { EVENT } from './WorkerSubscription';
18
+ import createConfig from './helpers/createConfig';
19
+ import MutableVolume from './filesystems/MutableVolume';
20
+ import FileAccess from './filesystems/FileAccess';
21
+
22
+ export default class Source {
23
+ #emitter: EventEmitter = new EventEmitter();
24
+ #modulePath: string;
25
+ #plugins: PluginModuleDefinition[] = [];
26
+ #serialisers: SerialiserModuleDefinition[] = [];
27
+ #worker: WorkerSubscription;
28
+ #globalFilesystem: IUnionVolume;
29
+ #pluginApi: Plugin;
30
+ #mergedOptions: Record<string, unknown>;
31
+ #pageExtensions: string[];
32
+ #ignorePages: string[];
33
+
34
+ config: MutableData<{}>;
35
+ serialiser: Serialiser;
36
+ namespace: string;
37
+ id: Symbol;
38
+ filesystem: MutableVolume;
39
+
40
+ constructor(
41
+ { modulePath, namespace }: SourceModuleDefinition,
42
+ mergedOptions: Record<string, unknown>,
43
+ pageExtensions: string[],
44
+ ignorePages: string[],
45
+ globalFilesystem: IUnionVolume
46
+ ) {
47
+ this.#modulePath = modulePath;
48
+ this.#mergedOptions = mergedOptions;
49
+ this.#globalFilesystem = globalFilesystem;
50
+ this.#ignorePages = ignorePages;
51
+ this.namespace = namespace;
52
+ this.id = Symbol(
53
+ `${path.basename(path.dirname(path.resolve(modulePath, '../')))}#${md5(
54
+ util.inspect(this.#mergedOptions, { sorted: true })
55
+ ).substring(0, 8)}`
56
+ );
57
+ this.filesystem = new MutableVolume(new FileAccess(new Volume()), this.namespace);
58
+ this.#pageExtensions = pageExtensions;
59
+ }
60
+
61
+ onUpdate(callback) {
62
+ this.#emitter.on(EVENT.UPDATE, callback);
63
+
64
+ return () => this.#emitter.off(EVENT.UPDATE, callback);
65
+ }
66
+
67
+ onError(callback) {
68
+ this.#emitter.once(EVENT.ERROR, callback);
69
+
70
+ return () => this.#emitter.off(EVENT.ERROR, callback);
71
+ }
72
+
73
+ onStart(callback) {
74
+ this.#emitter.once(EVENT.START, callback);
75
+
76
+ return () => this.#emitter.off(EVENT.START, callback);
77
+ }
78
+
79
+ onExit(callback) {
80
+ this.#emitter.once(EVENT.EXIT, callback);
81
+
82
+ return () => this.#emitter.off(EVENT.EXIT, callback);
83
+ }
84
+
85
+ stop() {
86
+ if (this.#worker && this.#worker.closed) {
87
+ throw new Error('Cannot stop source that has not started');
88
+ }
89
+ if (this.#worker) {
90
+ this.#worker.stop();
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Called when another source (not this one) has changed.
96
+ * This source can then ask its plugins if they also want to clear their cache in response to the other source's change.
97
+ */
98
+ async requestCacheClear(updatedSourceFilesystem: IVolumeImmutable) {
99
+ const initTime = new Date().getTime();
100
+ const shouldInvokeAfterUpdate = await this.#pluginApi.shouldClearCache(
101
+ updatedSourceFilesystem,
102
+ {
103
+ globalFilesystem: this.#globalFilesystem,
104
+ pageExtensions: this.#pageExtensions,
105
+ ignorePages: this.#ignorePages,
106
+ serialiser: this.serialiser,
107
+ config: this.config.asReadOnly()
108
+ }
109
+ );
110
+ const timeTaken = new Date().getTime() - initTime;
111
+ if (timeTaken > 400) {
112
+ console.warn(
113
+ `Lifecycle phase 'shouldClearCache' for source '${this.id.description}' took ${timeTaken}ms to complete. The method is async, so this may not be an accurate measurement of execution time, but consider optimising this method if it is performing intensive operations.`
114
+ );
115
+ }
116
+ if (shouldInvokeAfterUpdate === true) {
117
+ this.filesystem.clearCache();
118
+ }
119
+ }
120
+
121
+ async use(plugins: PluginModuleDefinition[] = [], serialisers: SerialiserModuleDefinition[] = []) {
122
+ this.#plugins.push(...plugins);
123
+ this.#serialisers.push(...serialisers);
124
+ }
125
+
126
+ async invokeAfterUpdate(sharedFilesystem, globalConfig) {
127
+ const initTime = new Date().getTime();
128
+ await this.#pluginApi.afterUpdate(this.filesystem.asRestricted(), {
129
+ globalFilesystem: this.#globalFilesystem,
130
+ sharedFilesystem,
131
+ globalConfig,
132
+ pageExtensions: this.#pageExtensions,
133
+ ignorePages: this.#ignorePages,
134
+ serialiser: this.serialiser,
135
+ config: this.config.asReadOnly()
136
+ });
137
+ const timeTaken = new Date().getTime() - initTime;
138
+ if (timeTaken > 800) {
139
+ console.warn(
140
+ `Lifecycle phase 'afterUpdate' for source '${this.id.description}' took ${
141
+ timeTaken / 1000
142
+ }s to complete. The method is async, so this may not be an accurate measurement of execution time, but consider optimising this method if it is performing intensive operations.`
143
+ );
144
+ }
145
+ }
146
+
147
+ #createWorker() {
148
+ const worker = new WorkerSubscription({
149
+ modulePath: this.#modulePath,
150
+ name: this.id.description,
151
+ options: this.#mergedOptions,
152
+ namespace: this.namespace,
153
+ pageExtensions: this.#pageExtensions,
154
+ ignorePages: this.#ignorePages,
155
+ plugins: this.#plugins,
156
+ serialisers: this.#serialisers
157
+ });
158
+ worker.once(EVENT.ERROR, error => this.#emitter.emit(EVENT.ERROR, error));
159
+ worker.once(EVENT.EXIT, () => {
160
+ this.#emitter.emit(EVENT.EXIT);
161
+ this.#emitter.removeAllListeners();
162
+ //this.filesystem.reset();
163
+ this.filesystem = null;
164
+ });
165
+ worker.once(EVENT.START, () => this.#emitter.emit(EVENT.START));
166
+ return worker;
167
+ }
168
+
169
+ async start() {
170
+ this.serialiser = await bindSerialiser(this.#serialisers);
171
+ this.#pluginApi = await bindPluginMethods(this.#plugins);
172
+ this.#worker = this.#createWorker();
173
+ this.#worker.on(EVENT.UPDATE, async ({ data: { pages, symlinks, data } }) => {
174
+ this.config = createConfig(data);
175
+ this.#emitter.emit(EVENT.UPDATE, { pages, symlinks, data });
176
+ });
177
+ }
178
+ }
@@ -0,0 +1,196 @@
1
+ import type SourceModuleDefinition from '@jpmorganchase/mosaic-types/dist/SourceModuleDefinition';
2
+ import type SerialiserModuleDefinition from '@jpmorganchase/mosaic-types/dist/SerialiserModuleDefinition';
3
+ import type PluginModuleDefinition from '@jpmorganchase/mosaic-types/dist/PluginModuleDefinition';
4
+ import type {
5
+ IUnionVolume,
6
+ IVolumeImmutable,
7
+ IVolumeMutable
8
+ } from '@jpmorganchase/mosaic-types/dist/Volume';
9
+
10
+ import Source from './Source';
11
+ import createConfig from './helpers/createConfig';
12
+ import { merge } from 'lodash';
13
+
14
+ export default class SourceManager {
15
+ #sources: Map<Symbol, Source> = new Map();
16
+ #plugins: PluginModuleDefinition[];
17
+ #serialisers: SerialiserModuleDefinition[];
18
+ #handlers: Set<(filesystem: IVolumeImmutable, source: Source) => {}> = new Set();
19
+ #globalFilesystem: IUnionVolume;
20
+ #sharedFilesystem: IVolumeMutable;
21
+ #pageExtensions: string[];
22
+ #ignorePages: string[];
23
+ #globalConfig = createConfig();
24
+
25
+ constructor(
26
+ plugins = [],
27
+ serialisers = [],
28
+ pageExtensions,
29
+ ignorePages,
30
+ globalFilesystem,
31
+ sharedFilesystem
32
+ ) {
33
+ this.#plugins = plugins;
34
+ this.#pageExtensions = pageExtensions;
35
+ this.#ignorePages = ignorePages;
36
+ this.#serialisers = serialisers;
37
+ this.#globalFilesystem = globalFilesystem;
38
+ this.#sharedFilesystem = sharedFilesystem;
39
+ }
40
+
41
+ onSourceUpdate(callback) {
42
+ const handler = (filesystem: IVolumeImmutable, source: Source) => callback(filesystem, source);
43
+
44
+ this.#handlers.add(handler);
45
+
46
+ return () => this.#handlers.delete(handler);
47
+ }
48
+
49
+ getSource(id: Symbol) {
50
+ return this.#sources.get(id);
51
+ }
52
+
53
+ destroyAll() {
54
+ this.#sources.forEach(source => source.stop());
55
+ }
56
+
57
+ addSource(
58
+ sourceDefinition: SourceModuleDefinition,
59
+ options: Record<string, unknown>
60
+ ): Promise<Source> {
61
+ return new Promise(async (resolve, reject) => {
62
+ let sourceActive = false;
63
+
64
+ const source = new Source(
65
+ sourceDefinition,
66
+ {
67
+ ...sourceDefinition.options,
68
+ ...options
69
+ },
70
+ this.#pageExtensions,
71
+ this.#ignorePages,
72
+ this.#globalFilesystem
73
+ );
74
+ const immutableSourceFilesystem = source.filesystem.asReadOnly();
75
+
76
+ let initOrStartTime = new Date().getTime();
77
+
78
+ this.#sources.set(source.id, source);
79
+
80
+ source.use(this.#plugins, this.#serialisers);
81
+
82
+ await source.start();
83
+
84
+ source.onUpdate(async ({ pages, symlinks, data }) => {
85
+ if (!sourceActive) {
86
+ reject(
87
+ new Error(
88
+ `Source '${source.id.description}' received a message before it was initialised.`
89
+ )
90
+ );
91
+ } else {
92
+ logUpdateStatus(source.id, initOrStartTime);
93
+ initOrStartTime = null;
94
+
95
+ //
96
+ try {
97
+ this.#globalConfig.setData(
98
+ Array.from(this.#sources.values()).reduce(
99
+ (mergedConfig, { config }) => merge(mergedConfig, config?.data || {}),
100
+ {}
101
+ ),
102
+ true
103
+ );
104
+ source.filesystem.reset();
105
+ source.filesystem.fromJSON(pages);
106
+ // We need to re-apply symlinks in the main thread
107
+ await source.filesystem.symlinksFromJSON(symlinks);
108
+
109
+ // After each async operation, we should check if anything has caused the Source to close
110
+ if (!sourceActive) {
111
+ return;
112
+ }
113
+
114
+ await source.invokeAfterUpdate(this.#sharedFilesystem, this.#globalConfig);
115
+
116
+ // After each async operation, we should check if anything has caused the Source to close
117
+ if (!sourceActive) {
118
+ return;
119
+ }
120
+
121
+ source.filesystem.freeze();
122
+ source.filesystem.clearCache();
123
+
124
+ await this.#updateSources(immutableSourceFilesystem, source);
125
+ this.#invokeUpdateCallbacks(immutableSourceFilesystem, source);
126
+ } catch (e) {
127
+ console.warn(
128
+ `[PullDocs] Exceptions during source update are currently set to terminate the parent source. Terminating '${String(
129
+ source.id.description
130
+ )}'. See error:`
131
+ );
132
+ console.error(e);
133
+ source.stop();
134
+ }
135
+ //
136
+ }
137
+ });
138
+ source.onError(error => {
139
+ console.error(error);
140
+ if (!sourceActive) {
141
+ reject(error);
142
+ }
143
+ });
144
+ source.onExit(() => {
145
+ if (!sourceActive) {
146
+ reject(
147
+ new Error(`Source '${source.id.description}' silently exited before initialising.`)
148
+ );
149
+ }
150
+ console.debug(`[PullDocs] Source '${source.id.description}' closed`);
151
+
152
+ this.#sources.delete(source.id);
153
+ sourceActive = false;
154
+ });
155
+ source.onStart(() => {
156
+ sourceActive = true;
157
+ console.debug(
158
+ `[PullDocs] Source '${source.id.description}' started in ${
159
+ new Date().getTime() - initOrStartTime
160
+ }ms - awaiting first docs snapshot`
161
+ );
162
+ initOrStartTime = new Date().getTime();
163
+ resolve(source);
164
+ });
165
+ });
166
+ }
167
+
168
+ #invokeUpdateCallbacks(filesystem: IVolumeImmutable, source: Source) {
169
+ this.#handlers.forEach(callback => callback(filesystem, source));
170
+ }
171
+
172
+ #updateSources(immutableSourceFilesystem, source) {
173
+ // Notify other frozen sources that something has changed. If a source's filesystem hasn't been frozen yet - there's no point
174
+ // in notifying it of another source changing, as it hasn't initialised or called `afterUpdate` for the first time yet anyway.
175
+ // No need to do them sequentially - we can fire them all off at once and wait for them all to finish in their own time
176
+ return Promise.all(
177
+ Array.from(this.#sources.values()).map(existingSource => {
178
+ if (existingSource !== source && existingSource.filesystem.frozen) {
179
+ return existingSource.requestCacheClear(immutableSourceFilesystem);
180
+ }
181
+ })
182
+ );
183
+ }
184
+ }
185
+
186
+ function logUpdateStatus(sourceId, initOrStartTime) {
187
+ if (initOrStartTime) {
188
+ console.debug(
189
+ `[PullDocs] Source '${sourceId.description}' received first docs snapshot ${
190
+ (new Date().getTime() - initOrStartTime) / 1000
191
+ }s after starting.`
192
+ );
193
+ } else {
194
+ console.debug(`[PullDocs] Source '${sourceId.description}' received updated docs`);
195
+ }
196
+ }
@@ -0,0 +1,80 @@
1
+ import { EventEmitter } from 'stream';
2
+ import type { Subscription } from 'rxjs';
3
+
4
+ import * as ObservableWorker from './operators/ObservableWorker';
5
+
6
+ export enum EVENT {
7
+ ERROR = 'ERROR',
8
+ EXIT = 'EXIT',
9
+ START = 'START',
10
+ UPDATE = 'UPDATE'
11
+ }
12
+
13
+ const textDecoder = new TextDecoder('utf8');
14
+
15
+ export default class WorkerSubscription {
16
+ #worker$: Subscription;
17
+ #emitter = new EventEmitter();
18
+
19
+ constructor(workerData) {
20
+ this.#worker$ = ObservableWorker.from<{ type: 'message' | 'init'; data: Uint8Array }>(
21
+ workerData
22
+ ).subscribe({ next: this.#onNext, error: this.#onError, complete: this.#onComplete });
23
+ }
24
+
25
+ get closed() {
26
+ return this.#worker$?.closed;
27
+ }
28
+
29
+ stop() {
30
+ if (this.closed) {
31
+ throw new Error('Cannot stop source - subscription is already closed');
32
+ }
33
+ this.#worker$.unsubscribe();
34
+ this.#onComplete();
35
+ }
36
+
37
+ once(type: EVENT, handler) {
38
+ const autoUnsubscribeHandler = (...args) => {
39
+ this.#emitter.off(type, autoUnsubscribeHandler);
40
+ handler(...args);
41
+ };
42
+ this.on(type, autoUnsubscribeHandler);
43
+
44
+ return () => this.#emitter.off(type, autoUnsubscribeHandler);
45
+ }
46
+
47
+ on(type: EVENT, handler) {
48
+ this.#emitter.on(type, handler);
49
+
50
+ return () => this.#emitter.off(type, handler);
51
+ }
52
+
53
+ #onNext = ({ type, data }: { data: Uint8Array; type: 'init' | 'message' }) => {
54
+ if (type === 'message') {
55
+ this.#emitter.emit(EVENT.UPDATE, { data: JSON.parse(textDecoder.decode(data)) });
56
+ } else if (type === 'init') {
57
+ this.#emitter.emit(EVENT.START);
58
+ if (data) {
59
+ // We receive a file buffer for 'init', not an object
60
+ this.#emitter.emit(EVENT.UPDATE, { data: JSON.parse(textDecoder.decode(data)) });
61
+ }
62
+ } else {
63
+ this.#onError(
64
+ new Error(`Unsupported type '${type}' with value of '${data}' received from source worker.`)
65
+ );
66
+ this.#worker$.unsubscribe();
67
+ this.#worker$ = null;
68
+ }
69
+ };
70
+
71
+ #onError = e => {
72
+ this.#emitter.emit(EVENT.ERROR, e);
73
+ this.#onComplete();
74
+ };
75
+
76
+ #onComplete = () => {
77
+ this.#emitter.emit(EVENT.EXIT);
78
+ this.#emitter.removeAllListeners();
79
+ };
80
+ }
@@ -0,0 +1,29 @@
1
+ const constructorSpy = jest.fn();
2
+
3
+ class Source {
4
+ constructor(...args) {
5
+ constructorSpy(...args);
6
+ this.filesystem = {
7
+ asReadOnly: jest.fn(),
8
+ clearCache: jest.fn(),
9
+ freeze: jest.fn(),
10
+ fromJSON: jest.fn(),
11
+ reset: jest.fn(),
12
+ symlinksFromJSON: () => new Promise(resolve => setTimeout(resolve))
13
+ };
14
+ this.id = Symbol(args[0].name || 'some description');
15
+ }
16
+ }
17
+
18
+ Source.prototype.onError = jest.fn();
19
+ Source.prototype.onExit = jest.fn();
20
+ Source.prototype.onStart = jest.fn();
21
+ Source.prototype.onUpdate = jest.fn();
22
+ Source.prototype.invokeAfterUpdate = () => new Promise(resolve => setTimeout(resolve));
23
+ Source.prototype.requestCacheClear = jest.fn();
24
+ Source.prototype.start = jest.fn();
25
+ Source.prototype.stop = jest.fn();
26
+ Source.prototype.use = jest.fn();
27
+ Source.prototype.constructorSpy = constructorSpy;
28
+
29
+ export default Source;