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

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