From acb295350f1399aa8dcadbc50e476b5ecc8100d9 Mon Sep 17 00:00:00 2001 From: illiakovalenko Date: Thu, 22 Aug 2024 10:58:17 +0300 Subject: [PATCH 01/10] Initial changes --- .../src/templates/node-xmcloud-proxy/.env | 5 + .../templates/node-xmcloud-proxy/package.json | 1 + .../templates/node-xmcloud-proxy/src/index.ts | 18 +- packages/sitecore-jss-proxy/package.json | 2 + packages/sitecore-jss-proxy/src/index.ts | 609 +----------------- .../src/middleware/editing/config.ts | 35 + .../src/middleware/editing/index.ts | 88 +++ .../headless-ssr-proxy}/AppRenderer.ts | 0 .../headless-ssr-proxy}/ProxyConfig.ts | 0 .../headless-ssr-proxy}/RenderResponse.ts | 0 .../headless-ssr-proxy}/RouteUrlParser.ts | 0 .../headless-ssr-proxy}/index.test.ts | 0 .../middleware/headless-ssr-proxy/index.ts | 608 +++++++++++++++++ .../src/middleware/index.ts | 2 + .../src/middleware/utils.ts | 5 + 15 files changed, 764 insertions(+), 609 deletions(-) create mode 100644 packages/sitecore-jss-proxy/src/middleware/editing/config.ts create mode 100644 packages/sitecore-jss-proxy/src/middleware/editing/index.ts rename packages/sitecore-jss-proxy/src/{ => middleware/headless-ssr-proxy}/AppRenderer.ts (100%) rename packages/sitecore-jss-proxy/src/{ => middleware/headless-ssr-proxy}/ProxyConfig.ts (100%) rename packages/sitecore-jss-proxy/src/{ => middleware/headless-ssr-proxy}/RenderResponse.ts (100%) rename packages/sitecore-jss-proxy/src/{ => middleware/headless-ssr-proxy}/RouteUrlParser.ts (100%) rename packages/sitecore-jss-proxy/src/{ => middleware/headless-ssr-proxy}/index.test.ts (100%) create mode 100644 packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.ts create mode 100644 packages/sitecore-jss-proxy/src/middleware/index.ts create mode 100644 packages/sitecore-jss-proxy/src/middleware/utils.ts diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env index 519a159f39..da70361764 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env @@ -1,3 +1,8 @@ +# To secure the Sitecore editor endpoint exposed by your proxy app +# (`/api/editing/render` by default), a secret token is used. +# We recommend an alphanumeric value of at least 16 characters. +JSS_EDITING_SECRET= + # Your proxy port (default: 3001) PROXY_PORT= diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/package.json b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/package.json index 548b1ee39a..b05cf0f709 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/package.json +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/package.json @@ -10,6 +10,7 @@ "start": "ts-node ./src/index.ts" }, "dependencies": { + "@sitecore-jss/sitecore-jss-proxy": "~22.2.0-canary", "compression": "^1.7.4", "express": "^4.18.2", "dotenv": "^16.0.3", diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts index ca9919c43b..795a4acc57 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts @@ -3,6 +3,7 @@ import express, { Response } from 'express'; import compression from 'compression'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { debug } from '@sitecore-jss/sitecore-jss'; +import { editingRouter } from '@sitecore-jss/sitecore-jss-proxy'; import { config } from './config'; const server = express(); @@ -46,7 +47,7 @@ const graphQLEndpoint = (() => { try { const graphQLEndpoint = new URL(clientFactoryConfig.endpoint); - // Browser request path to the proxy. Includes only the pathname. + // Browser request path to the proxy. Includes only the pathname. const pathname = graphQLEndpoint.pathname; // Target URL for the proxy. Can't include the query string. const target = `${graphQLEndpoint.protocol}//${graphQLEndpoint.hostname}${pathname}`; @@ -125,6 +126,21 @@ server.use( }) ); +server.use( + '/api/editing', + editingRouter({ + config: { + components: ['ContentBlock', 'Foo'], + metadata: { + packages: { + '@sitecore-jss/sitecore-jss-react': 'latest', + }, + }, + path: '/test', + }, + }) +); + server.use(async (req, res) => { debug.proxy(`performing SSR for ${req.originalUrl}`); diff --git a/packages/sitecore-jss-proxy/package.json b/packages/sitecore-jss-proxy/package.json index 0a10d063e8..e67a04ed1f 100644 --- a/packages/sitecore-jss-proxy/package.json +++ b/packages/sitecore-jss-proxy/package.json @@ -27,6 +27,8 @@ "url": "https://github.com/sitecore/jss/issues" }, "dependencies": { + "@sitecore-jss/sitecore-jss": "22.2.0-canary.26", + "express": "^4.19.2", "http-proxy-middleware": "^2.0.6", "http-status-codes": "^2.2.0", "set-cookie-parser": "^2.5.1" diff --git a/packages/sitecore-jss-proxy/src/index.ts b/packages/sitecore-jss-proxy/src/index.ts index 75637ace02..2d3c0f4efc 100644 --- a/packages/sitecore-jss-proxy/src/index.ts +++ b/packages/sitecore-jss-proxy/src/index.ts @@ -1,608 +1 @@ -import { IncomingMessage, ServerResponse, ClientRequest, IncomingHttpHeaders } from 'http'; -import { Request, RequestHandler, Response } from 'express'; -import { ServerOptions } from 'http-proxy'; -import { createProxyMiddleware, Options } from 'http-proxy-middleware'; -import HttpStatus from 'http-status-codes'; -import setCookieParser, { Cookie } from 'set-cookie-parser'; -import zlib from 'zlib'; // node.js standard lib -import { AppRenderer } from './AppRenderer'; -import { ProxyConfig, LayoutServiceData, ServerBundle } from './ProxyConfig'; -import { RenderResponse } from './RenderResponse'; -import { RouteUrlParser } from './RouteUrlParser'; -import { buildQueryString, tryParseJson } from './util'; - -interface ExtendedRequest extends Request { - // Custom property we set to keep an original method when we swap 'HEAD' with 'GET' - originalMethod?: string; -} - -// For some reason, every other response returned by Sitecore contains the 'set-cookie' header with the SC_ANALYTICS_GLOBAL_COOKIE value as an empty string. -// This effectively sets the cookie to empty on the client as well, so if a user were to close their browser -// after one of these 'empty value' responses, they would not be tracked as a returning visitor after re-opening their browser. -// To address this, we simply parse the response cookies and if the analytics cookie is present but has an empty value, then we -// remove it from the response header. This means the existing cookie in the browser remains intact. -export const removeEmptyAnalyticsCookie = (proxyResponse: IncomingMessage) => { - const cookies = setCookieParser.parse(proxyResponse.headers['set-cookie'] as string[]); - if (cookies) { - const analyticsCookieIndex = cookies.findIndex( - (c: Cookie) => c.name === 'SC_ANALYTICS_GLOBAL_COOKIE' - ); - if (analyticsCookieIndex !== -1) { - const analyticsCookie = cookies[analyticsCookieIndex]; - if (analyticsCookie && analyticsCookie.value === '') { - cookies.splice(analyticsCookieIndex, 1); - /* eslint-disable no-param-reassign */ - proxyResponse.headers['set-cookie'] = (cookies as unknown) as string[]; - /* eslint-enable no-param-reassign */ - } - } - } -}; - -// inspired by: http://stackoverflow.com/a/22487927/9324 -/** - * @param {IncomingMessage} proxyResponse - * @param {IncomingMessage} request - * @param {ServerResponse} serverResponse - * @param {AppRenderer} renderer - * @param {ProxyConfig} config - */ -async function renderAppToResponse( - proxyResponse: IncomingMessage, - request: IncomingMessage, - serverResponse: ServerResponse, - renderer: AppRenderer, - config: ProxyConfig -) { - // monkey-patch FTW? - const originalWriteHead = serverResponse.writeHead; - const originalWrite = serverResponse.write; - const originalEnd = serverResponse.end; - - // these lines are necessary and must happen before we do any writing to the response - // otherwise the headers will have already been sent - delete proxyResponse.headers['content-length']; - proxyResponse.headers['content-type'] = 'text/html; charset=utf-8'; - - // remove IIS server header for security - delete proxyResponse.headers.server; - - if (config.setHeaders) { - config.setHeaders(request, serverResponse, proxyResponse); - } - - const contentEncoding = proxyResponse.headers['content-encoding']; - if ( - contentEncoding && - (contentEncoding.indexOf('gzip') !== -1 || contentEncoding.indexOf('deflate') !== -1) - ) { - delete proxyResponse.headers['content-encoding']; - } - - // we are going to set our own status code if rendering fails - serverResponse.writeHead = () => serverResponse; - - // buffer the response body as it is written for later processing - let buf = Buffer.from(''); - - serverResponse.write = (data, encoding: unknown) => { - if (Buffer.isBuffer(data)) { - buf = Buffer.concat([buf, data]); // append raw buffer - } else { - buf = Buffer.concat([buf, Buffer.from(data, encoding as BufferEncoding)]); // append string with optional character encoding (default utf8) - } - - // sanity check: if the response is huge, bail. - // ...we don't want to let someone bring down the server by filling up all our RAM. - if (buf.length > (config.maxResponseSizeBytes as number)) { - throw new Error('Document too large'); - } - - return true; - }; - - /** - * Extract layout service data from proxy response - */ - async function extractLayoutServiceDataFromProxyResponse() { - if ( - proxyResponse.statusCode === HttpStatus.OK || - proxyResponse.statusCode === HttpStatus.NOT_FOUND - ) { - let responseString: Promise; - - if ( - contentEncoding && - (contentEncoding.indexOf('gzip') !== -1 || contentEncoding.indexOf('deflate') !== -1) - ) { - responseString = new Promise((resolve, reject) => { - if (config.debug) { - console.log('Layout service response is compressed; decompressing.'); - } - - zlib.unzip(buf, (error, result) => { - if (error) { - reject(error); - } - - if (result) { - resolve(result.toString('utf-8')); - } - }); - }); - } else { - responseString = Promise.resolve(buf.toString('utf-8')); - } - - return responseString.then(tryParseJson); - } - - return Promise.resolve(null); - } - - /** - * function replies with HTTP 500 when an error occurs - * @param {Error} error - */ - async function replyWithError(error: Error): Promise { - console.error(error); - - let errorResponse = { - statusCode: proxyResponse.statusCode || HttpStatus.INTERNAL_SERVER_ERROR, - content: proxyResponse.statusMessage || 'Internal Server Error', - headers: {}, - }; - - if (config.onError) { - const onError = await config.onError(error, proxyResponse); - errorResponse = { ...errorResponse, ...onError }; - } - - completeProxyResponse( - Buffer.from(errorResponse.content), - errorResponse.statusCode, - errorResponse.headers - ); - } - - // callback handles the result of server-side rendering - /** - * @param {Error | null} error - * @param {RenderResponse} result - */ - async function handleRenderingResult(error: Error | null, result: RenderResponse | null) { - if (!error && !result) { - return replyWithError(new Error('Render function did not return a result or an error!')); - } - - if (error) { - return replyWithError(error); - } - - if (!result) { - // should not occur, but makes TS happy - return replyWithError(new Error('Render function result did not return a result.')); - } - - if (!result.html) { - return replyWithError( - new Error('Render function result was returned but html property was falsy.') - ); - } - - if (config.transformSSRContent) { - result.html = await config.transformSSRContent(result, request, serverResponse); - } - - // we have to convert back to a buffer so that we can get the *byte count* (rather than character count) of the body - let content = Buffer.from(result.html); - - // setting the content-length header is not absolutely necessary, but is recommended - proxyResponse.headers['content-length'] = content.length.toString(10); - - // if original request was a HEAD, we should not return a response body - if (request.method === 'HEAD') { - if (config.debug) { - console.log('DEBUG: Original request method was HEAD, clearing response body'); - } - content = Buffer.from([]); - } - - if (result.redirect) { - if (!result.status) { - result.status = 302; - } - - proxyResponse.headers.location = result.redirect; - } - - const finalStatusCode = result.status || proxyResponse.statusCode || HttpStatus.OK; - - if (config.debug) { - console.log( - 'DEBUG: FINAL response headers for client', - JSON.stringify(proxyResponse.headers, null, 2) - ); - - console.log('DEBUG: FINAL status code for client', finalStatusCode); - } - - completeProxyResponse(content, finalStatusCode); - } - - /** - * @param {Buffer | null} content - * @param {number} statusCode - * @param {IncomingHttpHeaders} [headers] - */ - function completeProxyResponse( - content: Buffer | null, - statusCode: number, - headers?: IncomingHttpHeaders - ) { - if (!headers) { - headers = proxyResponse.headers; - } - - originalWriteHead.apply(serverResponse, [statusCode, headers]); - - if (content) originalWrite.call(serverResponse, content); - - originalEnd.call(serverResponse); - } - - /** - * @param {object} layoutServiceData - */ - async function createViewBag(layoutServiceData: LayoutServiceData) { - let viewBag = { - statusCode: proxyResponse.statusCode, - dictionary: {}, - }; - - if (config.createViewBag) { - const customViewBag = await config.createViewBag( - request, - serverResponse, - proxyResponse, - layoutServiceData - ); - - viewBag = { ...viewBag, ...customViewBag }; - } - - return viewBag; - } - - // as the response is ending, we parse the current response body which is JSON, then - // render the app using that JSON, but return HTML to the final response. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - serverResponse.end = async () => { - try { - const layoutServiceData = await extractLayoutServiceDataFromProxyResponse(); - const viewBag = await createViewBag(layoutServiceData); - - if (!layoutServiceData) { - throw new Error( - `Received invalid response ${proxyResponse.statusCode} ${proxyResponse.statusMessage}` - ); - } - - return renderer( - handleRenderingResult, - // originalUrl not defined in `http-proxy-middleware` types but it exists - ((request as unknown) as { [key: string]: unknown }).originalUrl as string, - layoutServiceData, - viewBag - ); - } catch (error) { - return replyWithError(error as Error); - } - }; -} - -/** - * @param {IncomingMessage} proxyResponse - * @param {Request} request - * @param {Response} serverResponse - * @param {AppRenderer} renderer - * @param {ProxyConfig} config - */ -function handleProxyResponse( - proxyResponse: IncomingMessage, - request: Request, - serverResponse: Response, - renderer: AppRenderer, - config: ProxyConfig -) { - removeEmptyAnalyticsCookie(proxyResponse); - - if (config.debug) { - console.log('DEBUG: request url', request.url); - console.log('DEBUG: request query', request.query); - console.log('DEBUG: request original url', request.originalUrl); - console.log('DEBUG: proxied request response code', proxyResponse.statusCode); - console.log('DEBUG: RAW request headers', JSON.stringify(request.headers, null, 2)); - console.log( - 'DEBUG: RAW headers from the proxied response', - JSON.stringify(proxyResponse.headers, null, 2) - ); - } - - // if the request URL contains any of the excluded rewrite routes, we assume the response does not need to be server rendered. - // instead, the response should just be relayed as usual. - if (isUrlIgnored(request.originalUrl, config, true)) { - return Promise.resolve(undefined); - } - - // your first thought might be: why do we need to render the app here? why not just pass the JSON response to another piece of middleware that will render the app? - // the answer: the proxy middleware ends the response and does not "chain" - return renderAppToResponse(proxyResponse, request, serverResponse, renderer, config); -} - -/** - * @param {string} reqPath - * @param {Request} req - * @param {ProxyConfig} config - * @param {RouteUrlParser} parseRouteUrl - */ -export function rewriteRequestPath( - reqPath: string, - req: Request, - config: ProxyConfig, - parseRouteUrl?: RouteUrlParser -) { - // the path comes in URL-encoded by default, - // but we don't want that because... - // 1. We need to URL-encode it before we send it out to the Layout Service, if it matches a route - // 2. We don't want to force people to URL-encode ignored routes, etc (just use spaces instead of %20, etc) - const decodedReqPath = decodeURIComponent(reqPath); - - // if the request URL contains a path/route that should not be re-written, then just pass it along as-is - if (isUrlIgnored(reqPath, config)) { - // we do not return the decoded URL because we're using it verbatim - should be encoded. - return reqPath; - } - - // if the request URL doesn't contain the layout service controller path, assume we need to rewrite the request URL so that it does - // if this seems redundant, it is. the config.pathRewriteExcludeRoutes should contain the layout service path, but can't always assume that it will... - if (decodedReqPath.indexOf(config.layoutServiceRoute) !== -1) { - return reqPath; - } - - let finalReqPath = decodedReqPath; - const qsIndex = finalReqPath.indexOf('?'); - let qs = ''; - if (qsIndex > -1 || Object.keys(req.query).length) { - qs = buildQueryString(req.query as { [key: string]: string | number | boolean }); - // Splice qs part when url contains that - if (qsIndex > -1) finalReqPath = finalReqPath.slice(0, qsIndex); - } - - if (config.qsParams) { - if (qs) { - qs += '&'; - } - qs += `${config.qsParams}`; - } - - let lang; - if (parseRouteUrl) { - if (config.debug) { - console.log(`DEBUG: Parsing route URL using ${decodedReqPath} URL...`); - } - const routeParams = parseRouteUrl(finalReqPath); - - if (routeParams) { - if (routeParams.sitecoreRoute) { - finalReqPath = routeParams.sitecoreRoute; - } else { - finalReqPath = '/'; - } - if (!finalReqPath.startsWith('/')) { - finalReqPath = `/${finalReqPath}`; - } - lang = routeParams.lang; - - if (routeParams.qsParams) { - qs += `&${routeParams.qsParams}`; - } - - if (config.debug) { - console.log('DEBUG: parseRouteUrl() result', routeParams); - } - } - } - - let path = `${config.layoutServiceRoute}?item=${encodeURIComponent(finalReqPath)}&sc_apikey=${ - config.apiKey - }`; - - if (lang) { - path = `${path}&sc_lang=${lang}`; - } - - if (qs) { - path = `${path}&${qs}`; - } - - return path; -} - -/** - * @param {string} originalUrl - * @param {ProxyConfig} config - * @param {boolean} noDebug - */ -function isUrlIgnored(originalUrl: string, config: ProxyConfig, noDebug = false): boolean { - if (config.pathRewriteExcludePredicate && config.pathRewriteExcludeRoutes) { - console.error( - 'ERROR: pathRewriteExcludePredicate and pathRewriteExcludeRoutes were both provided in config. Provide only one.' - ); - process.exit(1); - } - - let result = null; - - if (config.pathRewriteExcludeRoutes) { - const matchRoute = decodeURIComponent(originalUrl).toUpperCase(); - result = config.pathRewriteExcludeRoutes.find( - (excludedRoute: string) => excludedRoute.length > 0 && matchRoute.startsWith(excludedRoute) - ); - - if (!noDebug && config.debug) { - if (!result) { - console.log( - `DEBUG: URL ${originalUrl} did not match the proxy exclude list, and will be treated as a layout service route to render. Excludes:`, - config.pathRewriteExcludeRoutes - ); - } else { - console.log( - `DEBUG: URL ${originalUrl} matched the proxy exclude list and will be served verbatim as received. Excludes: `, - config.pathRewriteExcludeRoutes - ); - } - } - - return result ? true : false; - } - - if (config.pathRewriteExcludePredicate) { - result = config.pathRewriteExcludePredicate(originalUrl); - - if (!noDebug && config.debug) { - if (!result) { - console.log( - `DEBUG: URL ${originalUrl} did not match the proxy exclude function, and will be treated as a layout service route to render.` - ); - } else { - console.log( - `DEBUG: URL ${originalUrl} matched the proxy exclude function and will be served verbatim as received.` - ); - } - } - - return result; - } - - return false; -} - -/** - * @param {ClientRequest} proxyReq - * @param {Request} req - * @param {Response} res - * @param {ServerOptions} options - * @param {ProxyConfig} config - * @param {Function} customOnProxyReq - */ -function handleProxyRequest( - proxyReq: ClientRequest, - req: ExtendedRequest, - res: Response, - options: ServerOptions, - config: ProxyConfig, - customOnProxyReq: - | ((proxyReq: ClientRequest, req: Request, res: Response, options: ServerOptions) => void) - | undefined -) { - if (!isUrlIgnored(req.originalUrl, config, true)) { - // In case 'followRedirects' is enabled, and before the proxy was initialized we had set 'originalMethod' - // now we need to set req.method back to original one, since proxyReq is already initialized. - // See more info in 'preProxyHandler' - if (options.followRedirects && req.originalMethod === 'HEAD') { - req.method = req.originalMethod; - delete req.originalMethod; - - if (config.debug) { - console.log('DEBUG: Rewriting HEAD request to GET to create accurate headers'); - } - } else if (proxyReq.method === 'HEAD') { - if (config.debug) { - console.log('DEBUG: Rewriting HEAD request to GET to create accurate headers'); - } - // if a HEAD request, we still need to issue a GET so we can return accurate headers - proxyReq.method = 'GET'; - } - } - - // invoke custom onProxyReq - if (customOnProxyReq) { - customOnProxyReq(proxyReq, req, res, options); - } -} - -/** - * @param {AppRenderer} renderer - * @param {ProxyConfig} config - * @param {RouteUrlParser} parseRouteUrl - */ -function createOptions( - renderer: AppRenderer, - config: ProxyConfig, - parseRouteUrl: RouteUrlParser -): Options { - if (!config.maxResponseSizeBytes) { - config.maxResponseSizeBytes = 10 * 1024 * 1024; - } - - // ensure all excludes are case insensitive - if (config.pathRewriteExcludeRoutes && Array.isArray(config.pathRewriteExcludeRoutes)) { - config.pathRewriteExcludeRoutes = config.pathRewriteExcludeRoutes.map((exclude) => - exclude.toUpperCase() - ); - } - - if (config.debug) { - console.log('DEBUG: Final proxy config', config); - } - - const customOnProxyReq = config.proxyOptions?.onProxyReq; - return { - ...config.proxyOptions, - target: config.apiHost, - changeOrigin: true, // required otherwise need to include CORS headers - ws: config.ws || false, - pathRewrite: (reqPath, req) => rewriteRequestPath(reqPath, req, config, parseRouteUrl), - logLevel: config.debug ? 'debug' : 'info', - onProxyReq: (proxyReq, req, res, options) => - handleProxyRequest(proxyReq, req, res, options, config, customOnProxyReq), - onProxyRes: (proxyRes, req, res) => handleProxyResponse(proxyRes, req, res, renderer, config), - }; -} - -/** - * @param {AppRenderer} renderer - * @param {ProxyConfig} config - * @param {RouteUrlParser} parseRouteUrl - */ -export default function scProxy( - renderer: AppRenderer, - config: ProxyConfig, - parseRouteUrl: RouteUrlParser -) { - const options = createOptions(renderer, config, parseRouteUrl); - - const preProxyHandler: RequestHandler = (req, _res, next) => { - // When 'followRedirects' is enabled, 'onProxyReq' is executed after 'proxyReq' is initialized based on original 'req' - // and there are no public properties/methods to modify Redirectable 'proxyReq'. - // so, we need to set 'HEAD' req as 'GET' before the proxy is initialized. - // During the 'onProxyReq' event we will set 'req.method' back as 'HEAD'. - // if a HEAD request, we need to issue a GET so we can return accurate headers - if ( - req.method === 'HEAD' && - options.followRedirects && - !isUrlIgnored(req.originalUrl, config, true) - ) { - req.method = 'GET'; - (req as ExtendedRequest).originalMethod = 'HEAD'; - } - - next(); - }; - - return [preProxyHandler, createProxyMiddleware(options)]; -} - -export { ProxyConfig, ServerBundle }; +export * from './middleware'; diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/config.ts b/packages/sitecore-jss-proxy/src/middleware/editing/config.ts new file mode 100644 index 0000000000..a1db8dc6a7 --- /dev/null +++ b/packages/sitecore-jss-proxy/src/middleware/editing/config.ts @@ -0,0 +1,35 @@ +import { Request, Response } from 'express'; +import { EditMode } from '@sitecore-jss/sitecore-jss/layout'; +import { Metadata } from '@sitecore-jss/sitecore-jss/utils'; + +export type EditingConfigEndpointOptions = { + /** + * Custom path for the endpoint. Default is `/config` + * @example + * { path: '/foo/config' } -> /foo/config + */ + path?: string; + /** + * Components available in the application + */ + components: string[] | Map; + /** + * Application metadata + */ + metadata: Metadata; +}; + +export const editingConfigMiddleware = (config: EditingConfigEndpointOptions) => async ( + _req: Request, + res: Response +): Promise => { + const components = Array.isArray(config.components) + ? config.components + : Array.from(config.components.keys()); + + return res.status(200).json({ + components, + packages: config.metadata.packages, + editMode: EditMode.Metadata, + }); +}; diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/index.ts b/packages/sitecore-jss-proxy/src/middleware/editing/index.ts new file mode 100644 index 0000000000..4557b539c0 --- /dev/null +++ b/packages/sitecore-jss-proxy/src/middleware/editing/index.ts @@ -0,0 +1,88 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import { debug } from '@sitecore-jss/sitecore-jss'; +import { enforceCors } from '@sitecore-jss/sitecore-jss/utils'; +import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET } from '../utils'; +import { EditingConfigEndpointOptions, editingConfigMiddleware } from './config'; + +export type EditingRouterConfig = { + /** + * Configuration for the /config endpoint + */ + config: EditingConfigEndpointOptions; + /** + * Configuration for the /render endpoint + */ + render?: { + /** + * Custom path for the editing render endpoint + */ + path?: string; + }; +}; + +export const editingMiddleware = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + const providedSecret = req.query[QUERY_PARAM_EDITING_SECRET]; + const secret = process.env.JSS_EDITING_SECRET; + + debug.editing('editing middleware start: %o', { + path: req.path, + method: req.method, + query: req.query, + headers: req.headers, + }); + + if (!enforceCors(req, res, EDITING_ALLOWED_ORIGINS)) { + debug.editing( + 'invalid origin host - set allowed origins in JSS_ALLOWED_ORIGINS environment variable' + ); + return res.status(401).json({ + html: `Requests from origin ${req.headers?.origin} not allowed`, + }); + } + + if (!secret) { + debug.editing('missing editing secret - set JSS_EDITING_SECRET environment variable'); + + return res + .status(401) + .json({ message: 'Missing editing secret - set JSS_EDITING_SECRET environment variable' }); + } + + if (secret !== providedSecret) { + debug.editing('invalid editing secret - sent "%s" expected "%s"', secret, providedSecret); + + return res.status(401).json({ html: 'Missing or invalid secret' }); + } + + return next(); +}; + +const editingNotFoundMiddleware = (req: Request, res: Response) => { + debug.editing('invalid method or path - sent %s %s', req.method, req.originalUrl); + + res.setHeader('Allow', 'GET'); + + return res.status(405).json({ + html: `Invalid request method or path ${req.method} ${req.originalUrl}`, + }); +}; + +export const editingRouter = (options: EditingRouterConfig) => { + const router = Router(); + + router.use(editingMiddleware); + + router.get(options.config.path || '/config', editingConfigMiddleware(options.config)); + router.get(options.render?.path || '/render', () => { + return null; + }); + + // Middleware to handle invalid method/path + router.use(editingNotFoundMiddleware); + + return router; +}; diff --git a/packages/sitecore-jss-proxy/src/AppRenderer.ts b/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/AppRenderer.ts similarity index 100% rename from packages/sitecore-jss-proxy/src/AppRenderer.ts rename to packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/AppRenderer.ts diff --git a/packages/sitecore-jss-proxy/src/ProxyConfig.ts b/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/ProxyConfig.ts similarity index 100% rename from packages/sitecore-jss-proxy/src/ProxyConfig.ts rename to packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/ProxyConfig.ts diff --git a/packages/sitecore-jss-proxy/src/RenderResponse.ts b/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/RenderResponse.ts similarity index 100% rename from packages/sitecore-jss-proxy/src/RenderResponse.ts rename to packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/RenderResponse.ts diff --git a/packages/sitecore-jss-proxy/src/RouteUrlParser.ts b/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/RouteUrlParser.ts similarity index 100% rename from packages/sitecore-jss-proxy/src/RouteUrlParser.ts rename to packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/RouteUrlParser.ts diff --git a/packages/sitecore-jss-proxy/src/index.test.ts b/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.test.ts similarity index 100% rename from packages/sitecore-jss-proxy/src/index.test.ts rename to packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.test.ts diff --git a/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.ts b/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.ts new file mode 100644 index 0000000000..8030dd220d --- /dev/null +++ b/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.ts @@ -0,0 +1,608 @@ +import { IncomingMessage, ServerResponse, ClientRequest, IncomingHttpHeaders } from 'http'; +import { Request, RequestHandler, Response } from 'express'; +import { ServerOptions } from 'http-proxy'; +import { createProxyMiddleware, Options } from 'http-proxy-middleware'; +import HttpStatus from 'http-status-codes'; +import setCookieParser, { Cookie } from 'set-cookie-parser'; +import zlib from 'zlib'; // node.js standard lib +import { AppRenderer } from './AppRenderer'; +import { ProxyConfig, LayoutServiceData, ServerBundle } from './ProxyConfig'; +import { RenderResponse } from './RenderResponse'; +import { RouteUrlParser } from './RouteUrlParser'; +import { buildQueryString, tryParseJson } from '../../util'; + +interface ExtendedRequest extends Request { + // Custom property we set to keep an original method when we swap 'HEAD' with 'GET' + originalMethod?: string; +} + +// For some reason, every other response returned by Sitecore contains the 'set-cookie' header with the SC_ANALYTICS_GLOBAL_COOKIE value as an empty string. +// This effectively sets the cookie to empty on the client as well, so if a user were to close their browser +// after one of these 'empty value' responses, they would not be tracked as a returning visitor after re-opening their browser. +// To address this, we simply parse the response cookies and if the analytics cookie is present but has an empty value, then we +// remove it from the response header. This means the existing cookie in the browser remains intact. +export const removeEmptyAnalyticsCookie = (proxyResponse: IncomingMessage) => { + const cookies = setCookieParser.parse(proxyResponse.headers['set-cookie'] as string[]); + if (cookies) { + const analyticsCookieIndex = cookies.findIndex( + (c: Cookie) => c.name === 'SC_ANALYTICS_GLOBAL_COOKIE' + ); + if (analyticsCookieIndex !== -1) { + const analyticsCookie = cookies[analyticsCookieIndex]; + if (analyticsCookie && analyticsCookie.value === '') { + cookies.splice(analyticsCookieIndex, 1); + /* eslint-disable no-param-reassign */ + proxyResponse.headers['set-cookie'] = (cookies as unknown) as string[]; + /* eslint-enable no-param-reassign */ + } + } + } +}; + +// inspired by: http://stackoverflow.com/a/22487927/9324 +/** + * @param {IncomingMessage} proxyResponse + * @param {IncomingMessage} request + * @param {ServerResponse} serverResponse + * @param {AppRenderer} renderer + * @param {ProxyConfig} config + */ +async function renderAppToResponse( + proxyResponse: IncomingMessage, + request: IncomingMessage, + serverResponse: ServerResponse, + renderer: AppRenderer, + config: ProxyConfig +) { + // monkey-patch FTW? + const originalWriteHead = serverResponse.writeHead; + const originalWrite = serverResponse.write; + const originalEnd = serverResponse.end; + + // these lines are necessary and must happen before we do any writing to the response + // otherwise the headers will have already been sent + delete proxyResponse.headers['content-length']; + proxyResponse.headers['content-type'] = 'text/html; charset=utf-8'; + + // remove IIS server header for security + delete proxyResponse.headers.server; + + if (config.setHeaders) { + config.setHeaders(request, serverResponse, proxyResponse); + } + + const contentEncoding = proxyResponse.headers['content-encoding']; + if ( + contentEncoding && + (contentEncoding.indexOf('gzip') !== -1 || contentEncoding.indexOf('deflate') !== -1) + ) { + delete proxyResponse.headers['content-encoding']; + } + + // we are going to set our own status code if rendering fails + serverResponse.writeHead = () => serverResponse; + + // buffer the response body as it is written for later processing + let buf = Buffer.from(''); + + serverResponse.write = (data, encoding: unknown) => { + if (Buffer.isBuffer(data)) { + buf = Buffer.concat([buf, data]); // append raw buffer + } else { + buf = Buffer.concat([buf, Buffer.from(data, encoding as BufferEncoding)]); // append string with optional character encoding (default utf8) + } + + // sanity check: if the response is huge, bail. + // ...we don't want to let someone bring down the server by filling up all our RAM. + if (buf.length > (config.maxResponseSizeBytes as number)) { + throw new Error('Document too large'); + } + + return true; + }; + + /** + * Extract layout service data from proxy response + */ + async function extractLayoutServiceDataFromProxyResponse() { + if ( + proxyResponse.statusCode === HttpStatus.OK || + proxyResponse.statusCode === HttpStatus.NOT_FOUND + ) { + let responseString: Promise; + + if ( + contentEncoding && + (contentEncoding.indexOf('gzip') !== -1 || contentEncoding.indexOf('deflate') !== -1) + ) { + responseString = new Promise((resolve, reject) => { + if (config.debug) { + console.log('Layout service response is compressed; decompressing.'); + } + + zlib.unzip(buf, (error, result) => { + if (error) { + reject(error); + } + + if (result) { + resolve(result.toString('utf-8')); + } + }); + }); + } else { + responseString = Promise.resolve(buf.toString('utf-8')); + } + + return responseString.then(tryParseJson); + } + + return Promise.resolve(null); + } + + /** + * function replies with HTTP 500 when an error occurs + * @param {Error} error + */ + async function replyWithError(error: Error): Promise { + console.error(error); + + let errorResponse = { + statusCode: proxyResponse.statusCode || HttpStatus.INTERNAL_SERVER_ERROR, + content: proxyResponse.statusMessage || 'Internal Server Error', + headers: {}, + }; + + if (config.onError) { + const onError = await config.onError(error, proxyResponse); + errorResponse = { ...errorResponse, ...onError }; + } + + completeProxyResponse( + Buffer.from(errorResponse.content), + errorResponse.statusCode, + errorResponse.headers + ); + } + + // callback handles the result of server-side rendering + /** + * @param {Error | null} error + * @param {RenderResponse} result + */ + async function handleRenderingResult(error: Error | null, result: RenderResponse | null) { + if (!error && !result) { + return replyWithError(new Error('Render function did not return a result or an error!')); + } + + if (error) { + return replyWithError(error); + } + + if (!result) { + // should not occur, but makes TS happy + return replyWithError(new Error('Render function result did not return a result.')); + } + + if (!result.html) { + return replyWithError( + new Error('Render function result was returned but html property was falsy.') + ); + } + + if (config.transformSSRContent) { + result.html = await config.transformSSRContent(result, request, serverResponse); + } + + // we have to convert back to a buffer so that we can get the *byte count* (rather than character count) of the body + let content = Buffer.from(result.html); + + // setting the content-length header is not absolutely necessary, but is recommended + proxyResponse.headers['content-length'] = content.length.toString(10); + + // if original request was a HEAD, we should not return a response body + if (request.method === 'HEAD') { + if (config.debug) { + console.log('DEBUG: Original request method was HEAD, clearing response body'); + } + content = Buffer.from([]); + } + + if (result.redirect) { + if (!result.status) { + result.status = 302; + } + + proxyResponse.headers.location = result.redirect; + } + + const finalStatusCode = result.status || proxyResponse.statusCode || HttpStatus.OK; + + if (config.debug) { + console.log( + 'DEBUG: FINAL response headers for client', + JSON.stringify(proxyResponse.headers, null, 2) + ); + + console.log('DEBUG: FINAL status code for client', finalStatusCode); + } + + completeProxyResponse(content, finalStatusCode); + } + + /** + * @param {Buffer | null} content + * @param {number} statusCode + * @param {IncomingHttpHeaders} [headers] + */ + function completeProxyResponse( + content: Buffer | null, + statusCode: number, + headers?: IncomingHttpHeaders + ) { + if (!headers) { + headers = proxyResponse.headers; + } + + originalWriteHead.apply(serverResponse, [statusCode, headers]); + + if (content) originalWrite.call(serverResponse, content); + + originalEnd.call(serverResponse); + } + + /** + * @param {object} layoutServiceData + */ + async function createViewBag(layoutServiceData: LayoutServiceData) { + let viewBag = { + statusCode: proxyResponse.statusCode, + dictionary: {}, + }; + + if (config.createViewBag) { + const customViewBag = await config.createViewBag( + request, + serverResponse, + proxyResponse, + layoutServiceData + ); + + viewBag = { ...viewBag, ...customViewBag }; + } + + return viewBag; + } + + // as the response is ending, we parse the current response body which is JSON, then + // render the app using that JSON, but return HTML to the final response. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + serverResponse.end = async () => { + try { + const layoutServiceData = await extractLayoutServiceDataFromProxyResponse(); + const viewBag = await createViewBag(layoutServiceData); + + if (!layoutServiceData) { + throw new Error( + `Received invalid response ${proxyResponse.statusCode} ${proxyResponse.statusMessage}` + ); + } + + return renderer( + handleRenderingResult, + // originalUrl not defined in `http-proxy-middleware` types but it exists + ((request as unknown) as { [key: string]: unknown }).originalUrl as string, + layoutServiceData, + viewBag + ); + } catch (error) { + return replyWithError(error as Error); + } + }; +} + +/** + * @param {IncomingMessage} proxyResponse + * @param {Request} request + * @param {Response} serverResponse + * @param {AppRenderer} renderer + * @param {ProxyConfig} config + */ +function handleProxyResponse( + proxyResponse: IncomingMessage, + request: Request, + serverResponse: Response, + renderer: AppRenderer, + config: ProxyConfig +) { + removeEmptyAnalyticsCookie(proxyResponse); + + if (config.debug) { + console.log('DEBUG: request url', request.url); + console.log('DEBUG: request query', request.query); + console.log('DEBUG: request original url', request.originalUrl); + console.log('DEBUG: proxied request response code', proxyResponse.statusCode); + console.log('DEBUG: RAW request headers', JSON.stringify(request.headers, null, 2)); + console.log( + 'DEBUG: RAW headers from the proxied response', + JSON.stringify(proxyResponse.headers, null, 2) + ); + } + + // if the request URL contains any of the excluded rewrite routes, we assume the response does not need to be server rendered. + // instead, the response should just be relayed as usual. + if (isUrlIgnored(request.originalUrl, config, true)) { + return Promise.resolve(undefined); + } + + // your first thought might be: why do we need to render the app here? why not just pass the JSON response to another piece of middleware that will render the app? + // the answer: the proxy middleware ends the response and does not "chain" + return renderAppToResponse(proxyResponse, request, serverResponse, renderer, config); +} + +/** + * @param {string} reqPath + * @param {Request} req + * @param {ProxyConfig} config + * @param {RouteUrlParser} parseRouteUrl + */ +export function rewriteRequestPath( + reqPath: string, + req: Request, + config: ProxyConfig, + parseRouteUrl?: RouteUrlParser +) { + // the path comes in URL-encoded by default, + // but we don't want that because... + // 1. We need to URL-encode it before we send it out to the Layout Service, if it matches a route + // 2. We don't want to force people to URL-encode ignored routes, etc (just use spaces instead of %20, etc) + const decodedReqPath = decodeURIComponent(reqPath); + + // if the request URL contains a path/route that should not be re-written, then just pass it along as-is + if (isUrlIgnored(reqPath, config)) { + // we do not return the decoded URL because we're using it verbatim - should be encoded. + return reqPath; + } + + // if the request URL doesn't contain the layout service controller path, assume we need to rewrite the request URL so that it does + // if this seems redundant, it is. the config.pathRewriteExcludeRoutes should contain the layout service path, but can't always assume that it will... + if (decodedReqPath.indexOf(config.layoutServiceRoute) !== -1) { + return reqPath; + } + + let finalReqPath = decodedReqPath; + const qsIndex = finalReqPath.indexOf('?'); + let qs = ''; + if (qsIndex > -1 || Object.keys(req.query).length) { + qs = buildQueryString(req.query as { [key: string]: string | number | boolean }); + // Splice qs part when url contains that + if (qsIndex > -1) finalReqPath = finalReqPath.slice(0, qsIndex); + } + + if (config.qsParams) { + if (qs) { + qs += '&'; + } + qs += `${config.qsParams}`; + } + + let lang; + if (parseRouteUrl) { + if (config.debug) { + console.log(`DEBUG: Parsing route URL using ${decodedReqPath} URL...`); + } + const routeParams = parseRouteUrl(finalReqPath); + + if (routeParams) { + if (routeParams.sitecoreRoute) { + finalReqPath = routeParams.sitecoreRoute; + } else { + finalReqPath = '/'; + } + if (!finalReqPath.startsWith('/')) { + finalReqPath = `/${finalReqPath}`; + } + lang = routeParams.lang; + + if (routeParams.qsParams) { + qs += `&${routeParams.qsParams}`; + } + + if (config.debug) { + console.log('DEBUG: parseRouteUrl() result', routeParams); + } + } + } + + let path = `${config.layoutServiceRoute}?item=${encodeURIComponent(finalReqPath)}&sc_apikey=${ + config.apiKey + }`; + + if (lang) { + path = `${path}&sc_lang=${lang}`; + } + + if (qs) { + path = `${path}&${qs}`; + } + + return path; +} + +/** + * @param {string} originalUrl + * @param {ProxyConfig} config + * @param {boolean} noDebug + */ +function isUrlIgnored(originalUrl: string, config: ProxyConfig, noDebug = false): boolean { + if (config.pathRewriteExcludePredicate && config.pathRewriteExcludeRoutes) { + console.error( + 'ERROR: pathRewriteExcludePredicate and pathRewriteExcludeRoutes were both provided in config. Provide only one.' + ); + process.exit(1); + } + + let result = null; + + if (config.pathRewriteExcludeRoutes) { + const matchRoute = decodeURIComponent(originalUrl).toUpperCase(); + result = config.pathRewriteExcludeRoutes.find( + (excludedRoute: string) => excludedRoute.length > 0 && matchRoute.startsWith(excludedRoute) + ); + + if (!noDebug && config.debug) { + if (!result) { + console.log( + `DEBUG: URL ${originalUrl} did not match the proxy exclude list, and will be treated as a layout service route to render. Excludes:`, + config.pathRewriteExcludeRoutes + ); + } else { + console.log( + `DEBUG: URL ${originalUrl} matched the proxy exclude list and will be served verbatim as received. Excludes: `, + config.pathRewriteExcludeRoutes + ); + } + } + + return result ? true : false; + } + + if (config.pathRewriteExcludePredicate) { + result = config.pathRewriteExcludePredicate(originalUrl); + + if (!noDebug && config.debug) { + if (!result) { + console.log( + `DEBUG: URL ${originalUrl} did not match the proxy exclude function, and will be treated as a layout service route to render.` + ); + } else { + console.log( + `DEBUG: URL ${originalUrl} matched the proxy exclude function and will be served verbatim as received.` + ); + } + } + + return result; + } + + return false; +} + +/** + * @param {ClientRequest} proxyReq + * @param {Request} req + * @param {Response} res + * @param {ServerOptions} options + * @param {ProxyConfig} config + * @param {Function} customOnProxyReq + */ +function handleProxyRequest( + proxyReq: ClientRequest, + req: ExtendedRequest, + res: Response, + options: ServerOptions, + config: ProxyConfig, + customOnProxyReq: + | ((proxyReq: ClientRequest, req: Request, res: Response, options: ServerOptions) => void) + | undefined +) { + if (!isUrlIgnored(req.originalUrl, config, true)) { + // In case 'followRedirects' is enabled, and before the proxy was initialized we had set 'originalMethod' + // now we need to set req.method back to original one, since proxyReq is already initialized. + // See more info in 'preProxyHandler' + if (options.followRedirects && req.originalMethod === 'HEAD') { + req.method = req.originalMethod; + delete req.originalMethod; + + if (config.debug) { + console.log('DEBUG: Rewriting HEAD request to GET to create accurate headers'); + } + } else if (proxyReq.method === 'HEAD') { + if (config.debug) { + console.log('DEBUG: Rewriting HEAD request to GET to create accurate headers'); + } + // if a HEAD request, we still need to issue a GET so we can return accurate headers + proxyReq.method = 'GET'; + } + } + + // invoke custom onProxyReq + if (customOnProxyReq) { + customOnProxyReq(proxyReq, req, res, options); + } +} + +/** + * @param {AppRenderer} renderer + * @param {ProxyConfig} config + * @param {RouteUrlParser} parseRouteUrl + */ +function createOptions( + renderer: AppRenderer, + config: ProxyConfig, + parseRouteUrl: RouteUrlParser +): Options { + if (!config.maxResponseSizeBytes) { + config.maxResponseSizeBytes = 10 * 1024 * 1024; + } + + // ensure all excludes are case insensitive + if (config.pathRewriteExcludeRoutes && Array.isArray(config.pathRewriteExcludeRoutes)) { + config.pathRewriteExcludeRoutes = config.pathRewriteExcludeRoutes.map((exclude) => + exclude.toUpperCase() + ); + } + + if (config.debug) { + console.log('DEBUG: Final proxy config', config); + } + + const customOnProxyReq = config.proxyOptions?.onProxyReq; + return { + ...config.proxyOptions, + target: config.apiHost, + changeOrigin: true, // required otherwise need to include CORS headers + ws: config.ws || false, + pathRewrite: (reqPath, req) => rewriteRequestPath(reqPath, req, config, parseRouteUrl), + logLevel: config.debug ? 'debug' : 'info', + onProxyReq: (proxyReq, req, res, options) => + handleProxyRequest(proxyReq, req, res, options, config, customOnProxyReq), + onProxyRes: (proxyRes, req, res) => handleProxyResponse(proxyRes, req, res, renderer, config), + }; +} + +/** + * @param {AppRenderer} renderer + * @param {ProxyConfig} config + * @param {RouteUrlParser} parseRouteUrl + */ +export default function scProxy( + renderer: AppRenderer, + config: ProxyConfig, + parseRouteUrl: RouteUrlParser +) { + const options = createOptions(renderer, config, parseRouteUrl); + + const preProxyHandler: RequestHandler = (req, _res, next) => { + // When 'followRedirects' is enabled, 'onProxyReq' is executed after 'proxyReq' is initialized based on original 'req' + // and there are no public properties/methods to modify Redirectable 'proxyReq'. + // so, we need to set 'HEAD' req as 'GET' before the proxy is initialized. + // During the 'onProxyReq' event we will set 'req.method' back as 'HEAD'. + // if a HEAD request, we need to issue a GET so we can return accurate headers + if ( + req.method === 'HEAD' && + options.followRedirects && + !isUrlIgnored(req.originalUrl, config, true) + ) { + req.method = 'GET'; + (req as ExtendedRequest).originalMethod = 'HEAD'; + } + + next(); + }; + + return [preProxyHandler, createProxyMiddleware(options)]; +} + +export { ProxyConfig, ServerBundle }; diff --git a/packages/sitecore-jss-proxy/src/middleware/index.ts b/packages/sitecore-jss-proxy/src/middleware/index.ts new file mode 100644 index 0000000000..3a64648d1e --- /dev/null +++ b/packages/sitecore-jss-proxy/src/middleware/index.ts @@ -0,0 +1,2 @@ +export { default as scProxy, ProxyConfig, ServerBundle } from './headless-ssr-proxy'; +export { editingRouter } from './editing'; diff --git a/packages/sitecore-jss-proxy/src/middleware/utils.ts b/packages/sitecore-jss-proxy/src/middleware/utils.ts new file mode 100644 index 0000000000..168ea980af --- /dev/null +++ b/packages/sitecore-jss-proxy/src/middleware/utils.ts @@ -0,0 +1,5 @@ +export const QUERY_PARAM_EDITING_SECRET = 'secret'; +/** + * Default allowed origins for editing requests. This is used to enforce CORS, CSP headers. + */ +export const EDITING_ALLOWED_ORIGINS = ['https://pages.sitecorecloud.io']; From 8f35003e5d511ef3f2011c993e58d2c0fce54cc5 Mon Sep 17 00:00:00 2001 From: illiakovalenko Date: Thu, 22 Aug 2024 17:48:01 +0300 Subject: [PATCH 02/10] Update --- .../angular-xmcloud/scripts/bootstrap.ts | 28 +++++++++++++++++++ .../scripts/generate-metadata.ts | 25 +++++++++++++++++ .../angular-xmcloud/server.exports.ts | 28 +++++++++++++++++++ .../scripts/generate-component-factory.ts | 8 ++++++ .../src/templates/angular/server.bundle.ts | 25 ++--------------- .../src/templates/angular/server.exports.ts | 13 +++++++++ .../templates/node-xmcloud-proxy/src/index.ts | 11 +++----- .../templates/node-xmcloud-proxy/src/types.ts | 3 ++ packages/sitecore-jss-proxy/package.json | 8 +++++- .../src/middleware/editing/config.ts | 2 +- .../src/middleware/editing/index.ts | 2 +- .../headless-ssr-proxy/index.test.ts | 2 +- .../src/test/config.test.ts | 2 +- 13 files changed, 123 insertions(+), 34 deletions(-) create mode 100644 packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/bootstrap.ts create mode 100644 packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-metadata.ts create mode 100644 packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts create mode 100644 packages/create-sitecore-jss/src/templates/angular/server.exports.ts diff --git a/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/bootstrap.ts b/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/bootstrap.ts new file mode 100644 index 0000000000..41f69fe242 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/bootstrap.ts @@ -0,0 +1,28 @@ +import 'dotenv/config'; + +/* + BOOTSTRAPPING + The bootstrap process runs before build, and generates TS that needs to be + included into the build - specifically, the component name to component mapping, + and the global config module. +*/ + +/* + PLUGINS GENERATION +*/ +require('./generate-plugins'); + +/* + CONFIG GENERATION +*/ +require('./generate-config'); + +/* + COMPONENT FACTORY GENERATION +*/ +require('./generate-component-factory'); + +/* + METADATA GENERATION +*/ +require('./generate-metadata'); diff --git a/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-metadata.ts b/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-metadata.ts new file mode 100644 index 0000000000..eb1bfb9747 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-metadata.ts @@ -0,0 +1,25 @@ +import fs from 'fs'; +import path from 'path'; +import { Metadata, getMetadata } from '@sitecore-jss/sitecore-jss-dev-tools'; + +/* + METADATA GENERATION + Generates the /src/environments/metadata.json file which contains application + configuration metadata that is used for Sitecore XM Cloud integration. +*/ +generateMetadata(); + +function generateMetadata(): void { + const metadata: Metadata = getMetadata(); + writeMetadata(metadata); +} + +/** + * Writes the metadata object to disk. + * @param {Metadata} metadata metadata to write. + */ +function writeMetadata(metadata: Metadata): void { + const filePath = path.resolve('src/environments/metadata.json'); + console.log(`Writing metadata to ${filePath}`); + fs.writeFileSync(filePath, JSON.stringify(metadata, null, 2), { encoding: 'utf8' }); +} diff --git a/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts b/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts new file mode 100644 index 0000000000..2dcc766c2e --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts @@ -0,0 +1,28 @@ +import clientFactory from './src/app/lib/graphql-client-factory'; +import { getGraphQLClientFactoryConfig } from './src/app/lib/graphql-client-factory/config'; +import { dictionaryServiceFactory } from './src/app/lib/dictionary-service-factory'; +import { layoutServiceFactory } from './src/app/lib/layout-service-factory'; +import { environment } from './src/environments/environment'; +import { components } from './src/app/components/app-components.module'; +import metadata from './src/environments/metadata.json'; + +/** + * Define the required configuration values to be exported from the server.bundle.ts. + */ + +const apiKey = environment.sitecoreApiKey; +const siteName = environment.sitecoreSiteName; +const defaultLanguage = environment.defaultLanguage; +const getClientFactoryConfig = getGraphQLClientFactoryConfig; + +export { + apiKey, + siteName, + clientFactory, + getClientFactoryConfig, + dictionaryServiceFactory, + layoutServiceFactory, + defaultLanguage, + components, + metadata, +}; diff --git a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory.ts b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory.ts index 8f797504d1..e95617e52c 100644 --- a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory.ts +++ b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory.ts @@ -79,12 +79,14 @@ function generateComponentFactory() { const registrations: string[] = []; const lazyRegistrations: string[] = []; const declarations: string[] = []; + const components: string[] = []; packages.forEach((p) => { const variables = p.components .map((c) => { registrations.push(`{ name: '${c.componentName}', type: ${c.moduleName} },`); declarations.push(`${c.moduleName},`); + components.push(c.componentName); return c.moduleName; }) @@ -118,6 +120,8 @@ function generateComponentFactory() { const componentName = componentClassMatch[1]; const importVarName = `${componentName}Component`; + components.push(componentName); + // check for lazy loading needs const moduleFilePath = path.join(componentRootPath, componentFolder, `${componentFolder}.module.ts`); const isLazyLoaded = fs.existsSync(moduleFilePath); @@ -144,6 +148,10 @@ import { JssModule } from '@sitecore-jss/sitecore-jss-angular'; import { AppComponentsSharedModule } from './app-components.shared.module'; ${imports.join('\n')} +export const components = [ + ${components.map((c) => `'${c}'`).join(',\n ')} +]; + @NgModule({ imports: [ AppComponentsSharedModule, diff --git a/packages/create-sitecore-jss/src/templates/angular/server.bundle.ts b/packages/create-sitecore-jss/src/templates/angular/server.bundle.ts index 7e51507217..0d04d3bea6 100644 --- a/packages/create-sitecore-jss/src/templates/angular/server.bundle.ts +++ b/packages/create-sitecore-jss/src/templates/angular/server.bundle.ts @@ -4,15 +4,12 @@ import { join } from 'path'; import 'reflect-metadata'; import 'zone.js'; import { JssRouteBuilderService } from './src/app/routing/jss-route-builder.service'; -import { environment } from './src/environments/environment'; import { AppServerModule, renderModule } from './src/main.server'; -import clientFactory from './src/app/lib/graphql-client-factory'; -import { getGraphQLClientFactoryConfig } from './src/app/lib/graphql-client-factory/config'; -import { dictionaryServiceFactory } from './src/app/lib/dictionary-service-factory'; -import { layoutServiceFactory } from './src/app/lib/layout-service-factory'; export * from './src/main.server'; +export * from './server.exports'; + const http = require('http'); const https = require('https'); @@ -101,20 +98,4 @@ function parseRouteUrl(url: string) { }; } -const apiKey = environment.sitecoreApiKey; -const siteName = environment.sitecoreSiteName; -const defaultLanguage = environment.defaultLanguage; -const getClientFactoryConfig = getGraphQLClientFactoryConfig; - -export { - renderView, - parseRouteUrl, - setUpDefaultAgents, - apiKey, - siteName, - clientFactory, - getClientFactoryConfig, - dictionaryServiceFactory, - layoutServiceFactory, - defaultLanguage, -}; +export { renderView, parseRouteUrl, setUpDefaultAgents }; diff --git a/packages/create-sitecore-jss/src/templates/angular/server.exports.ts b/packages/create-sitecore-jss/src/templates/angular/server.exports.ts new file mode 100644 index 0000000000..1389ce87fd --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular/server.exports.ts @@ -0,0 +1,13 @@ +import { environment } from './src/environments/environment'; + +/** + * Define the required configuration values to be exported from the server.bundle.ts. + */ + +const apiKey = environment.sitecoreApiKey; +const siteName = environment.sitecoreSiteName; + +export { + apiKey, + siteName, +}; diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts index 795a4acc57..53ef268b79 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts @@ -27,6 +27,8 @@ const requiredProperties = [ 'defaultLanguage', 'layoutServiceFactory', 'dictionaryServiceFactory', + 'components', + 'metadata', ]; const missingProperties = requiredProperties.filter((property) => !config.serverBundle[property]); @@ -130,13 +132,8 @@ server.use( '/api/editing', editingRouter({ config: { - components: ['ContentBlock', 'Foo'], - metadata: { - packages: { - '@sitecore-jss/sitecore-jss-react': 'latest', - }, - }, - path: '/test', + components: config.serverBundle.components, + metadata: config.serverBundle.metadata, }, }) ); diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts index f0956eaed2..0c68595019 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts @@ -3,6 +3,7 @@ import { GraphQLRequestClientFactoryConfig, } from '@sitecore-jss/sitecore-jss'; import { DictionaryService } from '@sitecore-jss/sitecore-jss/i18n'; +import { Metadata } from '@sitecore-jss/sitecore-jss/utils'; import { LayoutService, LayoutServiceData } from '@sitecore-jss/sitecore-jss/layout'; interface ServerResponse { @@ -52,6 +53,8 @@ export interface ServerBundle { defaultLanguage: string; layoutServiceFactory: { create: () => LayoutService }; dictionaryServiceFactory: { create: () => DictionaryService }; + components: string[]; + metadata: Metadata; } export interface Config { diff --git a/packages/sitecore-jss-proxy/package.json b/packages/sitecore-jss-proxy/package.json index e67a04ed1f..3ebf965a47 100644 --- a/packages/sitecore-jss-proxy/package.json +++ b/packages/sitecore-jss-proxy/package.json @@ -28,7 +28,6 @@ }, "dependencies": { "@sitecore-jss/sitecore-jss": "22.2.0-canary.26", - "express": "^4.19.2", "http-proxy-middleware": "^2.0.6", "http-status-codes": "^2.2.0", "set-cookie-parser": "^2.5.1" @@ -39,14 +38,21 @@ "@types/mocha": "^10.0.1", "@types/node": "^20.14.2", "@types/set-cookie-parser": "^2.4.2", + "@types/sinon-chai": "^3.2.12", "chai": "^4.3.7", "del-cli": "^5.0.0", "eslint": "^8.33.0", + "express": "^4.19.2", "mocha": "^10.2.0", "nyc": "^15.1.0", + "sinon": "^17.0.1", + "sinon-chai": "^3.7.0", "ts-node": "^10.9.1", "typescript": "~4.9.5" }, + "peerDependencies": { + "express": "^4.19.2" + }, "types": "types/index.d.ts", "gitHead": "2f4820efddf4454eeee58ed1b2cc251969efdf5b", "files": [ diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/config.ts b/packages/sitecore-jss-proxy/src/middleware/editing/config.ts index a1db8dc6a7..ff9b534994 100644 --- a/packages/sitecore-jss-proxy/src/middleware/editing/config.ts +++ b/packages/sitecore-jss-proxy/src/middleware/editing/config.ts @@ -10,7 +10,7 @@ export type EditingConfigEndpointOptions = { */ path?: string; /** - * Components available in the application + * Components registered in the application */ components: string[] | Map; /** diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/index.ts b/packages/sitecore-jss-proxy/src/middleware/editing/index.ts index 4557b539c0..a68667b64c 100644 --- a/packages/sitecore-jss-proxy/src/middleware/editing/index.ts +++ b/packages/sitecore-jss-proxy/src/middleware/editing/index.ts @@ -29,7 +29,7 @@ export const editingMiddleware = async ( const secret = process.env.JSS_EDITING_SECRET; debug.editing('editing middleware start: %o', { - path: req.path, + path: req.originalUrl, method: req.method, query: req.query, headers: req.headers, diff --git a/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.test.ts b/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.test.ts index 0b8d180e8a..f773663c93 100644 --- a/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.test.ts +++ b/packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/index.test.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { IncomingMessage } from 'http'; import { Request } from 'express'; import { removeEmptyAnalyticsCookie, rewriteRequestPath } from './'; -import config from './test/config.test'; +import config from '../../test/config.test'; describe('removeEmptyAnalyticsCookie', () => { it('should remove empty analytics cookie from response headers', () => { diff --git a/packages/sitecore-jss-proxy/src/test/config.test.ts b/packages/sitecore-jss-proxy/src/test/config.test.ts index ba2faff79e..bc5df88bd2 100644 --- a/packages/sitecore-jss-proxy/src/test/config.test.ts +++ b/packages/sitecore-jss-proxy/src/test/config.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { ProxyConfig } from '../ProxyConfig'; +import { ProxyConfig } from '../middleware/headless-ssr-proxy/ProxyConfig'; const config: ProxyConfig = { apiHost: 'http://jssadvancedapp', From 6becaae01cbd992c976002879b87c8e181abd65b Mon Sep 17 00:00:00 2001 From: illiakovalenko Date: Thu, 22 Aug 2024 19:50:34 +0300 Subject: [PATCH 03/10] Added unit tests chunk --- .../templates/node-xmcloud-proxy/src/types.ts | 2 +- packages/sitecore-jss-proxy/package.json | 2 + .../src/middleware/editing/index.test.ts | 185 ++++++++++++++++++ .../src/middleware/editing/index.ts | 7 +- 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 packages/sitecore-jss-proxy/src/middleware/editing/index.test.ts diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts index 0c68595019..1adb39d418 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts @@ -53,7 +53,7 @@ export interface ServerBundle { defaultLanguage: string; layoutServiceFactory: { create: () => LayoutService }; dictionaryServiceFactory: { create: () => DictionaryService }; - components: string[]; + components: string[] | Map; metadata: Metadata; } diff --git a/packages/sitecore-jss-proxy/package.json b/packages/sitecore-jss-proxy/package.json index 3ebf965a47..f059813ad8 100644 --- a/packages/sitecore-jss-proxy/package.json +++ b/packages/sitecore-jss-proxy/package.json @@ -39,6 +39,7 @@ "@types/node": "^20.14.2", "@types/set-cookie-parser": "^2.4.2", "@types/sinon-chai": "^3.2.12", + "@types/supertest": "^6.0.2", "chai": "^4.3.7", "del-cli": "^5.0.0", "eslint": "^8.33.0", @@ -47,6 +48,7 @@ "nyc": "^15.1.0", "sinon": "^17.0.1", "sinon-chai": "^3.7.0", + "supertest": "^7.0.0", "ts-node": "^10.9.1", "typescript": "~4.9.5" }, diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/index.test.ts b/packages/sitecore-jss-proxy/src/middleware/editing/index.test.ts new file mode 100644 index 0000000000..d1243e7391 --- /dev/null +++ b/packages/sitecore-jss-proxy/src/middleware/editing/index.test.ts @@ -0,0 +1,185 @@ +import { expect, use } from 'chai'; +import sinonChai from 'sinon-chai'; +import express from 'express'; +import request from 'supertest'; +import { editingRouter } from './index'; + +use(sinonChai); + +describe('editingRouter', () => { + const defaultOptions = { + config: { + components: ['component1', 'component2'], + metadata: { + packages: { + foo: '1.0.0', + bar: '2.0.0', + }, + }, + }, + }; + + let app: express.Express; + + beforeEach(() => { + app = express(); + }); + + it('should throw 401 CORS error when requested origin is not allowed', (done) => { + app.use(editingRouter(defaultOptions)); + + request(app) + .get('/config') + .set('origin', 'http://not-allowed.com') + .expect(401) + .end((err, res) => { + if (err) return done(err); + expect(res.body.html).to.equal( + 'Requests from origin http://not-allowed.com not allowed' + ); + done(); + }); + }); + + it('should throw 401 error when editing secret is not set', (done) => { + app.use(editingRouter(defaultOptions)); + + request(app) + .get('/config') + .expect(401) + .end((err, res) => { + if (err) return done(err); + expect(res.body.html).to.equal( + 'Missing editing secret - set JSS_EDITING_SECRET environment variable' + ); + done(); + }); + }); + + it('should throw 401 error when editing secret is incorrect', (done) => { + process.env.JSS_EDITING_SECRET = 'correct'; + + app.use(editingRouter(defaultOptions)); + + request(app) + .get('/config') + .query({ secret: 'incorrect' }) + .expect(401) + .end((err, res) => { + if (err) return done(err); + expect(res.body.html).to.equal('Missing or invalid secret'); + + delete process.env.JSS_EDITING_SECRET; + + done(); + }); + }); + + describe('/config', () => { + it('should return editing config', (done) => { + process.env.JSS_EDITING_SECRET = 'correct'; + + app.use('/api/editing', editingRouter(defaultOptions)); + + request(app) + .get('/api/editing/config') + .query({ secret: 'correct' }) + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(res.body).to.deep.equal({ + components: ['component1', 'component2'], + packages: { + foo: '1.0.0', + bar: '2.0.0', + }, + editMode: 'metadata', + }); + + delete process.env.JSS_EDITING_SECRET; + + done(); + }); + }); + + it('should return editing config when components are a map', (done) => { + process.env.JSS_EDITING_SECRET = 'correct'; + + app.use( + '/api/editing', + editingRouter({ + config: { + components: new Map([ + ['component1', true], + ['component2', true], + ]), + metadata: { + packages: { + foo: '1.0.0', + bar: '2.0.0', + }, + }, + }, + }) + ); + + request(app) + .get('/api/editing/config') + .query({ secret: 'correct' }) + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(res.body).to.deep.equal({ + components: ['component1', 'component2'], + packages: { + foo: '1.0.0', + bar: '2.0.0', + }, + editMode: 'metadata', + }); + + delete process.env.JSS_EDITING_SECRET; + + done(); + }); + }); + + it('should return editing config when custom request path is set', (done) => { + process.env.JSS_EDITING_SECRET = 'correct'; + + app.use( + '/api/editing', + editingRouter({ + config: { + components: ['component1'], + metadata: { + packages: { + foo: '1.0.0', + }, + }, + path: '/foo/config', + }, + }) + ); + + request(app) + .get('/api/editing/foo/config') + .query({ secret: 'correct' }) + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(res.body).to.deep.equal({ + components: ['component1'], + packages: { + foo: '1.0.0', + }, + editMode: 'metadata', + }); + + delete process.env.JSS_EDITING_SECRET; + + done(); + }); + }); + }); +}); diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/index.ts b/packages/sitecore-jss-proxy/src/middleware/editing/index.ts index a68667b64c..b5649f46af 100644 --- a/packages/sitecore-jss-proxy/src/middleware/editing/index.ts +++ b/packages/sitecore-jss-proxy/src/middleware/editing/index.ts @@ -47,9 +47,10 @@ export const editingMiddleware = async ( if (!secret) { debug.editing('missing editing secret - set JSS_EDITING_SECRET environment variable'); - return res - .status(401) - .json({ message: 'Missing editing secret - set JSS_EDITING_SECRET environment variable' }); + return res.status(401).json({ + html: + 'Missing editing secret - set JSS_EDITING_SECRET environment variable', + }); } if (secret !== providedSecret) { From 926670b8c35574e95ffa848781c30454158eba8e Mon Sep 17 00:00:00 2001 From: illiakovalenko Date: Thu, 22 Aug 2024 22:07:53 +0300 Subject: [PATCH 04/10] Update --- .../src/templates/node-xmcloud-proxy/.env | 1 + .../templates/node-xmcloud-proxy/src/index.ts | 3 ++ .../templates/node-xmcloud-proxy/src/types.ts | 40 +----------------- .../src/editing/constants.ts | 6 --- .../src/editing/editing-config-middleware.ts | 5 ++- .../src/editing/editing-data-middleware.ts | 5 ++- .../src/editing/editing-data-service.ts | 2 +- .../src/editing/editing-render-middleware.ts | 5 ++- .../src/editing/feaas-render-middleware.ts | 5 ++- packages/sitecore-jss-proxy/README.md | 2 +- packages/sitecore-jss-proxy/src/index.ts | 1 + .../src/middleware/editing/config.ts | 26 ++++++++++-- .../src/middleware/editing/index.test.ts | 42 +++++++++++++++++++ .../src/middleware/editing/index.ts | 41 +++++++++++++++--- .../headless-ssr-proxy/AppRenderer.ts | 11 ----- .../headless-ssr-proxy/ProxyConfig.ts | 6 +-- .../headless-ssr-proxy/RenderResponse.ts | 15 ------- .../middleware/headless-ssr-proxy/index.ts | 5 +-- .../src/middleware/utils.ts | 5 --- .../src/types/AppRenderer.ts | 40 ++++++++++++++++++ .../RouteUrlParser.ts | 0 .../sitecore-jss-proxy/src/types/index.ts | 2 + packages/sitecore-jss/src/editing/index.ts | 2 + packages/sitecore-jss/src/editing/utils.ts | 10 +++++ 24 files changed, 184 insertions(+), 96 deletions(-) delete mode 100644 packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/AppRenderer.ts delete mode 100644 packages/sitecore-jss-proxy/src/middleware/headless-ssr-proxy/RenderResponse.ts delete mode 100644 packages/sitecore-jss-proxy/src/middleware/utils.ts create mode 100644 packages/sitecore-jss-proxy/src/types/AppRenderer.ts rename packages/sitecore-jss-proxy/src/{middleware/headless-ssr-proxy => types}/RouteUrlParser.ts (100%) create mode 100644 packages/sitecore-jss-proxy/src/types/index.ts diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env index da70361764..ed0d6422ef 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/.env @@ -1,5 +1,6 @@ # To secure the Sitecore editor endpoint exposed by your proxy app # (`/api/editing/render` by default), a secret token is used. +# The environment variable is used by `editingRouter` # We recommend an alphanumeric value of at least 16 characters. JSS_EDITING_SECRET= diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts index 53ef268b79..f7fb90e43b 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts @@ -128,6 +128,9 @@ server.use( }) ); +/** + * Proxy editing requests through the editing router + */ server.use( '/api/editing', editingRouter({ diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts index 1adb39d418..bd9746d3fe 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts @@ -4,44 +4,8 @@ import { } from '@sitecore-jss/sitecore-jss'; import { DictionaryService } from '@sitecore-jss/sitecore-jss/i18n'; import { Metadata } from '@sitecore-jss/sitecore-jss/utils'; -import { LayoutService, LayoutServiceData } from '@sitecore-jss/sitecore-jss/layout'; - -interface ServerResponse { - /** - * The rendered HTML to return to the client - */ - html: string; - /** - * Set the HTTP status code. If not set, the status code returned from Layout Service is returned. - */ - status?: number; - /** - * Sets a redirect URL, causing the reply to send a HTTP redirect instead of the HTML content. - * Note: when using this you must set the status code to 301 or 302. - */ - redirect?: string; -} - -declare type AppRenderer = ( - callback: (error: Error | null, result: ServerResponse | null) => void, - path: string, - /** - * Data returned by Layout Service. If the route does not exist, null. - */ - layoutData: LayoutServiceData, - viewBag: { - [key: string]: unknown; - dictionary: { [key: string]: string }; - } -) => void; - -declare type RouteUrlParser = ( - url: string -) => { - sitecoreRoute?: string; - lang?: string; - qsParams?: string; -}; +import { LayoutService } from '@sitecore-jss/sitecore-jss/layout'; +import { AppRenderer, RouteUrlParser } from '@sitecore-jss/sitecore-jss-proxy'; export interface ServerBundle { [key: string]: unknown; diff --git a/packages/sitecore-jss-nextjs/src/editing/constants.ts b/packages/sitecore-jss-nextjs/src/editing/constants.ts index ccb67004db..bfcc089b4f 100644 --- a/packages/sitecore-jss-nextjs/src/editing/constants.ts +++ b/packages/sitecore-jss-nextjs/src/editing/constants.ts @@ -1,4 +1,3 @@ -export const QUERY_PARAM_EDITING_SECRET = 'secret'; export const QUERY_PARAM_VERCEL_PROTECTION_BYPASS = 'x-vercel-protection-bypass'; export const QUERY_PARAM_VERCEL_SET_BYPASS_COOKIE = 'x-vercel-set-bypass-cookie'; @@ -7,8 +6,3 @@ export const QUERY_PARAM_VERCEL_SET_BYPASS_COOKIE = 'x-vercel-set-bypass-cookie' * Note these are in lowercase format to match expected `IncomingHttpHeaders`. */ export const EDITING_PASS_THROUGH_HEADERS = ['authorization', 'cookie']; - -/** - * Default allowed origins for editing requests. This is used to enforce CORS, CSP headers. - */ -export const EDITING_ALLOWED_ORIGINS = ['https://pages.sitecorecloud.io']; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.ts index 7be123f5e9..97aec74a47 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.ts @@ -1,5 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET } from './constants'; +import { + EDITING_ALLOWED_ORIGINS, + QUERY_PARAM_EDITING_SECRET, +} from '@sitecore-jss/sitecore-jss/editing'; import { getJssEditingSecret } from '../utils/utils'; import { debug } from '@sitecore-jss/sitecore-jss'; import { EditMode } from '@sitecore-jss/sitecore-jss/layout'; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts index 9cce3a2fd4..18b2e4d24c 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts @@ -1,7 +1,10 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { EditingDataCache, editingDataDiskCache } from './editing-data-cache'; import { EditingData, isEditingData } from './editing-data'; -import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET } from './constants'; +import { + EDITING_ALLOWED_ORIGINS, + QUERY_PARAM_EDITING_SECRET, +} from '@sitecore-jss/sitecore-jss/editing'; import { getJssEditingSecret } from '../utils/utils'; import { enforceCors } from '@sitecore-jss/sitecore-jss/utils'; import { debug } from '@sitecore-jss/sitecore-jss'; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-data-service.ts b/packages/sitecore-jss-nextjs/src/editing/editing-data-service.ts index 6a51126b28..a1fd783a6f 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-data-service.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-data-service.ts @@ -1,4 +1,4 @@ -import { QUERY_PARAM_EDITING_SECRET } from './constants'; +import { QUERY_PARAM_EDITING_SECRET } from '@sitecore-jss/sitecore-jss/editing'; import { AxiosDataFetcher, debug } from '@sitecore-jss/sitecore-jss'; import { EditingData } from './editing-data'; import { EditingDataCache, editingDataDiskCache } from './editing-data-cache'; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts index 56c73148d9..ec5ded6c1a 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts @@ -2,9 +2,12 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { STATIC_PROPS_ID, SERVER_PROPS_ID } from 'next/constants'; import { AxiosDataFetcher, debug } from '@sitecore-jss/sitecore-jss'; import { EditMode, LayoutServicePageState } from '@sitecore-jss/sitecore-jss/layout'; +import { + QUERY_PARAM_EDITING_SECRET, + EDITING_ALLOWED_ORIGINS, +} from '@sitecore-jss/sitecore-jss/editing'; import { EditingData } from './editing-data'; import { EditingDataService, editingDataService } from './editing-data-service'; -import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET } from './constants'; import { getJssEditingSecret } from '../utils/utils'; import { RenderMiddlewareBase } from './render-middleware'; import { enforceCors, getAllowedOriginsFromEnv } from '@sitecore-jss/sitecore-jss/utils'; diff --git a/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.ts index 3e7772ba50..9dbc05dfed 100644 --- a/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.ts @@ -1,6 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { debug } from '@sitecore-jss/sitecore-jss'; -import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET } from './constants'; +import { + EDITING_ALLOWED_ORIGINS, + QUERY_PARAM_EDITING_SECRET, +} from '@sitecore-jss/sitecore-jss/editing'; import { getJssEditingSecret } from '../utils/utils'; import { RenderMiddlewareBase } from './render-middleware'; import { enforceCors } from '@sitecore-jss/sitecore-jss/utils'; diff --git a/packages/sitecore-jss-proxy/README.md b/packages/sitecore-jss-proxy/README.md index d571a2c79c..d5b52a6f07 100644 --- a/packages/sitecore-jss-proxy/README.md +++ b/packages/sitecore-jss-proxy/README.md @@ -1,6 +1,6 @@ # Sitecore JavaScript Rendering SDK Proxy -This module is provided as a part of Sitecore JavaScript Rendering SDK (JSS). It contains the headless-mode SSR proxy implementation. +This module is provided as a part of Sitecore JavaScript Rendering SDK (JSS). It provides middlewares, utilities to work in a headless mode.