@pwrdrvr/microapps-router-lib 0.4.0-alpha.8 → 0.4.0-alpha.9

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,193 @@
1
+ import { Application, DBManager, Rules, Version } from '@pwrdrvr/microapps-datalib';
2
+
3
+ interface ICacheEntry<T> {
4
+ timestamp: number;
5
+ data: T;
6
+ }
7
+
8
+ export class AppVersionCache {
9
+ private negativeAppNameCache: Map<string, ICacheEntry<undefined>>;
10
+ private appRulesCache: Map<string, ICacheEntry<Rules>>;
11
+ private appVersionsCache: Map<string, Map<string, ICacheEntry<Version>>>;
12
+ private dbManager: DBManager;
13
+
14
+ private static instance: AppVersionCache;
15
+
16
+ /**
17
+ * Get the AppVersionCache instance or create it
18
+ */
19
+ public static GetInstance({ dbManager }: { dbManager: DBManager }): AppVersionCache {
20
+ if (!AppVersionCache.instance) {
21
+ AppVersionCache.instance = new AppVersionCache({ dbManager });
22
+ }
23
+
24
+ return AppVersionCache.instance;
25
+ }
26
+
27
+ constructor({ dbManager }: { dbManager: DBManager }) {
28
+ this.negativeAppNameCache = new Map();
29
+ this.appRulesCache = new Map();
30
+ this.appVersionsCache = new Map();
31
+ this.dbManager = dbManager;
32
+ }
33
+
34
+ private get CacheIsEmpty(): boolean {
35
+ return this.appRulesCache.size === 0 && this.appVersionsCache.size === 0;
36
+ }
37
+
38
+ /**
39
+ * Used to populate the cache when the cache is completely empty
40
+ * @param appName
41
+ */
42
+ private async PopulateEmptyCache({
43
+ key: { AppName },
44
+ }: {
45
+ key: { AppName: string };
46
+ }): Promise<void> {
47
+ const versionsAndRules = await Application.GetVersionsAndRules({
48
+ dbManager: this.dbManager,
49
+ key: { AppName },
50
+ });
51
+
52
+ if (!versionsAndRules || (versionsAndRules.Versions.length === 0 && !versionsAndRules.Rules)) {
53
+ this.negativeAppNameCache.set(AppName.toLowerCase(), {
54
+ timestamp: Date.now(),
55
+ data: undefined,
56
+ });
57
+ return;
58
+ }
59
+
60
+ // Remove negative cache entry if it exists
61
+ this.negativeAppNameCache.delete(AppName.toLowerCase());
62
+
63
+ this.appRulesCache.set(AppName.toLowerCase(), {
64
+ timestamp: Date.now(),
65
+ data: versionsAndRules.Rules,
66
+ });
67
+
68
+ const versionsMap = new Map<string, ICacheEntry<Version>>();
69
+ for (const version of versionsAndRules.Versions) {
70
+ versionsMap.set(version.SemVer, {
71
+ timestamp: Date.now(),
72
+ data: version,
73
+ });
74
+ }
75
+ this.appVersionsCache.set(AppName.toLowerCase(), versionsMap);
76
+ }
77
+
78
+ public ClearCache(): void {
79
+ this.appRulesCache.clear();
80
+ this.appVersionsCache.clear();
81
+ }
82
+
83
+ /**
84
+ * Get Rules for an app
85
+ *
86
+ * Populates the cache if empty
87
+ * Freshens a cache entry if it is stale
88
+ * Feturns the cached value if it is fresh
89
+ * Fetches a single item if the cache is not empty but the item is not in the cache
90
+ *
91
+ * @param appName
92
+ * @returns
93
+ */
94
+ public async GetRules({
95
+ key: { AppName },
96
+ }: {
97
+ key: { AppName: string };
98
+ }): Promise<Rules | undefined> {
99
+ const now = Date.now();
100
+
101
+ // Check the negative cache first
102
+ const negativeCacheEntry = this.negativeAppNameCache.get(AppName.toLowerCase());
103
+ if (negativeCacheEntry && now - negativeCacheEntry.timestamp < 60000) {
104
+ return undefined;
105
+ } else if (negativeCacheEntry) {
106
+ this.negativeAppNameCache.delete(AppName.toLowerCase());
107
+ }
108
+
109
+ const ruleCacheEntry = this.appRulesCache.get(AppName.toLowerCase());
110
+ if (ruleCacheEntry && now - ruleCacheEntry.timestamp < 60000) {
111
+ return ruleCacheEntry.data;
112
+ }
113
+
114
+ if (this.CacheIsEmpty || !ruleCacheEntry) {
115
+ await this.PopulateEmptyCache({ key: { AppName } });
116
+
117
+ return this.appRulesCache.get(AppName.toLowerCase())?.data;
118
+ }
119
+
120
+ const versionsAndRules = await Application.GetVersionsAndRules({
121
+ dbManager: this.dbManager,
122
+ key: { AppName: AppName },
123
+ });
124
+ this.appRulesCache.set(AppName.toLowerCase(), { timestamp: now, data: versionsAndRules.Rules });
125
+
126
+ return versionsAndRules.Rules;
127
+ }
128
+
129
+ /**
130
+ * Get Version info for an app and semVer
131
+ *
132
+ * Populates the cache if empty
133
+ * Freshens a cache entry if it is stale
134
+ * Feturns the cached value if it is fresh
135
+ * Fetches a single item if the cache is not empty but the item is not in the cache
136
+ *
137
+ * @param appName
138
+ * @returns
139
+ */
140
+ public async GetVersionInfo({
141
+ key: { AppName, SemVer },
142
+ }: {
143
+ key: { AppName: string; SemVer: string };
144
+ }): Promise<Version | undefined> {
145
+ const now = Date.now();
146
+
147
+ // Check the negative cache first
148
+ const negativeCacheEntry = this.negativeAppNameCache.get(AppName.toLowerCase());
149
+ if (negativeCacheEntry && now - negativeCacheEntry.timestamp < 60000) {
150
+ return undefined;
151
+ } else if (negativeCacheEntry) {
152
+ this.negativeAppNameCache.delete(AppName.toLowerCase());
153
+ }
154
+
155
+ // Check if we have the item cached and if it is fresh enough
156
+ const versionCacheEntry = this.appVersionsCache.get(AppName.toLowerCase());
157
+ if (versionCacheEntry) {
158
+ const versionInfo = versionCacheEntry.get(SemVer);
159
+ if (versionInfo && now - versionInfo.timestamp < 900000) {
160
+ return versionInfo.data;
161
+ }
162
+ }
163
+
164
+ if (this.CacheIsEmpty || !versionCacheEntry) {
165
+ await this.PopulateEmptyCache({ key: { AppName } });
166
+ if (!this.appVersionsCache) {
167
+ return undefined;
168
+ }
169
+
170
+ return this.appVersionsCache.get(AppName.toLowerCase())?.get(SemVer)?.data;
171
+ } else {
172
+ // We have some data in the cache, but not for this version
173
+ // So we need to get the data for this version
174
+ const versionInfo = await Version.LoadVersion({
175
+ dbManager: this.dbManager,
176
+ key: { AppName, SemVer: SemVer },
177
+ });
178
+ if (!versionInfo) {
179
+ return undefined;
180
+ }
181
+
182
+ const versionsMap = this.appVersionsCache.get(AppName.toLowerCase());
183
+ if (!versionsMap) {
184
+ return undefined;
185
+ }
186
+
187
+ // Save the version info in the cache
188
+ versionsMap.set(SemVer, { timestamp: now, data: versionInfo });
189
+
190
+ return versionInfo;
191
+ }
192
+ }
193
+ }
package/src/index.spec.ts CHANGED
@@ -97,7 +97,7 @@ describe('router - without prefix', () => {
97
97
  const version = new Version({
98
98
  AppName: 'Bat',
99
99
  IntegrationID: 'abcd',
100
- SemVer: '3.2.1-beta.1',
100
+ SemVer: '3.2.1-beta.2',
101
101
  Status: 'deployed',
102
102
  Type: 'static',
103
103
  });
@@ -106,17 +106,17 @@ describe('router - without prefix', () => {
106
106
  const rules = new Rules({
107
107
  AppName: 'Bat',
108
108
  Version: 0,
109
- RuleSet: { default: { SemVer: '3.2.1-beta.1', AttributeName: '', AttributeValue: '' } },
109
+ RuleSet: { default: { SemVer: '3.2.1-beta.2', AttributeName: '', AttributeValue: '' } },
110
110
  });
111
111
  await rules.Save(dbManager);
112
112
 
113
113
  // Call the handler
114
- const response = await GetRoute({ dbManager, rawPath: '/bat/3.2.1-beta.1/' });
114
+ const response = await GetRoute({ dbManager, rawPath: '/bat/3.2.1-beta.2/' });
115
115
 
116
116
  expect(response).toHaveProperty('appName');
117
117
  expect(response.appName).toBe('bat');
118
118
  expect(response).toHaveProperty('semVer');
119
- expect(response.semVer).toBe('3.2.1-beta.1');
119
+ expect(response.semVer).toBe('3.2.1-beta.2');
120
120
  });
121
121
 
122
122
  it('static app - request to app/x.y.z/ should redirect to defaultFile', async () => {
@@ -153,14 +153,15 @@ describe('router - without prefix', () => {
153
153
  });
154
154
 
155
155
  it('static app - request to app/notVersion should load app frame with defaultFile', async () => {
156
+ const AppName = 'Bat123';
156
157
  const app = new Application({
157
- AppName: 'Bat',
158
+ AppName,
158
159
  DisplayName: 'Bat App',
159
160
  });
160
161
  await app.Save(dbManager);
161
162
 
162
163
  const version = new Version({
163
- AppName: 'Bat',
164
+ AppName,
164
165
  DefaultFile: 'bat.html',
165
166
  IntegrationID: 'abcd',
166
167
  SemVer: '3.2.1-beta.1',
@@ -170,30 +171,31 @@ describe('router - without prefix', () => {
170
171
  await version.Save(dbManager);
171
172
 
172
173
  const rules = new Rules({
173
- AppName: 'Bat',
174
+ AppName,
174
175
  Version: 0,
175
176
  RuleSet: { default: { SemVer: '3.2.1-beta.1', AttributeName: '', AttributeValue: '' } },
176
177
  });
177
178
  await rules.Save(dbManager);
178
179
 
179
180
  // Call the handler
180
- const response = await GetRoute({ dbManager, rawPath: '/bat/notVersion' });
181
+ const response = await GetRoute({ dbManager, rawPath: `/${AppName}/notVersion` });
181
182
 
182
183
  expect(response).toHaveProperty('statusCode');
183
184
  expect(response.statusCode).toBe(200);
184
185
  expect(response).toBeDefined();
185
- expect(response.iFrameAppVersionPath).toBe('/bat/3.2.1-beta.1/bat.html');
186
+ expect(response.iFrameAppVersionPath).toBe(`/${AppName}/3.2.1-beta.1/bat.html`);
186
187
  });
187
188
 
188
189
  it('should serve appframe with no default file', async () => {
190
+ const AppName = 'Bat124';
189
191
  const app = new Application({
190
- AppName: 'Bat',
192
+ AppName,
191
193
  DisplayName: 'Bat App',
192
194
  });
193
195
  await app.Save(dbManager);
194
196
 
195
197
  const version = new Version({
196
- AppName: 'Bat',
198
+ AppName,
197
199
  DefaultFile: '',
198
200
  IntegrationID: 'abcd',
199
201
  SemVer: '3.2.1-beta1',
@@ -203,30 +205,31 @@ describe('router - without prefix', () => {
203
205
  await version.Save(dbManager);
204
206
 
205
207
  const rules = new Rules({
206
- AppName: 'Bat',
208
+ AppName,
207
209
  Version: 0,
208
210
  RuleSet: { default: { SemVer: '3.2.1-beta1', AttributeName: '', AttributeValue: '' } },
209
211
  });
210
212
  await rules.Save(dbManager);
211
213
 
212
214
  // Call the handler
213
- const response = await GetRoute({ dbManager, rawPath: '/bat/' });
215
+ const response = await GetRoute({ dbManager, rawPath: `/${AppName}/` });
214
216
 
215
217
  expect(response).toBeDefined();
216
218
  expect(response).toHaveProperty('statusCode');
217
219
  expect(response.statusCode).toBe(200);
218
- expect(response.iFrameAppVersionPath).toBe('/bat/3.2.1-beta1');
220
+ expect(response.iFrameAppVersionPath).toBe(`/${AppName}/3.2.1-beta1`);
219
221
  });
220
222
 
221
223
  it('should serve appframe with sub-route', async () => {
224
+ const AppName = 'Bat125';
222
225
  const app = new Application({
223
- AppName: 'Bat',
226
+ AppName,
224
227
  DisplayName: 'Bat App',
225
228
  });
226
229
  await app.Save(dbManager);
227
230
 
228
231
  const version = new Version({
229
- AppName: 'Bat',
232
+ AppName,
230
233
  DefaultFile: '',
231
234
  IntegrationID: 'abcd',
232
235
  SemVer: '3.2.1-beta2',
@@ -236,30 +239,31 @@ describe('router - without prefix', () => {
236
239
  await version.Save(dbManager);
237
240
 
238
241
  const rules = new Rules({
239
- AppName: 'Bat',
242
+ AppName,
240
243
  Version: 0,
241
244
  RuleSet: { default: { SemVer: '3.2.1-beta2', AttributeName: '', AttributeValue: '' } },
242
245
  });
243
246
  await rules.Save(dbManager);
244
247
 
245
248
  // Call the handler
246
- const response = await GetRoute({ dbManager, rawPath: '/bat/demo/grid' });
249
+ const response = await GetRoute({ dbManager, rawPath: `/${AppName}/demo/grid` });
247
250
 
248
251
  expect(response).toBeDefined();
249
252
  expect(response).toHaveProperty('statusCode');
250
253
  expect(response.statusCode).toBe(200);
251
- expect(response.iFrameAppVersionPath).toBe('/bat/3.2.1-beta2/demo/grid');
254
+ expect(response.iFrameAppVersionPath).toBe(`/${AppName}/3.2.1-beta2/demo/grid`);
252
255
  });
253
256
 
254
257
  it('should serve appframe with sub-route', async () => {
258
+ const AppName = 'Bat126';
255
259
  const app = new Application({
256
- AppName: 'Bat',
260
+ AppName,
257
261
  DisplayName: 'Bat App',
258
262
  });
259
263
  await app.Save(dbManager);
260
264
 
261
265
  const version = new Version({
262
- AppName: 'Bat',
266
+ AppName,
263
267
  DefaultFile: 'someFile.html',
264
268
  IntegrationID: 'abcd',
265
269
  SemVer: '3.2.1-beta3',
@@ -269,19 +273,19 @@ describe('router - without prefix', () => {
269
273
  await version.Save(dbManager);
270
274
 
271
275
  const rules = new Rules({
272
- AppName: 'Bat',
276
+ AppName,
273
277
  Version: 0,
274
278
  RuleSet: { default: { SemVer: '3.2.1-beta3', AttributeName: '', AttributeValue: '' } },
275
279
  });
276
280
  await rules.Save(dbManager);
277
281
 
278
282
  // Call the handler
279
- const response = await GetRoute({ dbManager, rawPath: '/bat/demo' });
283
+ const response = await GetRoute({ dbManager, rawPath: `/${AppName}/demo` });
280
284
 
281
285
  expect(response).toBeDefined();
282
286
  expect(response).toHaveProperty('statusCode');
283
287
  expect(response.statusCode).toBe(200);
284
- expect(response.iFrameAppVersionPath).toBe('/bat/3.2.1-beta3/demo');
288
+ expect(response.iFrameAppVersionPath).toBe(`/${AppName}/3.2.1-beta3/demo`);
285
289
  });
286
290
 
287
291
  it('should return 404 for /favicon.ico', async () => {
package/src/index.ts CHANGED
@@ -1,12 +1,14 @@
1
- // Used by ts-convict
2
1
  import 'source-map-support/register';
3
2
  import path from 'path';
4
3
  import { pathExistsSync, readFileSync } from 'fs-extra';
5
- import { Application, DBManager, IVersionsAndRules, Version } from '@pwrdrvr/microapps-datalib';
4
+ import { DBManager, Rules, Version } from '@pwrdrvr/microapps-datalib';
6
5
  import Log from './lib/log';
6
+ import { AppVersionCache } from './app-cache';
7
7
 
8
8
  const log = Log.Instance;
9
9
 
10
+ export { AppVersionCache };
11
+
10
12
  /**
11
13
  * Find and load the appFrame file
12
14
  * @returns
@@ -159,10 +161,13 @@ export async function GetRoute(event: IGetRouteEvent): Promise<IGetRouteResult>
159
161
  // /someapp/somepath/somefile.foo will split into length 4 with ["", "someapp", "somepath", "somefile.foo", ""] as results
160
162
  const partsAfterPrefix = pathAfterPrefix.split('/');
161
163
 
162
- const { versionsAndRules, appName } = await GetAppInfo({
164
+ const appName = await GetAppInfo({
163
165
  dbManager,
164
166
  appName: partsAfterPrefix.length >= 2 ? partsAfterPrefix[1] : '[root]',
165
167
  });
168
+ if (!appName) {
169
+ return { statusCode: 404, errorMessage: 'App not found' };
170
+ }
166
171
 
167
172
  const isRootApp = appName === '[root]';
168
173
  const appNameOrRootTrailingSlash = isRootApp ? '' : `${appName}/`;
@@ -215,7 +220,7 @@ export async function GetRoute(event: IGetRouteEvent): Promise<IGetRouteResult>
215
220
  // ^ ^^^^^^^^^^^^
216
221
  // 0 1
217
222
  // Got at least an application name, try to route it
218
- const response = RouteApp({
223
+ const response = await RouteApp({
219
224
  dbManager,
220
225
  normalizedPathPrefix,
221
226
  event,
@@ -224,7 +229,6 @@ export async function GetRoute(event: IGetRouteEvent): Promise<IGetRouteResult>
224
229
  possibleSemVerPathAfterApp,
225
230
  possibleSemVerQuery: queryStringParameters?.get('appver') || '',
226
231
  additionalParts,
227
- versionsAndRules,
228
232
  appNameOrRootTrailingSlash,
229
233
  });
230
234
  if (response) {
@@ -244,44 +248,32 @@ export async function GetRoute(event: IGetRouteEvent): Promise<IGetRouteResult>
244
248
  }
245
249
  }
246
250
 
247
- export interface IAppInfo {
248
- appName: string;
249
- versionsAndRules: IVersionsAndRules;
250
- }
251
-
252
251
  /**
253
- * Get info about an app or the root app
252
+ * Determine if we have an appname or a catch all app
254
253
  */
255
254
  export async function GetAppInfo(opts: {
256
255
  dbManager: DBManager;
257
256
  appName: string;
258
- }): Promise<IAppInfo> {
257
+ }): Promise<string | undefined> {
259
258
  const { dbManager, appName } = opts;
260
259
 
261
- let versionsAndRules: IVersionsAndRules;
260
+ let rules: Rules | undefined;
262
261
 
263
- versionsAndRules = await Application.GetVersionsAndRules({
264
- dbManager,
265
- key: { AppName: appName },
266
- });
262
+ const appVersionCache = AppVersionCache.GetInstance({ dbManager });
267
263
 
268
- if (versionsAndRules.Versions.length > 0) {
269
- return {
270
- appName,
271
- versionsAndRules,
272
- };
264
+ // Check if we got a matching app name
265
+ rules = await appVersionCache.GetRules({ key: { AppName: appName } });
266
+ if (rules && rules.AppName === appName.toLowerCase()) {
267
+ return appName;
273
268
  }
274
269
 
275
270
  // Check if we have a `[root]` app that is a catch all
276
- versionsAndRules = await Application.GetVersionsAndRules({
277
- dbManager,
278
- key: { AppName: '[root]' },
279
- });
271
+ rules = await appVersionCache.GetRules({ key: { AppName: '[root]' } });
272
+ if (rules && rules.AppName === '[root]') {
273
+ return '[root]';
274
+ }
280
275
 
281
- return {
282
- appName: '[root]',
283
- versionsAndRules,
284
- };
276
+ return undefined;
285
277
  }
286
278
 
287
279
  /**
@@ -293,7 +285,7 @@ export async function GetAppInfo(opts: {
293
285
  * @param log
294
286
  * @returns
295
287
  */
296
- function RouteApp(opts: {
288
+ async function RouteApp(opts: {
297
289
  dbManager: DBManager;
298
290
  event: IGetRouteEvent;
299
291
  appName: string;
@@ -302,10 +294,10 @@ function RouteApp(opts: {
302
294
  possibleSemVerQuery?: string;
303
295
  additionalParts: string;
304
296
  normalizedPathPrefix?: string;
305
- versionsAndRules: IVersionsAndRules;
306
297
  appNameOrRootTrailingSlash: string;
307
- }): IGetRouteResult {
298
+ }): Promise<IGetRouteResult> {
308
299
  const {
300
+ dbManager,
309
301
  event,
310
302
  normalizedPathPrefix = '',
311
303
  appName,
@@ -313,21 +305,28 @@ function RouteApp(opts: {
313
305
  possibleSemVerPathAfterApp,
314
306
  possibleSemVerQuery,
315
307
  additionalParts,
316
- versionsAndRules,
317
308
  appNameOrRootTrailingSlash,
318
309
  } = opts;
319
310
 
320
311
  let versionInfoToUse: Version | undefined;
321
312
 
313
+ const appVersionCache = AppVersionCache.GetInstance({ dbManager });
314
+
322
315
  // Check if the semver placeholder is actually a defined version
323
316
  const possibleSemVerPathAfterAppVersionInfo = possibleSemVerPathAfterApp
324
- ? versionsAndRules.Versions.find((item) => item.SemVer === possibleSemVerPathAfterApp)
317
+ ? await appVersionCache.GetVersionInfo({
318
+ key: { AppName: appName, SemVer: possibleSemVerPathAfterApp },
319
+ })
325
320
  : undefined;
326
321
  const possibleSemVerPathNextDataVersionInfo = possibleSemVerPathNextData
327
- ? versionsAndRules.Versions.find((item) => item.SemVer === possibleSemVerPathNextData)
322
+ ? await appVersionCache.GetVersionInfo({
323
+ key: { AppName: appName, SemVer: possibleSemVerPathNextData },
324
+ })
328
325
  : undefined;
329
326
  const possibleSemVerQueryVersionInfo = possibleSemVerQuery
330
- ? versionsAndRules.Versions.find((item) => item.SemVer === possibleSemVerQuery)
327
+ ? await appVersionCache.GetVersionInfo({
328
+ key: { AppName: appName, SemVer: possibleSemVerQuery },
329
+ })
331
330
  : undefined;
332
331
 
333
332
  // If there is a version in the path, use it
@@ -377,7 +376,8 @@ function RouteApp(opts: {
377
376
  // 80% to 1.1.0, 20% to default (1.0.3)
378
377
  //
379
378
 
380
- const defaultVersion = versionsAndRules.Rules?.RuleSet.default?.SemVer;
379
+ const rules = await appVersionCache.GetRules({ key: { AppName: appName } });
380
+ const defaultVersion = rules?.RuleSet.default?.SemVer;
381
381
 
382
382
  if (defaultVersion == null) {
383
383
  log.error(`could not find app ${appName}, for path ${event.rawPath} - returning 404`, {
@@ -390,10 +390,9 @@ function RouteApp(opts: {
390
390
  };
391
391
  }
392
392
 
393
- // TODO: Yeah, this is lame - We should save these in a dictionary keyed by SemVer
394
- const defaultVersionInfo = versionsAndRules.Versions.find(
395
- (item) => item.SemVer === defaultVersion,
396
- );
393
+ const defaultVersionInfo = await appVersionCache.GetVersionInfo({
394
+ key: { AppName: appName, SemVer: defaultVersion },
395
+ });
397
396
 
398
397
  versionInfoToUse = defaultVersionInfo;
399
398
  }
@@ -487,11 +486,13 @@ async function RedirectToDefaultFile(opts: {
487
486
  appNameOrRootTrailingSlash,
488
487
  semVer,
489
488
  } = opts;
490
- let versionInfo: Version;
489
+ let versionInfo: Version | undefined;
491
490
 
492
491
  try {
493
- versionInfo = await Version.LoadVersion({
494
- dbManager,
492
+ // Get the cache
493
+ const appVersionCache = AppVersionCache.GetInstance({ dbManager });
494
+
495
+ versionInfo = await appVersionCache.GetVersionInfo({
495
496
  key: { AppName: appName, SemVer: semVer },
496
497
  });
497
498
  } catch (error) {