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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/app-cache.d.ts +58 -0
  2. package/dist/app-cache.d.ts.map +1 -0
  3. package/dist/app-cache.js +154 -0
  4. package/dist/app-cache.js.map +1 -0
  5. package/dist/get-app-info.d.ts +9 -0
  6. package/dist/get-app-info.d.ts.map +1 -0
  7. package/dist/get-app-info.js +25 -0
  8. package/dist/get-app-info.js.map +1 -0
  9. package/dist/get-route.d.ts +83 -0
  10. package/dist/get-route.d.ts.map +1 -0
  11. package/dist/get-route.js +154 -0
  12. package/dist/get-route.js.map +1 -0
  13. package/dist/index.d.ts +6 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +14 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/lib/log.d.ts +5 -0
  18. package/dist/lib/log.d.ts.map +1 -0
  19. package/dist/lib/log.js +11 -0
  20. package/dist/lib/log.js.map +1 -0
  21. package/dist/load-app-frame.d.ts +8 -0
  22. package/dist/load-app-frame.d.ts.map +1 -0
  23. package/dist/load-app-frame.js +38 -0
  24. package/dist/load-app-frame.js.map +1 -0
  25. package/dist/normalize-path-prefix.d.ts +8 -0
  26. package/dist/normalize-path-prefix.d.ts.map +1 -0
  27. package/dist/normalize-path-prefix.js +21 -0
  28. package/dist/normalize-path-prefix.js.map +1 -0
  29. package/dist/redirect-default-file.d.ts +18 -0
  30. package/dist/redirect-default-file.d.ts.map +1 -0
  31. package/dist/redirect-default-file.js +54 -0
  32. package/dist/redirect-default-file.js.map +1 -0
  33. package/dist/route-app.d.ts +23 -0
  34. package/dist/route-app.d.ts.map +1 -0
  35. package/dist/route-app.js +169 -0
  36. package/dist/route-app.js.map +1 -0
  37. package/package.json +5 -2
  38. package/src/app-cache.spec.ts +175 -0
  39. package/src/app-cache.ts +193 -0
  40. package/src/get-app-info.spec.ts +77 -0
  41. package/src/get-app-info.ts +31 -0
  42. package/src/get-route.spec.ts +585 -0
  43. package/src/get-route.ts +267 -0
  44. package/src/index.ts +5 -536
  45. package/src/load-app-frame.spec.ts +51 -0
  46. package/src/load-app-frame.ts +36 -0
  47. package/src/normalize-path-prefix.spec.ts +27 -0
  48. package/src/normalize-path-prefix.ts +18 -0
  49. package/src/redirect-default-file.spec.ts +98 -0
  50. package/src/redirect-default-file.ts +79 -0
  51. package/src/route-app.spec.ts +128 -0
  52. package/src/route-app.ts +202 -0
  53. package/src/index.spec.ts +0 -318
  54. /package/src/{index.prefix.spec.ts → get-route.prefix.spec.ts} +0 -0
