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

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 PwrDrvr LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@pwrdrvr/microapps-router-lib",
3
+ "version": "0.4.0-alpha.8",
4
+ "description": "Router library for the microapps framework",
5
+ "types": "dist/index.d.ts",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/pwrdrvr/microapps-core.git"
13
+ },
14
+ "author": "PwrDrvr LLC",
15
+ "license": "MIT",
16
+ "bugs": {
17
+ "url": "https://github.com/pwrdrvr/microapps-core/issues"
18
+ },
19
+ "homepage": "https://github.com/pwrdrvr/microapps-core#readme",
20
+ "peerDependencies": {
21
+ "@aws-sdk/client-dynamodb": "^3.0.0",
22
+ "fs-extra": "^9.0.0",
23
+ "lambda-log": "^3.0.0",
24
+ "source-map-support": "^0.5.0",
25
+ "tslib": "^2.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@aws-sdk/client-dynamodb": "3.78.0",
29
+ "@types/aws-lambda": "8.10.110",
30
+ "@types/lambda-log": "^2.2.0",
31
+ "@types/node": "16.9.2",
32
+ "@types/source-map-support": "0.5.6",
33
+ "fs-extra": "^9.1.0",
34
+ "lambda-log": "^3.1.0",
35
+ "source-map-support": "0.5.21",
36
+ "tslib": "^2.1.0"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "package.json",
42
+ "LICENSE"
43
+ ]
44
+ }
@@ -0,0 +1,93 @@
1
+ /// <reference types="jest" />
2
+ import 'reflect-metadata';
3
+ import 'jest-dynalite/withDb';
4
+ import * as dynamodb from '@aws-sdk/client-dynamodb';
5
+ import { Application, DBManager, Version, Rules } from '@pwrdrvr/microapps-datalib';
6
+ import { GetRoute } from './index';
7
+
8
+ let dynamoClient: dynamodb.DynamoDBClient;
9
+ let dbManager: DBManager;
10
+
11
+ const TEST_TABLE_NAME = 'microapps';
12
+
13
+ describe('router - with prefix', () => {
14
+ beforeAll(() => {
15
+ dynamoClient = new dynamodb.DynamoDBClient({
16
+ endpoint: process.env.MOCK_DYNAMODB_ENDPOINT,
17
+ tls: false,
18
+ region: 'local',
19
+ });
20
+
21
+ // Init the DB manager to point it at the right table
22
+ dbManager = new DBManager({ dynamoClient, tableName: TEST_TABLE_NAME });
23
+ });
24
+
25
+ it('should serve appframe with version and default file substitued', async () => {
26
+ const app = new Application({
27
+ AppName: 'Bat',
28
+ DisplayName: 'Bat App',
29
+ });
30
+ await app.Save(dbManager);
31
+
32
+ const version = new Version({
33
+ AppName: 'Bat',
34
+ DefaultFile: 'bat.html',
35
+ IntegrationID: 'abcd',
36
+ SemVer: '3.2.1-beta.1',
37
+ Status: 'deployed',
38
+ Type: 'lambda',
39
+ });
40
+ await version.Save(dbManager);
41
+
42
+ const rules = new Rules({
43
+ AppName: 'Bat',
44
+ Version: 0,
45
+ RuleSet: { default: { SemVer: '3.2.1-beta.1', AttributeName: '', AttributeValue: '' } },
46
+ });
47
+ await rules.Save(dbManager);
48
+
49
+ // Call the handler
50
+ const response = await GetRoute({
51
+ dbManager,
52
+ normalizedPathPrefix: '/qa',
53
+ rawPath: '/qa/bat/',
54
+ });
55
+
56
+ expect(response).toHaveProperty('statusCode');
57
+ expect(response.statusCode).toBe(200);
58
+ expect(response).toBeDefined();
59
+ expect(response.iFrameAppVersionPath).toBe('/qa/bat/3.2.1-beta.1/bat.html');
60
+ });
61
+
62
+ it('should 404 appframe with version if the prefix is missing', async () => {
63
+ const app = new Application({
64
+ AppName: 'Bat',
65
+ DisplayName: 'Bat App',
66
+ });
67
+ await app.Save(dbManager);
68
+
69
+ const version = new Version({
70
+ AppName: 'Bat',
71
+ DefaultFile: 'bat.html',
72
+ IntegrationID: 'abcd',
73
+ SemVer: '3.2.1-beta.1',
74
+ Status: 'deployed',
75
+ Type: 'lambda',
76
+ });
77
+ await version.Save(dbManager);
78
+
79
+ const rules = new Rules({
80
+ AppName: 'Bat',
81
+ Version: 0,
82
+ RuleSet: { default: { SemVer: '3.2.1-beta.1', AttributeName: '', AttributeValue: '' } },
83
+ });
84
+ await rules.Save(dbManager);
85
+
86
+ // Call the handler
87
+ const response = await GetRoute({ dbManager, normalizedPathPrefix: '/qa', rawPath: '/bat/' });
88
+
89
+ expect(response).toHaveProperty('statusCode');
90
+ expect(response.statusCode).toBe(404);
91
+ expect(response.errorMessage).toBe('Request not routable');
92
+ });
93
+ });
@@ -0,0 +1,318 @@
1
+ /// <reference types="jest" />
2
+ import 'jest-dynalite/withDb';
3
+ import * as dynamodb from '@aws-sdk/client-dynamodb';
4
+ import { Application, DBManager, Version, Rules } from '@pwrdrvr/microapps-datalib';
5
+ import { GetRoute } from './index';
6
+
7
+ let dynamoClient: dynamodb.DynamoDBClient;
8
+ let dbManager: DBManager;
9
+
10
+ const TEST_TABLE_NAME = 'microapps';
11
+
12
+ describe('router - without prefix', () => {
13
+ beforeAll(() => {
14
+ dynamoClient = new dynamodb.DynamoDBClient({
15
+ endpoint: process.env.MOCK_DYNAMODB_ENDPOINT,
16
+ tls: false,
17
+ region: 'local',
18
+ });
19
+
20
+ // Init the DB manager to point it at the right table
21
+ dbManager = new DBManager({ dynamoClient, tableName: TEST_TABLE_NAME });
22
+ });
23
+
24
+ it('should serve appframe with version and default file substitued', async () => {
25
+ const app = new Application({
26
+ AppName: 'Bat',
27
+ DisplayName: 'Bat App',
28
+ });
29
+ await app.Save(dbManager);
30
+
31
+ const version = new Version({
32
+ AppName: 'Bat',
33
+ DefaultFile: 'bat.html',
34
+ IntegrationID: 'abcd',
35
+ SemVer: '3.2.1-beta.1',
36
+ Status: 'deployed',
37
+ Type: 'lambda',
38
+ });
39
+ await version.Save(dbManager);
40
+
41
+ const rules = new Rules({
42
+ AppName: 'Bat',
43
+ Version: 0,
44
+ RuleSet: { default: { SemVer: '3.2.1-beta.1', AttributeName: '', AttributeValue: '' } },
45
+ });
46
+ await rules.Save(dbManager);
47
+
48
+ // Call the handler
49
+ const response = await GetRoute({ dbManager, rawPath: '/bat/' });
50
+
51
+ expect(response).toHaveProperty('statusCode');
52
+ expect(response.statusCode).toBe(200);
53
+ expect(response).toBeDefined();
54
+ expect(response.iFrameAppVersionPath).toBe('/bat/3.2.1-beta.1/bat.html');
55
+ });
56
+
57
+ it('static app - request to app/x.y.z should redirect to defaultFile', async () => {
58
+ const app = new Application({
59
+ AppName: 'Bat',
60
+ DisplayName: 'Bat App',
61
+ });
62
+ await app.Save(dbManager);
63
+
64
+ const version = new Version({
65
+ AppName: 'Bat',
66
+ DefaultFile: 'bat.html',
67
+ IntegrationID: 'abcd',
68
+ SemVer: '3.2.1-beta.1',
69
+ Status: 'deployed',
70
+ Type: 'static',
71
+ });
72
+ await version.Save(dbManager);
73
+
74
+ const rules = new Rules({
75
+ AppName: 'Bat',
76
+ Version: 0,
77
+ RuleSet: { default: { SemVer: '3.2.1-beta.1', AttributeName: '', AttributeValue: '' } },
78
+ });
79
+ await rules.Save(dbManager);
80
+
81
+ // Call the handler
82
+ const response = await GetRoute({ dbManager, rawPath: '/bat/3.2.1-beta.1' });
83
+
84
+ expect(response).toHaveProperty('statusCode');
85
+ expect(response.statusCode).toBe(302);
86
+ expect(response.redirectLocation).toBeDefined();
87
+ expect(response.redirectLocation).toBe('/bat/3.2.1-beta.1/bat.html');
88
+ }, 60000);
89
+
90
+ it('static app - request to app/x.y.z/ should not redirect if no defaultFile', async () => {
91
+ const app = new Application({
92
+ AppName: 'Bat',
93
+ DisplayName: 'Bat App',
94
+ });
95
+ await app.Save(dbManager);
96
+
97
+ const version = new Version({
98
+ AppName: 'Bat',
99
+ IntegrationID: 'abcd',
100
+ SemVer: '3.2.1-beta.1',
101
+ Status: 'deployed',
102
+ Type: 'static',
103
+ });
104
+ await version.Save(dbManager);
105
+
106
+ const rules = new Rules({
107
+ AppName: 'Bat',
108
+ Version: 0,
109
+ RuleSet: { default: { SemVer: '3.2.1-beta.1', AttributeName: '', AttributeValue: '' } },
110
+ });
111
+ await rules.Save(dbManager);
112
+
113
+ // Call the handler
114
+ const response = await GetRoute({ dbManager, rawPath: '/bat/3.2.1-beta.1/' });
115
+
116
+ expect(response).toHaveProperty('appName');
117
+ expect(response.appName).toBe('bat');
118
+ expect(response).toHaveProperty('semVer');
119
+ expect(response.semVer).toBe('3.2.1-beta.1');
120
+ });
121
+
122
+ it('static app - request to app/x.y.z/ should redirect to defaultFile', async () => {
123
+ const app = new Application({
124
+ AppName: 'Bat',
125
+ DisplayName: 'Bat App',
126
+ });
127
+ await app.Save(dbManager);
128
+
129
+ const version = new Version({
130
+ AppName: 'Bat',
131
+ DefaultFile: 'bat.html',
132
+ IntegrationID: 'abcd',
133
+ SemVer: '3.2.1-beta.1',
134
+ Status: 'deployed',
135
+ Type: 'static',
136
+ });
137
+ await version.Save(dbManager);
138
+
139
+ const rules = new Rules({
140
+ AppName: 'Bat',
141
+ Version: 0,
142
+ RuleSet: { default: { SemVer: '3.2.1-beta.1', AttributeName: '', AttributeValue: '' } },
143
+ });
144
+ await rules.Save(dbManager);
145
+
146
+ // Call the handler
147
+ const response = await GetRoute({ dbManager, rawPath: '/bat/3.2.1-beta.1/' });
148
+
149
+ expect(response).toHaveProperty('statusCode');
150
+ expect(response.statusCode).toBe(302);
151
+ expect(response.redirectLocation).toBeDefined();
152
+ expect(response.redirectLocation).toBe('/bat/3.2.1-beta.1/bat.html');
153
+ });
154
+
155
+ it('static app - request to app/notVersion should load app frame with defaultFile', async () => {
156
+ const app = new Application({
157
+ AppName: 'Bat',
158
+ DisplayName: 'Bat App',
159
+ });
160
+ await app.Save(dbManager);
161
+
162
+ const version = new Version({
163
+ AppName: 'Bat',
164
+ DefaultFile: 'bat.html',
165
+ IntegrationID: 'abcd',
166
+ SemVer: '3.2.1-beta.1',
167
+ Status: 'deployed',
168
+ Type: 'static',
169
+ });
170
+ await version.Save(dbManager);
171
+
172
+ const rules = new Rules({
173
+ AppName: 'Bat',
174
+ Version: 0,
175
+ RuleSet: { default: { SemVer: '3.2.1-beta.1', AttributeName: '', AttributeValue: '' } },
176
+ });
177
+ await rules.Save(dbManager);
178
+
179
+ // Call the handler
180
+ const response = await GetRoute({ dbManager, rawPath: '/bat/notVersion' });
181
+
182
+ expect(response).toHaveProperty('statusCode');
183
+ expect(response.statusCode).toBe(200);
184
+ expect(response).toBeDefined();
185
+ expect(response.iFrameAppVersionPath).toBe('/bat/3.2.1-beta.1/bat.html');
186
+ });
187
+
188
+ it('should serve appframe with no default file', async () => {
189
+ const app = new Application({
190
+ AppName: 'Bat',
191
+ DisplayName: 'Bat App',
192
+ });
193
+ await app.Save(dbManager);
194
+
195
+ const version = new Version({
196
+ AppName: 'Bat',
197
+ DefaultFile: '',
198
+ IntegrationID: 'abcd',
199
+ SemVer: '3.2.1-beta1',
200
+ Status: 'deployed',
201
+ Type: 'lambda',
202
+ });
203
+ await version.Save(dbManager);
204
+
205
+ const rules = new Rules({
206
+ AppName: 'Bat',
207
+ Version: 0,
208
+ RuleSet: { default: { SemVer: '3.2.1-beta1', AttributeName: '', AttributeValue: '' } },
209
+ });
210
+ await rules.Save(dbManager);
211
+
212
+ // Call the handler
213
+ const response = await GetRoute({ dbManager, rawPath: '/bat/' });
214
+
215
+ expect(response).toBeDefined();
216
+ expect(response).toHaveProperty('statusCode');
217
+ expect(response.statusCode).toBe(200);
218
+ expect(response.iFrameAppVersionPath).toBe('/bat/3.2.1-beta1');
219
+ });
220
+
221
+ it('should serve appframe with sub-route', async () => {
222
+ const app = new Application({
223
+ AppName: 'Bat',
224
+ DisplayName: 'Bat App',
225
+ });
226
+ await app.Save(dbManager);
227
+
228
+ const version = new Version({
229
+ AppName: 'Bat',
230
+ DefaultFile: '',
231
+ IntegrationID: 'abcd',
232
+ SemVer: '3.2.1-beta2',
233
+ Status: 'deployed',
234
+ Type: 'lambda',
235
+ });
236
+ await version.Save(dbManager);
237
+
238
+ const rules = new Rules({
239
+ AppName: 'Bat',
240
+ Version: 0,
241
+ RuleSet: { default: { SemVer: '3.2.1-beta2', AttributeName: '', AttributeValue: '' } },
242
+ });
243
+ await rules.Save(dbManager);
244
+
245
+ // Call the handler
246
+ const response = await GetRoute({ dbManager, rawPath: '/bat/demo/grid' });
247
+
248
+ expect(response).toBeDefined();
249
+ expect(response).toHaveProperty('statusCode');
250
+ expect(response.statusCode).toBe(200);
251
+ expect(response.iFrameAppVersionPath).toBe('/bat/3.2.1-beta2/demo/grid');
252
+ });
253
+
254
+ it('should serve appframe with sub-route', async () => {
255
+ const app = new Application({
256
+ AppName: 'Bat',
257
+ DisplayName: 'Bat App',
258
+ });
259
+ await app.Save(dbManager);
260
+
261
+ const version = new Version({
262
+ AppName: 'Bat',
263
+ DefaultFile: 'someFile.html',
264
+ IntegrationID: 'abcd',
265
+ SemVer: '3.2.1-beta3',
266
+ Status: 'deployed',
267
+ Type: 'lambda',
268
+ });
269
+ await version.Save(dbManager);
270
+
271
+ const rules = new Rules({
272
+ AppName: 'Bat',
273
+ Version: 0,
274
+ RuleSet: { default: { SemVer: '3.2.1-beta3', AttributeName: '', AttributeValue: '' } },
275
+ });
276
+ await rules.Save(dbManager);
277
+
278
+ // Call the handler
279
+ const response = await GetRoute({ dbManager, rawPath: '/bat/demo' });
280
+
281
+ expect(response).toBeDefined();
282
+ expect(response).toHaveProperty('statusCode');
283
+ expect(response.statusCode).toBe(200);
284
+ expect(response.iFrameAppVersionPath).toBe('/bat/3.2.1-beta3/demo');
285
+ });
286
+
287
+ it('should return 404 for /favicon.ico', async () => {
288
+ const app = new Application({
289
+ AppName: 'Bat',
290
+ DisplayName: 'Bat App',
291
+ });
292
+ await app.Save(dbManager);
293
+
294
+ const version = new Version({
295
+ AppName: 'Bat',
296
+ DefaultFile: 'someFile.html',
297
+ IntegrationID: 'abcd',
298
+ SemVer: '3.2.1-beta3',
299
+ Status: 'deployed',
300
+ Type: 'lambda',
301
+ });
302
+ await version.Save(dbManager);
303
+
304
+ const rules = new Rules({
305
+ AppName: 'Bat',
306
+ Version: 0,
307
+ RuleSet: { default: { SemVer: '3.2.1-beta3', AttributeName: '', AttributeValue: '' } },
308
+ });
309
+ await rules.Save(dbManager);
310
+
311
+ // Call the handler
312
+ const response = await GetRoute({ dbManager, rawPath: '/favicon.ico' });
313
+
314
+ expect(response).toBeDefined();
315
+ expect(response).toHaveProperty('statusCode');
316
+ expect(response.statusCode).toBe(404);
317
+ });
318
+ });
package/src/index.ts ADDED
@@ -0,0 +1,536 @@
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
+ }
package/src/lib/log.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { LambdaLog } from 'lambda-log';
2
+
3
+ export default class Log {
4
+ public static Instance = new LambdaLog({
5
+ silent: process.env.JEST_WORKER_ID !== undefined,
6
+ // debug: process.env.DEBUG ? true : false,
7
+ });
8
+ }