Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move the MRT reference app to the SDKs, so that we can verify eg. Node support #966

Merged
merged 16 commits into from
Mar 6, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/pwa-kit-create-app/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ const main = () => {
const pkgNames = [
'template-retail-react-app',
'template-express-minimal',
'template-typescript-minimal'
'template-typescript-minimal',
'template-mrt-reference-app'
]

if (!sh.test('-d', templatesDir)) {
Expand Down
10 changes: 9 additions & 1 deletion packages/pwa-kit-create-app/scripts/create-mobify-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ const EXPRESS_MINIMAL = 'express-minimal'
const TEST_PROJECT = 'test-project' // TODO: This will be replaced with the `isomorphic-client` config.
const RETAIL_REACT_APP_DEMO = 'retail-react-app-demo'
const RETAIL_REACT_APP = 'retail-react-app'
const MRT_REFERENCE_APP = 'mrt-reference-app'

const PRIVATE_PRESETS = [
TEST_PROJECT,
EXPRESS_MINIMAL_TEST_PROJECT,
TYPESCRIPT_MINIMAL_TEST_PROJECT
TYPESCRIPT_MINIMAL_TEST_PROJECT,
MRT_REFERENCE_APP
]
const PUBLIC_PRESETS = [
RETAIL_REACT_APP_DEMO,
Expand Down Expand Up @@ -464,6 +466,12 @@ const main = (opts) => {
})
case TEST_PROJECT:
return runGenerator(testProjectAnswers(), opts)
case MRT_REFERENCE_APP:
return runTemplateGenerator(
'mrt-reference-app',
opts,
'template-mrt-reference-app'
)
case RETAIL_REACT_APP_DEMO:
return Promise.resolve()
.then(() => runGenerator(demoProjectAnswers(), opts))
Expand Down
7 changes: 7 additions & 0 deletions packages/template-mrt-reference-app/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
build
coverage
docs
app/static
jest.config.js
webpack
scripts/generator/assets
9 changes: 9 additions & 0 deletions packages/template-mrt-reference-app/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright (c) 2021, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
module.exports = {
extends: require.resolve('pwa-kit-dev/configs/eslint/eslint-config')
}
3 changes: 3 additions & 0 deletions packages/template-mrt-reference-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
build
node_modules
build.tar
4 changes: 4 additions & 0 deletions packages/template-mrt-reference-app/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
build
docs
coverage
scripts/generator/assets
7 changes: 7 additions & 0 deletions packages/template-mrt-reference-app/.prettierrc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
printWidth: 100
singleQuote: true
semi: false
bracketSpacing: false
tabWidth: 4
arrowParens: 'always'
trailingComma: 'none'
14 changes: 14 additions & 0 deletions packages/template-mrt-reference-app/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
BSD 3-Clause License

Copyright (c) 2021, Salesforce.com, Inc.
All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

3. Neither the name of Salesforce.com nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25 changes: 25 additions & 0 deletions packages/template-mrt-reference-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# template-mrt-reference-app

This is the reference app that the Managed Runtime Team uses to test
features in the platform (eg. TLS versions, successful deploys, proxy
behaviour).

This app is intended to be a thin layer over the bare minimum SDKs
that we expect/require all MRT users to use.

Although MRT started life primarily as a hosting environment for
React apps, we're expanding that to support other technologies –
this app lets us test those platform features that are universal
across all apps, regardless of framework choice.


## Usage in CI/CD tests ⛅️

This app is deployed to several pre-existing test Targets as part
of a "smoke-test" of the MRT platform. To see the Targets in use
take a look at the CI config in

https://git.soma.salesforce.com/cc-mobify/ssr-infrastructure/blob/sfci-main/Jenkinsfile#L176

These smoke-tests are triggered by merges to the main development
branch of the above repository.
35 changes: 35 additions & 0 deletions packages/template-mrt-reference-app/app/request-processor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2023, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {QueryParameters} from 'pwa-kit-runtime/utils/ssr-request-processing'

const exclusions = ['removeme']

export const processRequest = ({path, querystring, parameters}) => {
console.assert(parameters, 'Missing parameters')

// Query string filtering

// Build a first QueryParameters object from the given querystring
const queryParameters = new QueryParameters(querystring)

// Build a second QueryParameters from the first, with all
// excluded parameters removed
const filtered = QueryParameters.from(
queryParameters.parameters.filter(
// parameter.key is always lower-case
(parameter) => !exclusions.includes(parameter.key)
)
)

// Re-generate the querystring
querystring = filtered.toString()

return {
path,
querystring
}
}
246 changes: 246 additions & 0 deletions packages/template-mrt-reference-app/app/ssr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/*
* Copyright (c) 2022, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

/**
* Notes on this test app 🧠
*
* HTTP requests to **all** paths except those listed below will return
* a JSON response containing useful diagnostic values from the request
* and the context in which the request is handled. Values are **whitelisted**,
* so if you want to view a new header or environment variable you will need
* to add it to the appropriate whitelist. Do **NOT** expose any values that
* contain potentially sensitive information (such as API keys or AWS
* credentials), especially from the environment. The deployed server is
* globally accessible.
*
* - `/exception`: Throws a custom error whose textual representation (visible in the HTTP response) is the same diagnostic information described above.
* - `/cache`: Returns the same diagnostic data, but will store it (as text) in an S3 object in the application cache, then retrieve it and return it. This tests access to the application cache.
* - `/auth/<anything>`: Requires HTTP basic authentication with the username `mobify` and the password `supersecret`
* - `/auth/logout`: Returns a 401 response that will remove any existing authentication data for a target
*
* The app will normally use the 'context.succeed' callback to return a
* response to the Lambda integration code. If the query parameter `directcallback`
* is set to any non-empty value, it will use the callback passed to the Lambda
* entry point instead. This allows testing of different SDK or code methods
* of generating responses.
*
* A `Cache-Control: no-cache` header is added to **all** responses, so CloudFront
* will never cache any of the responses from this test server. You therefore
* don't need to add cachebreakers when running tests.
*
* The server has a proxy configured to [HTTPBin](https://httpbin.org/). To send
* a test request to it, use the path `/mobify/proxy/httpbin/` - for example,
* `/mobify/proxy/httpbin/get`
*
* A test bundle file is available at `/mobify/bundle/<BUNDLE_NUMBER>/assets/mobify.png`
* where BUNDLE_NUMBER is the most recently published bundle number.
*/

const path = require('path')
const {getRuntime} = require('pwa-kit-runtime/ssr/server/express')
const pkg = require('../package.json')
const basicAuth = require('express-basic-auth')
const fetch = require('cross-fetch')

/**
* Custom error class
*/
class IntentionalError extends Error {
constructor(diagnostics, ...params) {
super(...params)
this.message = JSON.stringify(diagnostics, null, 2)
this.name = 'IntentionalError'
}
}

const ENVS_TO_EXPOSE = [
'aws_execution_env',
'aws_lambda_function_memory_size',
'aws_lambda_function_name',
'aws_lambda_function_version',
'aws_lambda_log_group_name',
'aws_lambda_log_stream_name',
'aws_region',
'bundle_id',
'deploy_id',
'deploy_target',
'external_domain_name',
'mobify_property_id',
'node_env',
'tz'
]

const HEADERS_TO_REDACT = ['x-api-key', 'x-apigateway-context', 'x-apigateway-event']

const BADSSL_TLS1_1_URL = 'https://tls-v1-1.badssl.com:1011/'
const BADSSL_TLS1_2_URL = 'https://tls-v1-2.badssl.com:1012/'

const redactAndSortObjectKeys = (o, redactList = HEADERS_TO_REDACT) => {
const redact = (k) => ({[k]: redactList.includes(k) ? '*****' : o[k]})
return Object.assign({}, ...Object.keys(o).sort().map(redact))
}

/**
* Shallow-clone the given object such that the only keys on the
* clone are those in the given whitelist, and so that the keys are
* in alphanumeric sort order.
* @param o the object to clone
* @param whitelist an Array of strings for keys that should be included.
* If a string ends in a '*', the key may contain zero or more characters
* matched by the '*' (i.e., it must start with the whitelist string up to
* but not including the '*')
* @return {{}}
*/
const filterAndSortObjectKeys = (o, whitelist) =>
o &&
Object.keys(o)
// Include only whitelisted keys
.filter((key) => {
const keylc = key.toLowerCase().trim()
return whitelist.some(
(pattern) =>
// wildcard matching
(pattern.endsWith('*') && keylc.startsWith(pattern.slice(0, -1))) ||
pattern === keylc // equality matching
)
})
// Sort the remaining keys
.sort()
// Include values
.reduce((acc, key) => {
acc[key] = o[key]
return acc
}, {})

/**
* Return a JSON-serializable object with key diagnostic values from a request
*/
const jsonFromRequest = (req) => {
return {
protocol: req.protocol,
method: req.method,
path: req.path,
query: req.query,
route_path: req.route.path,
body: req.body,
headers: redactAndSortObjectKeys(req.headers),
ip: req.ip,
env: filterAndSortObjectKeys(process.env, ENVS_TO_EXPOSE)
}
}

/**
* Express handler that returns a JSON response with diagnostic values
*/
const echo = (req, res) => res.json(jsonFromRequest(req))

/**
* Express handler that throws an IntentionalError
*/
const exception = (req) => {
// Intentionally throw an exception so that we can check for it
// in logs.
throw new IntentionalError(jsonFromRequest(req))
}

/**
* Express handler that makes 2 requests to badssl TLS testing domains
* to verify that our applications can only make requests to domains with
* updated TLS versions.
*/
const tlsVersionTest = async (_, res) => {
let response11 = await fetch(BADSSL_TLS1_1_URL)
.then((res) => res.ok)
.catch(() => false)
let response12 = await fetch(BADSSL_TLS1_2_URL)
.then((res) => res.ok)
.catch(() => false)
res.header('Content-Type', 'application/json')
res.send(JSON.stringify({'tls1.1': response11, 'tls1.2': response12}, null, 4))
}

/**
* Logging middleware; logs request and response headers (and response status).
*/
const loggingMiddleware = (req, res, next) => {
// Log request headers
console.log(`Request: ${req.method} ${req.originalUrl}`)
console.log(`Request headers: ${JSON.stringify(req.headers, null, 2)}`)
// Arrange to log response status and headers
res.on('finish', () => {
const statusCode = res._header ? String(res.statusCode) : String(-1)
console.log(`Response status: ${statusCode}`)
if (res.headersSent) {
const headers = JSON.stringify(res.getHeaders(), null, 2)
console.log(`Response headers: ${headers}`)
}
})

return next()
}

const options = {
// The build directory (an absolute path)
buildDir: path.resolve(process.cwd(), 'build'),

// The cache time for SSR'd pages (defaults to 600 seconds)
defaultCacheTimeSeconds: 600,

// The port that the local dev server listens on
port: 3000,

// The protocol on which the development Express app listens.
// Note that http://localhost is treated as a secure context for development.
protocol: 'http',

mobify: pkg.mobify
}

const runtime = getRuntime()

const {handler, app, server} = runtime.createHandler(options, (app) => {
app.get('/favicon.ico', runtime.serveStaticFile('static/favicon.ico'))

// Add middleware to explicitly suppress caching on all responses (done
// before we invoke the handlers)
app.use((req, res, next) => {
res.set('Cache-Control', 'no-cache')
return next()
})

// Add middleware to log request and response headers
app.use(loggingMiddleware)

// Configure routes
app.all('/exception', exception)
app.get('/tls', tlsVersionTest)

// Add a /auth/logout path that will always send a 401 (to allow clearing
// of browser credentials)
app.all('/auth/logout', (req, res) => res.status(401).send('Logged out'))
// Add auth middleware to the /auth paths only
app.use(
'/auth*',
basicAuth({
users: {mobify: 'supersecret'},
challenge: true,
// Use a realm that is different per target
realm: process.env.EXTERNAL_DOMAIN_NAME || 'echo-test'
})
)
app.all('/auth*', echo)
// All other paths/routes invoke echo directly
app.all('/*', echo)
app.set('json spaces', 4)
})

// SSR requires that we export a single handler function called 'get', that
// supports AWS use of the server that we created above.
exports.get = handler
exports.server = server

exports.app = app
Loading