package/src/index.ts CHANGED
@@ -1,536 +1,5 @@
1
- // Used by ts-convict
2
- import 'source-map-support/register';
3
- import path from 'path';
4
- import { pathExistsSync, readFileSync } from 'fs-extra';
5
- import { Application, DBManager, IVersionsAndRules, Version } from '@pwrdrvr/microapps-datalib';
6
- import Log from './lib/log';
7
-
8
- const log = Log.Instance;
9
-
10
- /**
11
- * Find and load the appFrame file
12
- * @returns
13
- */
14
- export function loadAppFrame({ basePath = '.' }: { basePath?: string }): string {
15
- const paths = [
16
- basePath,
17
- path.join(basePath, '..'),
18
- path.join(basePath, 'templates'),
19
- basePath,
20
- '/opt',
21
- '/opt/templates',
22
- ];
23
-
24
- for (const pathRoot of paths) {
25
- const fullPath = path.join(pathRoot, 'appFrame.html');
26
- try {
27
- if (pathExistsSync(fullPath)) {
28
- log.info('found html file', { fullPath });
29
- return readFileSync(fullPath, 'utf-8');
30
- }
31
- } catch {
32
- // Don't care - we get here if stat throws because the file does not exist
33
- }
34
- }
35
-
36
- log.error('appFrame.html not found');
37
- throw new Error('appFrame.html not found');
38
- }
39
-
40
- /**
41
- * Ensure that the path starts with a / and does not end with a /
42
- *
43
- * @param pathPrefix
44
- * @returns
45
- */
46
- export function normalizePathPrefix(pathPrefix: string): string {
47
- let normalizedPathPrefix = pathPrefix;
48
- if (normalizedPathPrefix !== '' && !normalizedPathPrefix.startsWith('/')) {
49
- normalizedPathPrefix = '/' + pathPrefix;
50
- }
51
- if (normalizedPathPrefix.endsWith('/')) {
52
- normalizedPathPrefix.substring(0, normalizedPathPrefix.length - 1);
53
- }
54
-
55
- return normalizedPathPrefix;
56
- }
57
-
58
- export interface IGetRouteResult {
59
- /**
60
- * HTTP status code for immediate response, immediate redirect, and errors
61
- */
62
- readonly statusCode?: number;
63
-
64
- /**
65
- * Error message for errors
66
- */
67
- readonly errorMessage?: string;
68
-
69
- /**
70
- * Location to redirect to
71
- */
72
- readonly redirectLocation?: string;
73
-
74
- /**
75
- * Optional headers for immediate response, immediate redirect, and errors
76
- */
77
- readonly headers?: Record<string, string>;
78
-
79
- /**
80
- *
81
- *
82
- * @example /myapp/1.0.0/index.html
83
- * @example /myapp/1.0.1
84
- * @example /myapp/1.0.2/some/path?query=string
85
- */
86
- readonly iFrameAppVersionPath?: string;
87
-
88
- /**
89
- * Name of the app if resolved
90
- */
91
- readonly appName?: string;
92
-
93
- /**
94
- * Version of the app if resolved
95
- */
96
- readonly semVer?: string;
97
-
98
- /**
99
- * Type of the app
100
- */
101
- readonly type?: 'apigwy' | 'lambda-url' | 'url' | 'static';
102
-
103
- /**
104
- * Startup type of the app (indirect with iframe or direct)
105
- */
106
- readonly startupType?: 'iframe' | 'direct';
107
-
108
- /**
109
- * URL to the app if resolved
110
- */
111
- readonly url?: string;
112
- }
113
-
114
- export interface IGetRouteEvent {
115
- readonly dbManager: DBManager;
116
-
117
- /**
118
- * rawPath from the Lambda event
119
- */
120
- readonly rawPath: string;
121
-
122
- /**
123
- * Configured prefix of the deployment, must start with a / and not end with a /
124
- */
125
- readonly normalizedPathPrefix?: string;
126
-
127
- /**
128
- * Query string params, if any
129
- * Checked for `appver=1.2.3` to override the app version
130
- */
131
- readonly queryStringParameters?: URLSearchParams;
132
- }
133
-
134
- /**
135
- * Get information about immediate redirect, immediate response,
136
- * or which host to route the request to.
137
- *
138
- * @param event
139
- *
140
- * @returns IGetRouteResult
141
- */
142
- export async function GetRoute(event: IGetRouteEvent): Promise<IGetRouteResult> {
143
- const { dbManager, normalizedPathPrefix = '', queryStringParameters } = event;
144
-
145
- try {
146
- if (normalizedPathPrefix && !event.rawPath.startsWith(normalizedPathPrefix)) {
147
- // The prefix is required if configured, if missing we cannot serve this app
148
- return { statusCode: 404, errorMessage: 'Request not routable' };
149
- }
150
-
151
- const pathAfterPrefix =
152
- normalizedPathPrefix && event.rawPath.startsWith(normalizedPathPrefix)
153
- ? event.rawPath.slice(normalizedPathPrefix.length - 1)
154
- : event.rawPath;
155
-
156
- // /someapp will split into length 2 with ["", "someapp"] as results
157
- // /someapp/somepath will split into length 3 with ["", "someapp", "somepath"] as results
158
- // /someapp/somepath/ will split into length 3 with ["", "someapp", "somepath", ""] as results
159
- // /someapp/somepath/somefile.foo will split into length 4 with ["", "someapp", "somepath", "somefile.foo", ""] as results
160
- const partsAfterPrefix = pathAfterPrefix.split('/');
161
-
162
- const { versionsAndRules, appName } = await GetAppInfo({
163
- dbManager,
164
- appName: partsAfterPrefix.length >= 2 ? partsAfterPrefix[1] : '[root]',
165
- });
166
-
167
- const isRootApp = appName === '[root]';
168
- const appNameOrRootTrailingSlash = isRootApp ? '' : `${appName}/`;
169
-
170
- // Strip the appName from the start of the path, if there was one
171
- const pathAfterAppName = isRootApp
172
- ? pathAfterPrefix
173
- : pathAfterPrefix.slice(appName.length + 1);
174
- const partsAfterAppName = pathAfterAppName.split('/');
175
-
176
- // Pass any parts after the appName/Version to the route handler
177
- let additionalParts = '';
178
- if (partsAfterAppName.length >= 2 && partsAfterAppName[1] !== '') {
179
- additionalParts = partsAfterAppName.slice(1).join('/');
180
- }
181
-
182
- // Route an app and version (only) to include the defaultFile
183
- // If the second part is not a version that exists, fall through to
184
- // routing the app and glomming the rest of the path on to the end
185
- if (
186
- partsAfterAppName.length === 2 ||
187
- (partsAfterAppName.length === 3 && !partsAfterAppName[2])
188
- ) {
189
- // / semVer /
190
- // ^ ^^^^^^ ^
191
- // 0 1 2
192
- // This is an app and a version only
193
- // If the request got here it's likely a static app that has no
194
- // Lambda function (thus the API Gateway route fell through to the Router)
195
- const response = await RedirectToDefaultFile({
196
- dbManager,
197
- appName,
198
- normalizedPathPrefix,
199
- semVer: partsAfterAppName[1],
200
- appNameOrRootTrailingSlash,
201
- });
202
- if (response) {
203
- return response;
204
- }
205
- }
206
-
207
- // Check for a version in the path
208
- // Examples
209
- // / semVer / somepath
210
- // / _next / data / semVer / somepath
211
- const possibleSemVerPathNextData = partsAfterAppName.length >= 4 ? partsAfterAppName[3] : '';
212
- const possibleSemVerPathAfterApp = partsAfterAppName.length >= 2 ? partsAfterAppName[1] : '';
213
-
214
- // (/ something)?
215
- // ^ ^^^^^^^^^^^^
216
- // 0 1
217
- // Got at least an application name, try to route it
218
- const response = RouteApp({
219
- dbManager,
220
- normalizedPathPrefix,
221
- event,
222
- appName,
223
- possibleSemVerPathNextData,
224
- possibleSemVerPathAfterApp,
225
- possibleSemVerQuery: queryStringParameters?.get('appver') || '',
226
- additionalParts,
227
- versionsAndRules,
228
- appNameOrRootTrailingSlash,
229
- });
230
- if (response) {
231
- return response;
232
- }
233
-
234
- return {
235
- statusCode: 599,
236
- errorMessage: `Router - Could not route: ${event.rawPath}, no matching route`,
237
- };
238
- } catch (error: any) {
239
- log.error('unexpected exception - returning 599', { statusCode: 599, error });
240
- return {
241
- statusCode: 599,
242
- errorMessage: `Router - Could not route: ${event.rawPath}, ${error.message}`,
243
- };
244
- }
245
- }
246
-
247
- export interface IAppInfo {
248
- appName: string;
249
- versionsAndRules: IVersionsAndRules;
250
- }
251
-
252
- /**
253
- * Get info about an app or the root app
254
- */
255
- export async function GetAppInfo(opts: {
256
- dbManager: DBManager;
257
- appName: string;
258
- }): Promise<IAppInfo> {
259
- const { dbManager, appName } = opts;
260
-
261
- let versionsAndRules: IVersionsAndRules;
262
-
263
- versionsAndRules = await Application.GetVersionsAndRules({
264
- dbManager,
265
- key: { AppName: appName },
266
- });
267
-
268
- if (versionsAndRules.Versions.length > 0) {
269
- return {
270
- appName,
271
- versionsAndRules,
272
- };
273
- }
274
-
275
- // Check if we have a `[root]` app that is a catch all
276
- versionsAndRules = await Application.GetVersionsAndRules({
277
- dbManager,
278
- key: { AppName: '[root]' },
279
- });
280
-
281
- return {
282
- appName: '[root]',
283
- versionsAndRules,
284
- };
285
- }
286
-
287
- /**
288
- * Lookup the version of the app to run
289
- * @param event
290
- * @param response
291
- * @param appName
292
- * @param additionalParts
293
- * @param log
294
- * @returns
295
- */
296
- function RouteApp(opts: {
297
- dbManager: DBManager;
298
- event: IGetRouteEvent;
299
- appName: string;
300
- possibleSemVerPathNextData?: string;
301
- possibleSemVerPathAfterApp?: string;
302
- possibleSemVerQuery?: string;
303
- additionalParts: string;
304
- normalizedPathPrefix?: string;
305
- versionsAndRules: IVersionsAndRules;
306
- appNameOrRootTrailingSlash: string;
307
- }): IGetRouteResult {
308
- const {
309
- event,
310
- normalizedPathPrefix = '',
311
- appName,
312
- possibleSemVerPathNextData,
313
- possibleSemVerPathAfterApp,
314
- possibleSemVerQuery,
315
- additionalParts,
316
- versionsAndRules,
317
- appNameOrRootTrailingSlash,
318
- } = opts;
319
-
320
- let versionInfoToUse: Version | undefined;
321
-
322
- // Check if the semver placeholder is actually a defined version
323
- const possibleSemVerPathAfterAppVersionInfo = possibleSemVerPathAfterApp
324
- ? versionsAndRules.Versions.find((item) => item.SemVer === possibleSemVerPathAfterApp)
325
- : undefined;
326
- const possibleSemVerPathNextDataVersionInfo = possibleSemVerPathNextData
327
- ? versionsAndRules.Versions.find((item) => item.SemVer === possibleSemVerPathNextData)
328
- : undefined;
329
- const possibleSemVerQueryVersionInfo = possibleSemVerQuery
330
- ? versionsAndRules.Versions.find((item) => item.SemVer === possibleSemVerQuery)
331
- : undefined;
332
-
333
- // If there is a version in the path, use it
334
- const possibleSemVerPathVersionInfo =
335
- possibleSemVerPathAfterAppVersionInfo || possibleSemVerPathNextDataVersionInfo;
336
- if (possibleSemVerPathVersionInfo) {
337
- // This is a version, and it's in the path already, route the request to it
338
- // without creating iframe
339
- return {
340
- appName,
341
- semVer: possibleSemVerPathVersionInfo.SemVer,
342
- ...(possibleSemVerPathVersionInfo?.URL ? { url: possibleSemVerPathVersionInfo?.URL } : {}),
343
- ...(possibleSemVerPathVersionInfo?.Type
344
- ? {
345
- type:
346
- possibleSemVerPathVersionInfo?.Type === 'lambda'
347
- ? 'apigwy'
348
- : possibleSemVerPathVersionInfo?.Type,
349
- }
350
- : {}),
351
- };
352
- } else if (possibleSemVerQueryVersionInfo) {
353
- // We got a version for the query string, but it's not in the path,
354
- // so fall back to normal routing (create an iframe or direct route)
355
- versionInfoToUse = possibleSemVerQueryVersionInfo;
356
- } else if (possibleSemVerQuery) {
357
- // We got a version in the query string but it does not exist
358
- // This needs to 404 as this is a very specific request for a specific version
359
- log.error(`could not find app ${appName}, for path ${event.rawPath} - returning 404`, {
360
- statusCode: 404,
361
- });
362
-
363
- return {
364
- statusCode: 404,
365
- errorMessage: `Router - Could not find app: ${event.rawPath}, ${appName}`,
366
- };
367
- } else {
368
- //
369
- // TODO: Get the incoming attributes of user
370
- // For logged in users, these can be: department, product type,
371
- // employee, office, division, etc.
372
- // For anonymous users this can be: geo region, language,
373
- // browser, IP, CIDR, ASIN, etc.
374
- //
375
- // The Rules can be either a version or a distribution of versions,
376
- // including default, for example:
377
- // 80% to 1.1.0, 20% to default (1.0.3)
378
- //
379
-
380
- const defaultVersion = versionsAndRules.Rules?.RuleSet.default?.SemVer;
381
-
382
- if (defaultVersion == null) {
383
- log.error(`could not find app ${appName}, for path ${event.rawPath} - returning 404`, {
384
- statusCode: 404,
385
- });
386
-
387
- return {
388
- statusCode: 404,
389
- errorMessage: `Router - Could not find app: ${event.rawPath}, ${appName}`,
390
- };
391
- }
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
- );
397
-
398
- versionInfoToUse = defaultVersionInfo;
399
- }
400
-
401
- if (!versionInfoToUse) {
402
- log.error(
403
- `could not find version info for app ${appName}, for path ${event.rawPath} - returning 404`,
404
- {
405
- statusCode: 404,
406
- },
407
- );
408
-
409
- return {
410
- statusCode: 404,
411
- errorMessage: `Router - Could not find version info for app: ${event.rawPath}, ${appName}`,
412
- };
413
- }
414
-
415
- if (versionInfoToUse?.StartupType === 'iframe' || !versionInfoToUse?.StartupType) {
416
- // Prepare the iframe contents
417
- let appVersionPath: string;
418
- if (
419
- versionInfoToUse?.Type !== 'static' &&
420
- (versionInfoToUse?.DefaultFile === undefined ||
421
- versionInfoToUse?.DefaultFile === '' ||
422
- additionalParts !== '')
423
- ) {
424
- // KLUDGE: We're going to take a missing default file to mean that the
425
- // app type is Next.js (or similar) and that it wants no trailing slash after the version
426
- // TODO: Move this to an attribute of the version
427
- appVersionPath = `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${versionInfoToUse.SemVer}`;
428
- if (additionalParts !== '') {
429
- appVersionPath += `/${additionalParts}`;
430
- }
431
- } else {
432
- // Linking to the file directly means this will be peeled off by the S3 route
433
- // That means we won't have to proxy this from S3
434
- appVersionPath = `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${versionInfoToUse.SemVer}/${versionInfoToUse.DefaultFile}`;
435
- }
436
-
437
- return {
438
- statusCode: 200,
439
- appName,
440
- semVer: versionInfoToUse.SemVer,
441
- startupType: 'iframe',
442
- ...(versionInfoToUse?.URL ? { url: versionInfoToUse?.URL } : {}),
443
- ...(versionInfoToUse?.Type
444
- ? { type: versionInfoToUse?.Type === 'lambda' ? 'apigwy' : versionInfoToUse?.Type }
445
- : {}),
446
- iFrameAppVersionPath: appVersionPath,
447
- };
448
- } else {
449
- // This is a direct app version, no iframe needed
450
-
451
- if (versionInfoToUse?.Type === 'lambda') {
452
- throw new Error('Invalid type for direct app version');
453
- }
454
- if (['apigwy', 'static'].includes(versionInfoToUse?.Type || '')) {
455
- throw new Error('Invalid type for direct app version');
456
- }
457
-
458
- return {
459
- appName,
460
- semVer: versionInfoToUse.SemVer,
461
- startupType: 'direct',
462
- ...(versionInfoToUse?.URL ? { url: versionInfoToUse?.URL } : {}),
463
- ...(versionInfoToUse?.Type ? { type: versionInfoToUse?.Type } : {}),
464
- };
465
- }
466
- }
467
-
468
- /**
469
- * Redirect the request to app/x.y.z/? to app/x.y.z/{defaultFile}
470
- * @param response
471
- * @param normalizedPathPrefix
472
- * @param appName
473
- * @param semVer
474
- * @returns
475
- */
476
- async function RedirectToDefaultFile(opts: {
477
- dbManager: DBManager;
478
- normalizedPathPrefix?: string;
479
- appName: string;
480
- semVer: string;
481
- appNameOrRootTrailingSlash: string;
482
- }): Promise<IGetRouteResult | undefined> {
483
- const {
484
- dbManager,
485
- normalizedPathPrefix = '',
486
- appName,
487
- appNameOrRootTrailingSlash,
488
- semVer,
489
- } = opts;
490
- let versionInfo: Version;
491
-
492
- try {
493
- versionInfo = await Version.LoadVersion({
494
- dbManager,
495
- key: { AppName: appName, SemVer: semVer },
496
- });
497
- } catch (error) {
498
- log.info(
499
- `LoadVersion threw for '${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}' - falling through to app routing'`,
500
- {
501
- appName,
502
- semVer,
503
- error,
504
- },
505
- );
506
- return undefined;
507
- }
508
-
509
- if (versionInfo === undefined) {
510
- log.info(
511
- `LoadVersion returned undefined for '${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}', assuming not found - falling through to app routing'`,
512
- {
513
- appName,
514
- semVer,
515
- },
516
- );
517
- return undefined;
518
- }
519
-
520
- if (!versionInfo.DefaultFile) {
521
- return undefined;
522
- }
523
-
524
- log.info(
525
- `found '${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}' - returning 302 to ${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}/${versionInfo.DefaultFile}`,
526
- {
527
- statusCode: 302,
528
- routedPath: `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}/${versionInfo.DefaultFile}`,
529
- },
530
- );
531
-
532
- return {
533
- statusCode: 302,
534
- redirectLocation: `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}/${versionInfo.DefaultFile}`,
535
- };
536
- }
1
+ export { loadAppFrame } from './load-app-frame';
2
+ export { GetAppInfo } from './get-app-info';
3
+ export { normalizePathPrefix } from './normalize-path-prefix';
4
+ export { GetRoute, IGetRouteEvent, IGetRouteResult } from './get-route';
5
+ export { RedirectToDefaultFile } from './redirect-default-file';
@@ -0,0 +1,51 @@
1
+ import mockFs from 'mock-fs';
2
+ import { loadAppFrame } from './load-app-frame';
3
+ import Log from './lib/log';
4
+
5
+ describe('loadAppFrame', () => {
6
+ afterEach(() => {
7
+ mockFs.restore();
8
+ });
9
+
10
+ it('should load appFrame.html from the default basePath', () => {
11
+ const mockHtmlContent = '<html><body>App Frame</body></html>';
12
+ mockFs({
13
+ 'appFrame.html': mockHtmlContent,
14
+ });
15
+
16
+ expect(loadAppFrame({})).toBe(mockHtmlContent);
17
+ });
18
+
19
+ it('should load appFrame.html from a custom basePath', () => {
20
+ const mockHtmlContent = '<html><body>App Frame</body></html>';
21
+ const customBasePath = './custom';
22
+ mockFs({
23
+ [customBasePath]: {
24
+ 'appFrame.html': mockHtmlContent,
25
+ },
26
+ });
27
+
28
+ expect(loadAppFrame({ basePath: customBasePath })).toBe(mockHtmlContent);
29
+ });
30
+
31
+ it('should throw an error if appFrame.html is not found', () => {
32
+ mockFs({});
33
+
34
+ expect(() => {
35
+ loadAppFrame({});
36
+ }).toThrowError('appFrame.html not found');
37
+ });
38
+
39
+ it('should log the error if appFrame.html is not found', () => {
40
+ mockFs({});
41
+ const logSpy = jest.spyOn(Log.Instance, 'error');
42
+
43
+ try {
44
+ loadAppFrame({});
45
+ } catch {
46
+ // Catch the error to allow the test to continue
47
+ }
48
+
49
+ expect(logSpy).toHaveBeenCalledWith('appFrame.html not found');
50
+ });
51
+ });
@@ -0,0 +1,36 @@
1
+ import path from 'path';
2
+ import { pathExistsSync, readFileSync } from 'fs-extra';
3
+ import Log from './lib/log';
4
+
5
+ const log = Log.Instance;
6
+
7
+ /**
8
+ * Find and load the appFrame file
9
+ * @returns
10
+ */
11
+
12
+ export function loadAppFrame({ basePath = '.' }: { basePath?: string }): string {
13
+ const paths = [
14
+ basePath,
15
+ path.join(basePath, '..'),
16
+ path.join(basePath, 'templates'),
17
+ basePath,
18
+ '/opt',
19
+ '/opt/templates',
20
+ ];
21
+
22
+ for (const pathRoot of paths) {
23
+ const fullPath = path.join(pathRoot, 'appFrame.html');
24
+ try {
25
+ if (pathExistsSync(fullPath)) {
26
+ log.info('found html file', { fullPath });
27
+ return readFileSync(fullPath, 'utf-8');
28
+ }
29
+ } catch {
30
+ // Don't care - we get here if stat throws because the file does not exist
31
+ }
32
+ }
33
+
34
+ log.error('appFrame.html not found');
35
+ throw new Error('appFrame.html not found');
36
+ }
@@ -0,0 +1,27 @@
1
+ import { normalizePathPrefix } from './normalize-path-prefix';
2
+
3
+ describe('normalizePathPrefix', () => {
4
+ it('should return an empty string when the input is an empty string', () => {
5
+ expect(normalizePathPrefix('')).toBe('');
6
+ });
7
+
8
+ it('should add a leading slash if it does not exist', () => {
9
+ expect(normalizePathPrefix('path')).toBe('/path');
10
+ });
11
+
12
+ it('should not add a leading slash if it already exists', () => {
13
+ expect(normalizePathPrefix('/path')).toBe('/path');
14
+ });
15
+
16
+ it('should remove a trailing slash if it exists', () => {
17
+ expect(normalizePathPrefix('/path/')).toBe('/path');
18
+ });
19
+
20
+ it('should remove a trailing slash and add a leading slash if needed', () => {
21
+ expect(normalizePathPrefix('path/')).toBe('/path');
22
+ });
23
+
24
+ it('should return the same input when it starts with a slash and does not end with a slash', () => {
25
+ expect(normalizePathPrefix('/path/subpath')).toBe('/path/subpath');
26
+ });
27
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Ensure that the path starts with a / and does not end with a /
3
+ *
4
+ * @param pathPrefix
5
+ * @returns
6
+ */
7
+
8
+ export function normalizePathPrefix(pathPrefix: string): string {
9
+ let normalizedPathPrefix = pathPrefix;
10
+ if (normalizedPathPrefix !== '' && !normalizedPathPrefix.startsWith('/')) {
11
+ normalizedPathPrefix = '/' + pathPrefix;
12
+ }
13
+ if (normalizedPathPrefix.endsWith('/')) {
14
+ normalizedPathPrefix = normalizedPathPrefix.substring(0, normalizedPathPrefix.length - 1);
15
+ }
16
+
17
+ return normalizedPathPrefix;
18
+ }