diff --git a/lib/preview-proxy.js b/lib/preview-proxy.js new file mode 100644 index 00000000..70123dac --- /dev/null +++ b/lib/preview-proxy.js @@ -0,0 +1,205 @@ +import crypto from 'crypto'; +import httpProxy from 'http-proxy'; + +const PREVIEW_HEADER_PREFIX = 'x-stencil-preview-'; +const SESSION_HEADER = 'x-stencil-preview-session'; +const TIMESTAMP_HEADER = 'x-stencil-preview-timestamp'; +const SIGNATURE_HEADER = 'x-stencil-preview-signature'; +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Normalizes the request target. + * + * @param {string} rawUrl - request target from the incoming request + * @param {string} origin - storefront origin + * @returns {string | null} null when the target is not origin-form + */ +function getSignedPath(rawUrl, origin) { + if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/')) { + return null; + } + try { + const url = new URL(`${origin}${rawUrl}`); + return url.origin === origin ? `${url.pathname}${url.search}` : null; + } catch { + return null; + } +} + +function buildSigningString({ sessionId, timestamp, method, pathWithQuery }) { + return `${sessionId}\n${timestamp}\n${method.toUpperCase()}\n${pathWithQuery}`; +} + +/** + * The HMAC key is the UTF-8 bytes of the hex access key string. + * + * @returns {Record} the three proof headers + */ +function signPreviewRequest({ sessionId, accessKey, timestamp, method, pathWithQuery }) { + const signature = crypto + .createHmac('sha256', accessKey) + .update(buildSigningString({ sessionId, timestamp, method, pathWithQuery })) + .digest('hex'); + return { + [SESSION_HEADER]: sessionId, + [TIMESTAMP_HEADER]: String(timestamp), + [SIGNATURE_HEADER]: signature, + }; +} + +function parseUrl(value) { + try { + return new URL(value); + } catch { + return null; + } +} + +function isLoopbackHost(host) { + const url = typeof host === 'string' && host ? parseUrl(`http://${host}`) : null; + return url !== null && LOOPBACK_HOSTNAMES.has(url.hostname); +} + +function isLoopbackUrl(value) { + const url = typeof value === 'string' ? parseUrl(value) : null; + return url !== null && LOOPBACK_HOSTNAMES.has(url.hostname); +} + +/** + * Origin and Referer from the local front door point at the storefront instead. + * Other values do not change. + */ +function rewriteRequestHeaders(request, storefrontOrigin) { + const { origin, referer } = request.headers; + if (isLoopbackUrl(origin)) { + request.headers.origin = storefrontOrigin; + } + if (isLoopbackUrl(referer)) { + const { pathname, search } = new URL(referer); + request.headers.referer = `${storefrontOrigin}${pathname}${search}`; + } +} + +/** + * Keeps the browser on the local front door when the storefront redirects to itself. + * A redirect to another host does not change. + */ +function rewriteLocation(location, storefrontUrl, localOrigin) { + if (typeof location !== 'string' || !/^(https?:)?\/\//i.test(location)) { + return location; + } + let url; + try { + url = new URL(location, storefrontUrl); + } catch { + return location; + } + if (url.host !== storefrontUrl.host || !/^https?:$/.test(url.protocol)) { + return location; + } + return `${localOrigin}${url.pathname}${url.search}${url.hash}`; +} + +function sendText(response, status, message) { + response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }); + response.end(message); +} + +/** + * Connect middleware for the local front door in Makeswift preview mode. It forwards each + * request to the storefront with a per-request proof, so the access key never leaves the CLI. + * + * @param {object} options + * @param {{ storeUrl: string, sessionId: string, accessKey: string }} options.previewSession - + * read on each request, so a re-registered session is used at once + * @param {Function} [options.proxyFactory] + * @param {{ error: Function }} [options.logger] + * @param {() => number} [options.now] + * @returns {(request: import('http').IncomingMessage, response: import('http').ServerResponse) => void} + */ +function createPreviewProxy({ + previewSession, + proxyFactory = httpProxy.createProxyServer, + logger = console, + now = Date.now, +}) { + const storefrontUrl = new URL(previewSession.storeUrl); + const storefrontOrigin = storefrontUrl.origin; + const proxy = proxyFactory({ + target: storefrontOrigin, + changeOrigin: true, + secure: true, + xfwd: false, + }); + + proxy.on('proxyReq', (proxyRequest, request) => { + // http-proxy collapses repeated slashes in the path. Send the exact signed path. + Object.assign(proxyRequest, { path: request.url }); + }); + + proxy.on('proxyRes', (proxyResponse, request) => { + const { headers } = proxyResponse; + if (headers.location) { + headers.location = rewriteLocation( + headers.location, + storefrontUrl, + `http://${request.headers.host}`, + ); + } + }); + + proxy.on('error', (error, _request, response) => { + logger.error(`Makeswift preview: the storefront request failed. ${error.message}`); + if (!response || typeof response.writeHead !== 'function') { + return; + } + if (response.headersSent) { + response.destroy(); + return; + } + sendText(response, 502, 'Makeswift preview: the storefront request failed.'); + }); + + return (request, response) => { + // Blocks DNS rebinding: another site cannot use the browser to send signed requests. + if (!isLoopbackHost(request.headers.host)) { + sendText(response, 403, 'Makeswift preview is available only on localhost.'); + return; + } + const pathWithQuery = getSignedPath(request.url, storefrontOrigin); + if (pathWithQuery === null) { + sendText(response, 400, 'Bad request.'); + return; + } + + for (const name of Object.keys(request.headers)) { + if (name.startsWith(PREVIEW_HEADER_PREFIX)) { + delete request.headers[name]; + } + } + delete request.headers.expect; + rewriteRequestHeaders(request, storefrontOrigin); + Object.assign( + request.headers, + signPreviewRequest({ + sessionId: previewSession.sessionId, + accessKey: previewSession.accessKey, + timestamp: now(), + method: request.method, + pathWithQuery, + }), + ); + request.url = pathWithQuery; + + proxy.web(request, response); + }; +} + +export { + buildSigningString, + createPreviewProxy, + getSignedPath, + isLoopbackHost, + rewriteLocation, + signPreviewRequest, +}; diff --git a/lib/preview-proxy.spec.js b/lib/preview-proxy.spec.js new file mode 100644 index 00000000..4d0d7389 --- /dev/null +++ b/lib/preview-proxy.spec.js @@ -0,0 +1,443 @@ +import { jest } from '@jest/globals'; +import http from 'http'; +import { webcrypto } from 'crypto'; +import { + buildSigningString, + createPreviewProxy, + getSignedPath, + isLoopbackHost, + rewriteLocation, + signPreviewRequest, +} from './preview-proxy.js'; + +const sessionId = '0b6f9c3e-4d2a-4f7b-9a1c-2e3d4f5a6b7c'; +const accessKey = '0123456789abcdef'.repeat(4); +const timestamp = 1791400000000; +const storeOrigin = 'https://store.example.com'; + +// Same steps as verifySignature() in makeswift-stencil-worker verify-preview-proof.ts. +async function workerVerifies({ signature, signingString, key = accessKey }) { + const cryptoKey = await webcrypto.subtle.importKey( + 'raw', + new TextEncoder().encode(key), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ); + return webcrypto.subtle.verify( + 'HMAC', + cryptoKey, + Buffer.from(signature, 'hex'), + new TextEncoder().encode(signingString), + ); +} + +describe('preview-proxy', () => { + describe('buildSigningString', () => { + it('should join session, timestamp, method, and path with newlines', () => { + expect( + buildSigningString({ + sessionId, + timestamp, + method: 'post', + pathWithQuery: '/cart.php?action=add&product_id=1', + }), + ).toBe( + '0b6f9c3e-4d2a-4f7b-9a1c-2e3d4f5a6b7c\n1791400000000\nPOST\n/cart.php?action=add&product_id=1', + ); + }); + }); + + describe('signPreviewRequest', () => { + const pathWithQuery = '/cart.php?action=add&product_id=1'; + + it('should return the proof headers with a pinned signature', () => { + expect( + signPreviewRequest({ + sessionId, + accessKey, + timestamp, + method: 'POST', + pathWithQuery, + }), + ).toEqual({ + 'x-stencil-preview-session': sessionId, + 'x-stencil-preview-timestamp': '1791400000000', + 'x-stencil-preview-signature': + '1faf01f8cb54eebd4a3613f82e788c1adb608dee6db5b5bb97039c32682533bf', + }); + }); + + it('should make a signature that the worker accepts', async () => { + const headers = signPreviewRequest({ + sessionId, + accessKey, + timestamp, + method: 'GET', + pathWithQuery: '/products/a%20b/?sort=new', + }); + const signature = headers['x-stencil-preview-signature']; + const signingString = `${sessionId}\n${timestamp}\nGET\n/products/a%20b/?sort=new`; + + expect(signature).toMatch(/^[a-f0-9]{64}$/); + await expect(workerVerifies({ signature, signingString })).resolves.toBe(true); + await expect( + workerVerifies({ signature, signingString: signingString.replace('GET', 'POST') }), + ).resolves.toBe(false); + await expect( + workerVerifies({ signature, signingString, key: 'f'.repeat(64) }), + ).resolves.toBe(false); + }); + }); + + describe('getSignedPath', () => { + it.each([ + ['/', '/'], + ['/cart.php?action=add', '/cart.php?action=add'], + ['/a/../b/./c?x=1#frag', '/b/c?x=1'], + ['//evil.example/x', '//evil.example/x'], + ['/a//b?q=a//b', '/a//b?q=a//b'], + ['/é?q=é', '/%C3%A9?q=%C3%A9'], + ['/%7Efoo?q=%20', '/%7Efoo?q=%20'], + ['/search?', '/search'], + ])('should normalize %s to %s', (rawUrl, expected) => { + expect(getSignedPath(rawUrl, storeOrigin)).toBe(expected); + }); + + it.each(['https://other.example/x', '*', '', undefined])('should reject %s', (rawUrl) => { + expect(getSignedPath(rawUrl, storeOrigin)).toBeNull(); + }); + }); + + describe('isLoopbackHost', () => { + it.each([ + ['localhost:3000', true], + ['127.0.0.1:3000', true], + ['[::1]:3000', true], + ['localhost', true], + ['evil.example:3000', false], + ['localhost.evil.example', false], + ['', false], + [undefined, false], + ])('%s -> %s', (host, expected) => { + expect(isLoopbackHost(host)).toBe(expected); + }); + }); + + describe('rewriteLocation', () => { + const storefrontUrl = new URL(storeOrigin); + const localOrigin = 'http://127.0.0.1:3000'; + + it.each([ + [ + 'https://store.example.com/login.php?from=x#top', + `${localOrigin}/login.php?from=x#top`, + ], + ['http://store.example.com/cart.php', `${localOrigin}/cart.php`], + ['//store.example.com/account.php', `${localOrigin}/account.php`], + ['https://other.example.com/x', 'https://other.example.com/x'], + ['https://store.example.com:8443/x', 'https://store.example.com:8443/x'], + ['/relative/path', '/relative/path'], + ])('%s -> %s', (location, expected) => { + expect(rewriteLocation(location, storefrontUrl, localOrigin)).toBe(expected); + }); + }); + + describe('createPreviewProxy', () => { + const getProxyStub = () => { + const handlers = {}; + return { + handlers, + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + web: jest.fn(), + }; + }; + const getResponseStub = () => ({ + writeHead: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + headersSent: false, + }); + const setup = () => { + const proxy = getProxyStub(); + const proxyFactory = jest.fn().mockReturnValue(proxy); + const logger = { error: jest.fn() }; + const previewSession = { storeUrl: `${storeOrigin}/`, sessionId, accessKey }; + const middleware = createPreviewProxy({ + previewSession, + proxyFactory, + logger, + now: () => timestamp, + }); + return { proxy, proxyFactory, logger, previewSession, middleware }; + }; + const getRequest = (overrides = {}) => ({ + method: 'GET', + url: '/', + ...overrides, + headers: { host: 'localhost:3000', ...overrides.headers }, + }); + + it('should proxy to the storefront origin with TLS verification', () => { + const { proxyFactory } = setup(); + expect(proxyFactory).toHaveBeenCalledWith({ + target: storeOrigin, + changeOrigin: true, + secure: true, + xfwd: false, + }); + }); + + it('should sign the normalized path and forward that path', () => { + const { proxy, middleware } = setup(); + const request = getRequest({ method: 'POST', url: '/a/../cart.php?x=1' }); + const response = getResponseStub(); + + middleware(request, response); + + expect(request.url).toBe('/cart.php?x=1'); + expect(request.headers).toEqual({ + host: 'localhost:3000', + ...signPreviewRequest({ + sessionId, + accessKey, + timestamp, + method: 'POST', + pathWithQuery: '/cart.php?x=1', + }), + }); + expect(proxy.web).toHaveBeenCalledWith(request, response); + + const proxyRequest = { path: '/collapsed' }; + proxy.handlers.proxyReq(proxyRequest, request); + expect(proxyRequest.path).toBe('/cart.php?x=1'); + }); + + it('should remove inbound preview headers and never send the secret', () => { + const { middleware } = setup(); + const request = getRequest({ + headers: { + 'x-stencil-preview-secret': 'leaked', + 'x-stencil-preview-session': 'forged', + 'x-stencil-preview-signature': 'forged', + 'x-stencil-preview-other': 'x', + }, + }); + + middleware(request, getResponseStub()); + + expect(request.headers['x-stencil-preview-secret']).toBeUndefined(); + expect(request.headers['x-stencil-preview-other']).toBeUndefined(); + expect(request.headers['x-stencil-preview-session']).toBe(sessionId); + expect(request.headers['x-stencil-preview-signature']).not.toBe('forged'); + }); + + it('should rewrite loopback Origin and Referer and keep cookies', () => { + const { middleware } = setup(); + const request = getRequest({ + headers: { + origin: 'http://localhost:3000', + referer: 'http://127.0.0.1:3000/cart.php?a=1', + cookie: 'SHOP_SESSION_TOKEN=abc; other=1', + }, + }); + + middleware(request, getResponseStub()); + + expect(request.headers.origin).toBe(storeOrigin); + expect(request.headers.referer).toBe(`${storeOrigin}/cart.php?a=1`); + expect(request.headers.cookie).toBe('SHOP_SESSION_TOKEN=abc; other=1'); + }); + + it('should keep a non-loopback Origin and Referer', () => { + const { middleware } = setup(); + const request = getRequest({ + headers: { origin: 'https://other.example', referer: 'https://other.example/p' }, + }); + + middleware(request, getResponseStub()); + + expect(request.headers.origin).toBe('https://other.example'); + expect(request.headers.referer).toBe('https://other.example/p'); + }); + + it('should use the current session values on each request', () => { + const { middleware, previewSession } = setup(); + previewSession.sessionId = 'new-session'; + previewSession.accessKey = 'f'.repeat(64); + const request = getRequest(); + + middleware(request, getResponseStub()); + + expect(request.headers).toEqual( + expect.objectContaining( + signPreviewRequest({ + sessionId: 'new-session', + accessKey: 'f'.repeat(64), + timestamp, + method: 'GET', + pathWithQuery: '/', + }), + ), + ); + }); + + it('should reject a request whose Host is not loopback', () => { + const { proxy, middleware } = setup(); + const response = getResponseStub(); + + middleware(getRequest({ headers: { host: 'evil.example:3000' } }), response); + + expect(response.writeHead).toHaveBeenCalledWith(403, expect.any(Object)); + expect(proxy.web).not.toHaveBeenCalled(); + }); + + it('should reject a request target that is not a path', () => { + const { proxy, middleware } = setup(); + const response = getResponseStub(); + + middleware(getRequest({ url: 'https://other.example/x' }), response); + + expect(response.writeHead).toHaveBeenCalledWith(400, expect.any(Object)); + expect(proxy.web).not.toHaveBeenCalled(); + }); + + it('should rewrite a same-store redirect to the local origin', () => { + const { proxy } = setup(); + const proxyResponse = { headers: { location: `${storeOrigin}/login.php` } }; + + proxy.handlers.proxyRes(proxyResponse, getRequest()); + + expect(proxyResponse.headers.location).toBe('http://localhost:3000/login.php'); + }); + + it('should return 502 when the storefront request fails', () => { + const { proxy, logger } = setup(); + const response = getResponseStub(); + + proxy.handlers.error(new Error('ECONNRESET'), getRequest(), response); + + expect(response.writeHead).toHaveBeenCalledWith(502, expect.any(Object)); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('ECONNRESET')); + }); + + it('should close the response when the error happens after the headers are sent', () => { + const { proxy } = setup(); + const response = { ...getResponseStub(), headersSent: true }; + + proxy.handlers.error(new Error('ECONNRESET'), getRequest(), response); + + expect(response.destroy).toHaveBeenCalled(); + expect(response.writeHead).not.toHaveBeenCalled(); + }); + }); + + describe('createPreviewProxy with a real upstream', () => { + let upstream; + let front; + let frontPort; + const received = []; + + beforeAll(async () => { + upstream = http.createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + received.push({ + method: request.method, + url: request.url, + headers: request.headers, + body, + }); + response.end('ok'); + }); + }); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', resolve); + }); + const previewSession = { + storeUrl: `http://127.0.0.1:${upstream.address().port}`, + sessionId, + accessKey, + }; + const middleware = createPreviewProxy({ + previewSession, + logger: { error: jest.fn() }, + now: () => timestamp, + }); + front = http.createServer(middleware); + await new Promise((resolve) => { + front.listen(0, '127.0.0.1', resolve); + }); + frontPort = front.address().port; + }); + + afterAll(async () => { + front.closeAllConnections(); + await new Promise((resolve) => { + front.close(resolve); + }); + await new Promise((resolve) => { + upstream.close(resolve); + }); + }); + + it('should send the signed path, method, body, and proof headers upstream', async () => { + const response = await fetch(`http://127.0.0.1:${frontPort}//a//b?q=1`, { + method: 'POST', + body: 'qty=2', + headers: { cookie: 'c=1', 'x-stencil-preview-secret': 'leaked' }, + }); + + expect(await response.text()).toBe('ok'); + const [request] = received; + expect(request.method).toBe('POST'); + expect(request.url).toBe('//a//b?q=1'); + expect(request.body).toBe('qty=2'); + expect(request.headers.cookie).toBe('c=1'); + expect(request.headers['x-stencil-preview-secret']).toBeUndefined(); + expect(request.headers.host).toBe(`127.0.0.1:${upstream.address().port}`); + await expect( + workerVerifies({ + signature: request.headers['x-stencil-preview-signature'], + signingString: `${sessionId}\n${timestamp}\nPOST\n//a//b?q=1`, + }), + ).resolves.toBe(true); + }); + + it('should send the signed path when the client sends Expect: 100-continue', async () => { + received.length = 0; + const status = await new Promise((resolve, reject) => { + const request = http.request({ + host: '127.0.0.1', + port: frontPort, + method: 'PUT', + path: '//c//d', + headers: { expect: '100-continue', 'content-length': '4' }, + }); + request.on('continue', () => request.end('body')); + request.on('response', (response) => { + response.resume(); + response.on('end', () => resolve(response.statusCode)); + }); + request.on('error', reject); + }); + + expect(status).toBe(200); + const [request] = received; + expect(request.url).toBe('//c//d'); + expect(request.body).toBe('body'); + expect(request.headers.expect).toBeUndefined(); + await expect( + workerVerifies({ + signature: request.headers['x-stencil-preview-signature'], + signingString: `${sessionId}\n${timestamp}\nPUT\n//c//d`, + }), + ).resolves.toBe(true); + }); + }); +}); diff --git a/lib/stencil-start.js b/lib/stencil-start.js index 61a8234c..53deb015 100755 --- a/lib/stencil-start.js +++ b/lib/stencil-start.js @@ -19,6 +19,9 @@ import LangValidator from './lang/validator.js'; import makeswiftProxyHealthModule from './makeswift-proxy-health.js'; import tunnelManagerModule from './TunnelManager.js'; import previewSessionClientModule from './PreviewSessionClient.js'; +import { createPreviewProxy } from './preview-proxy.js'; + +const PREVIEW_LISTEN_HOST = 'localhost'; class StencilStart { constructor({ @@ -39,6 +42,7 @@ class StencilStart { makeswiftProxyHealth = makeswiftProxyHealthModule, tunnelManager = tunnelManagerModule, previewSessionClient = previewSessionClientModule, + previewProxyFactory = createPreviewProxy, processObj = process, } = {}) { this._browserSync = browserSync; @@ -58,6 +62,7 @@ class StencilStart { this._makeswiftProxyHealth = makeswiftProxyHealth; this._tunnelManager = tunnelManager; this._previewSessionClient = previewSessionClient; + this._previewProxyFactory = previewProxyFactory; this._process = processObj; this._tunnel = null; this._previewSession = null; @@ -93,6 +98,7 @@ class StencilStart { } if (makeswiftPreview) { await this.startMakeswiftPreview({ + localPort: browserSyncPort, rendererPort: this.getRendererPort(browserSyncPort), storeUrl: previewStoreUrl, accessToken: updatedStencilConfig.accessToken, @@ -142,12 +148,13 @@ class StencilStart { * the preview session. If either fails, `stencil start` continues without Makeswift preview. * * @param {object} options + * @param {number} options.localPort - BrowserSync port, the local front door * @param {number} options.rendererPort * @param {string} options.storeUrl * @param {string} options.accessToken * @param {string} options.secret */ - async startMakeswiftPreview({ rendererPort, storeUrl, accessToken, secret }) { + async startMakeswiftPreview({ localPort, rendererPort, storeUrl, accessToken, secret }) { this._logger.log('Makeswift is enabled for this storefront. Starting Makeswift preview...'); try { this._tunnel = await this._tunnelManager.start(rendererPort); @@ -170,8 +177,9 @@ class StencilStart { this.warnPreviewUnavailable(err); return; } + const localUrl = `http://${PREVIEW_LISTEN_HOST}:${localPort}`; this._logger.log( - `Makeswift preview URL: ${this._tunnel.url.cyan}\n` + + `Makeswift preview is active. Open ${localUrl.cyan}\n` + 'The Makeswift preview session ends when you stop stencil start.', ); } @@ -353,8 +361,9 @@ class StencilStart { ignoreInitial: true, ignored: watchIgnored.map((val) => path.join(themePath, val)), }, - proxy: `localhost:${this.getRendererPort(browserSyncPort)}`, - tunnel, + ...(this._previewSession + ? this.getPreviewBrowserSyncOptions() + : { proxy: `localhost:${this.getRendererPort(browserSyncPort)}`, tunnel }), }); // Handle manual reloading of browsers by typing 'rs'; // Borrowed from https://github.com/remy/nodemon @@ -373,6 +382,30 @@ class StencilStart { await this.checkLangFiles(langsPath, this._storeSettingsLocale.default_shopper_language); } + /** + * In preview mode the local front door proxies to the storefront (not the renderer) and + * signs each request. The worker sends the request back to the renderer through the tunnel. + * It listens on localhost (a loopback address) only, so other devices cannot send signed + * requests. + * + * @returns {object} BrowserSync options + */ + getPreviewBrowserSyncOptions() { + return { + listen: PREVIEW_LISTEN_HOST, + // No static files. BrowserSync's own routes and HTML snippet injection still run + // first; the proxy middleware handles all other requests. + server: { baseDir: [] }, + middleware: [ + this._previewProxyFactory({ + previewSession: this._previewSession, + logger: this._logger, + }), + ], + tunnel: false, + }; + } + /** * Assembles all the needed templates and resolves their partials. * diff --git a/lib/stencil-start.spec.js b/lib/stencil-start.spec.js index e34f8a6a..89347b54 100644 --- a/lib/stencil-start.spec.js +++ b/lib/stencil-start.spec.js @@ -87,6 +87,7 @@ describe('StencilStart unit tests', () => { makeswiftProxyHealth, tunnelManager, previewSessionClient, + previewProxyFactory, processObj, } = {}) => { const passedArgs = { @@ -105,6 +106,7 @@ describe('StencilStart unit tests', () => { makeswiftProxyHealth: makeswiftProxyHealth || getMakeswiftProxyHealthStub(), tunnelManager: tunnelManager || getTunnelManagerStub(), previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), + previewProxyFactory: previewProxyFactory || jest.fn().mockReturnValue(jest.fn()), processObj: processObj || getProcessStub(), }; const instance = new StencilStart(passedArgs); @@ -203,6 +205,7 @@ describe('StencilStart unit tests', () => { describe('Makeswift preview', () => { const port = 3000; + const previewProxyMiddleware = jest.fn(); const runStart = async ({ cliOptions = {}, makeswiftActive = true, @@ -217,6 +220,7 @@ describe('StencilStart unit tests', () => { makeswiftProxyHealth: getMakeswiftProxyHealthStub(makeswiftActive), tunnelManager: tunnelManager || getTunnelManagerStub(), previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), + previewProxyFactory: jest.fn().mockReturnValue(previewProxyMiddleware), stencilConfigManager: getStencilConfigManagerStub({ port, accessToken }), }; const { instance } = createStencilStartInstance(deps); @@ -232,24 +236,52 @@ describe('StencilStart unit tests', () => { const signalHandler = (processObj, signal) => processObj.on.mock.calls.find(([name]) => name === signal)[1]; - it('should start a tunnel to the renderer port and keep --open on localhost', async () => { - const { makeswiftProxyHealth, tunnelManager, browserSync, output } = await runStart({ - cliOptions: { open: true }, - }); + it('should start a tunnel to the renderer port and print the local front door', async () => { + const { makeswiftProxyHealth, tunnelManager, output } = await runStart(); expect(makeswiftProxyHealth.isMakeswiftProxyActive).toHaveBeenCalledWith({ storeUrl: 'https://www.example.com', }); expect(tunnelManager.start).toHaveBeenCalledWith(port + 1); - expect(output).toContain('https://preview-123.trycloudflare.com'); + expect(output).toContain('Makeswift preview is active. Open'); + expect(output).toContain(`http://localhost:${port}`); + expect(output).not.toContain('https://preview-123.trycloudflare.com'); expect(output).not.toContain(previewSecret); expect(output).not.toContain(registeredSession.accessKey); - expect(browserSync.init).toHaveBeenCalledWith( + }); + + it('should serve a signing proxy to the storefront on loopback only', async () => { + const { browserSync, previewProxyFactory, logger, instance } = await runStart({ + cliOptions: { open: true }, + }); + expect(previewProxyFactory).toHaveBeenCalledWith({ + previewSession: instance.previewSession, + logger, + }); + const options = browserSync.init.mock.calls[0][0]; + expect(options).toEqual( expect.objectContaining({ open: true, - proxy: `localhost:${port + 1}`, + port, + listen: 'localhost', + server: { baseDir: [] }, + middleware: [previewProxyMiddleware], tunnel: false, }), ); + expect(options).not.toHaveProperty('proxy'); + }); + + it('should proxy BrowserSync to the renderer when preview is off', async () => { + const { browserSync, previewProxyFactory } = await runStart({ + makeswiftActive: false, + }); + expect(previewProxyFactory).not.toHaveBeenCalled(); + const options = browserSync.init.mock.calls[0][0]; + expect(options).toEqual( + expect.objectContaining({ proxy: `localhost:${port + 1}`, tunnel: false }), + ); + expect(options).not.toHaveProperty('listen'); + expect(options).not.toHaveProperty('middleware'); }); it('should not start a tunnel or print anything when Makeswift is not active', async () => { diff --git a/package-lock.json b/package-lock.json index 21246e7f..6c35c2ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "front-matter": "^4.0.2", "glob": "^7.1.6", "graceful-fs": "^4.2.4", + "http-proxy": "^1.18.1", "husky": "^8.0.1", "image-size": "^0.9.1", "inquirer": "^8.1.5", diff --git a/package.json b/package.json index 3111fc8f..6431b7a0 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "front-matter": "^4.0.2", "glob": "^7.1.6", "graceful-fs": "^4.2.4", + "http-proxy": "^1.18.1", "husky": "^8.0.1", "image-size": "^0.9.1", "inquirer": "^8.1.5",