diff --git a/lib/MakeswiftPreview.js b/lib/MakeswiftPreview.js new file mode 100644 index 00000000..ff4ffb1d --- /dev/null +++ b/lib/MakeswiftPreview.js @@ -0,0 +1,228 @@ +import 'colors'; +import makeswiftProxyHealthModule from './makeswift-proxy-health.js'; +import tunnelManagerModule from './TunnelManager.js'; +import previewSessionClientModule from './PreviewSessionClient.js'; +import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js'; +import { createPreviewProxy } from './preview-proxy.js'; + +const PREVIEW_LISTEN_HOST = 'localhost'; +// Ctrl-C must not be slow. A session that is not revoked expires with its TTL. +const SHUTDOWN_TIMEOUT_MS = 3000; + +/** + * Makeswift preview for `stencil start`: the tunnel, the preview session and its heartbeat, + * the signing front door, and the cleanup on exit. + */ +class MakeswiftPreview { + constructor({ + makeswiftProxyHealth = makeswiftProxyHealthModule, + tunnelManager = tunnelManagerModule, + previewSessionClient = previewSessionClientModule, + previewProxyFactory = createPreviewProxy, + previewSessionHeartbeatFactory = (options) => new PreviewSessionHeartbeat(options), + processObj = process, + logger = console, + shutdownTimeout = SHUTDOWN_TIMEOUT_MS, + } = {}) { + this._makeswiftProxyHealth = makeswiftProxyHealth; + this._tunnelManager = tunnelManager; + this._previewSessionClient = previewSessionClient; + this._previewProxyFactory = previewProxyFactory; + this._previewSessionHeartbeatFactory = previewSessionHeartbeatFactory; + this._process = processObj; + this._logger = logger; + this._shutdownTimeout = shutdownTimeout; + this._tunnel = null; + this._session = null; + this._accessToken = null; + this._heartbeat = null; + this._shutdownPromise = null; + } + + /** + * The registered preview session, or null when preview is off. + * + * @returns {{ storeUrl: string, tunnelUrl: string, secret: string, sessionId: string, accessKey: string, expiresIn: number } | null} + */ + get session() { + return this._session; + } + + /** + * Preview mode turns on when the storefront host is routed through the Makeswift proxy. + * A failed check never stops `stencil start`; it only turns preview mode off. + * + * @param {object} options + * @param {string} options.storeUrl + * @param {boolean | string} [options.tunnel] - the `--tunnel` CLI option + * @returns {Promise} + */ + async isEnabled({ storeUrl, tunnel }) { + const enabled = await this._makeswiftProxyHealth.isMakeswiftProxyActive({ storeUrl }); + if (enabled && tunnel) { + this._logger.log( + `${'Notice'.yellow}: Makeswift preview is off because --tunnel is set. ` + + 'Remove --tunnel to use Makeswift preview.', + ); + return false; + } + return enabled; + } + + /** + * Starts the Cloudflare tunnel to the renderer port (not the local front door) and registers + * the preview session. If either fails, `stencil start` continues without Makeswift preview. + * Call it before the renderer starts, so the renderer requires the secret only when a + * session exists. + * + * @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] + * @throws {Error} when there is no access token + */ + async start({ localPort, rendererPort, storeUrl, accessToken }) { + if (!accessToken) { + throw new Error( + 'Makeswift preview requires an access token. Add accessToken to your .stencil file.'.red, + ); + } + this._logger.log('Makeswift is enabled for this storefront. Starting Makeswift preview...'); + try { + this._tunnel = await this._tunnelManager.start(rendererPort); + } catch (err) { + this._warnUnavailable(err); + return; + } + this._registerShutdownHandlers(); + const secret = this._previewSessionClient.createSecret(); + try { + const tunnelUrl = this._tunnel.url; + const registration = await this._previewSessionClient.register({ + storeUrl, + accessToken, + tunnelUrl, + secret, + }); + this._session = { storeUrl, tunnelUrl, secret, ...registration }; + this._accessToken = accessToken; + } catch (err) { + await this.stop(); + this._warnUnavailable(err); + return; + } + this._heartbeat = this._previewSessionHeartbeatFactory({ + client: this._previewSessionClient, + session: this._session, + accessToken, + logger: this._logger, + }); + this._heartbeat.start(); + const localUrl = `http://${PREVIEW_LISTEN_HOST}:${localPort}`; + this._logger.log( + `Makeswift preview is active. Open ${localUrl.cyan}\n` + + 'The Makeswift preview session ends when you stop stencil start.', + ); + } + + /** + * 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 + */ + getBrowserSyncOptions() { + 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._session, + logger: this._logger, + }), + ], + tunnel: false, + }; + } + + /** + * Stops the heartbeat and the tunnel, and revokes the session. Safe to call more than once. + * It does not fail and it takes at most the shutdown timeout. A failed revoke is not shown: + * the session expires with its TTL. + * + * @returns {Promise} + */ + shutdown() { + this._shutdownPromise ??= this._shutdown(); + return this._shutdownPromise; + } + + /** + * Stops the heartbeat and the tunnel. It does not revoke the session. + * + * @returns {Promise} + */ + stop() { + this._heartbeat?.stop(); + this._heartbeat = null; + const tunnel = this._tunnel; + this._tunnel = null; + return tunnel ? tunnel.stop() : Promise.resolve(); + } + + async _shutdown() { + const session = this._session; + const accessToken = this._accessToken; + this._accessToken = null; + // Stop the heartbeat first, so it cannot change or renew the session that is revoked. + const stopped = this.stop(); + const revoked = session + ? this._previewSessionClient.revoke({ + storeUrl: session.storeUrl, + accessToken, + sessionId: session.sessionId, + }) + : undefined; + let timer; + const timedOut = new Promise((resolve) => { + timer = setTimeout(resolve, this._shutdownTimeout); + timer.unref(); + }); + await Promise.race([Promise.allSettled([stopped, revoked]), timedOut]); + clearTimeout(timer); + } + + _registerShutdownHandlers() { + let shuttingDown = false; + const onSignal = async (signal) => { + if (shuttingDown) { + this._process.exit(1); + return; + } + shuttingDown = true; + try { + await this.shutdown(); + } finally { + this._process.exit(signal === 'SIGINT' ? 130 : 143); + } + }; + this._process.on('SIGINT', onSignal); + this._process.on('SIGTERM', onSignal); + // Other exits must not leave cloudflared running. This handler must be synchronous, so it + // cannot revoke the session; the session TTL ends it. + this._process.once('exit', () => this.stop()); + } + + _warnUnavailable(err) { + this._logger.error( + `${'Warning'.yellow}: Makeswift preview is not available. ${err.message}\n` + + 'The local server continues without Makeswift preview.', + ); + } +} + +export default MakeswiftPreview; diff --git a/lib/MakeswiftPreview.spec.js b/lib/MakeswiftPreview.spec.js new file mode 100644 index 00000000..c77c5deb --- /dev/null +++ b/lib/MakeswiftPreview.spec.js @@ -0,0 +1,292 @@ +import { jest } from '@jest/globals'; +import MakeswiftPreview from './MakeswiftPreview.js'; + +describe('MakeswiftPreview', () => { + const localPort = 3000; + const rendererPort = 3001; + const storeUrl = 'https://www.example.com'; + const accessToken = 'token-123'; + const tunnelUrl = 'https://preview-123.trycloudflare.com'; + const previewSecret = 's'.repeat(64); + const registeredSession = { + sessionId: '0b6f9c3e-4d2a-4f7b-9a1c-2e3d4f5a6b7c', + accessKey: 'k'.repeat(64), + expiresIn: 300, + }; + const previewProxyMiddleware = jest.fn(); + + const getTunnelManagerStub = () => ({ + start: jest.fn().mockResolvedValue({ url: tunnelUrl, stop: jest.fn().mockResolvedValue() }), + }); + const getPreviewSessionClientStub = () => ({ + createSecret: jest.fn().mockReturnValue(previewSecret), + register: jest.fn().mockResolvedValue(registeredSession), + revoke: jest.fn().mockResolvedValue(), + }); + + const createPreview = ({ + makeswiftActive = true, + tunnelManager = getTunnelManagerStub(), + previewSessionClient = getPreviewSessionClientStub(), + shutdownTimeout, + } = {}) => { + const deps = { + makeswiftProxyHealth: { + isMakeswiftProxyActive: jest.fn().mockResolvedValue(makeswiftActive), + }, + tunnelManager, + previewSessionClient, + previewProxyFactory: jest.fn().mockReturnValue(previewProxyMiddleware), + previewSessionHeartbeatFactory: jest + .fn() + .mockReturnValue({ start: jest.fn(), stop: jest.fn() }), + processObj: { on: jest.fn(), once: jest.fn(), exit: jest.fn() }, + logger: { log: jest.fn(), error: jest.fn() }, + shutdownTimeout, + }; + const preview = new MakeswiftPreview(deps); + const output = () => + [...deps.logger.log.mock.calls, ...deps.logger.error.mock.calls].flat().join('\n'); + return { ...deps, preview, output }; + }; + + const startPreview = async (options) => { + const context = createPreview(options); + await context.preview.start({ localPort, rendererPort, storeUrl, accessToken }); + return context; + }; + + const signalHandler = (processObj, signal) => + processObj.on.mock.calls.find(([name]) => name === signal)[1]; + + describe('isEnabled', () => { + it('should be true when the storefront is routed through the Makeswift proxy', async () => { + const { preview, makeswiftProxyHealth, output } = createPreview(); + await expect(preview.isEnabled({ storeUrl })).resolves.toBe(true); + expect(makeswiftProxyHealth.isMakeswiftProxyActive).toHaveBeenCalledWith({ + storeUrl, + }); + expect(output()).toBe(''); + }); + + it('should be false when Makeswift is not active', async () => { + const { preview, output } = createPreview({ makeswiftActive: false }); + await expect(preview.isEnabled({ storeUrl, tunnel: true })).resolves.toBe(false); + expect(output()).toBe(''); + }); + + it('should be false with a notice when --tunnel is set', async () => { + const { preview, output } = createPreview(); + await expect(preview.isEnabled({ storeUrl, tunnel: true })).resolves.toBe(false); + expect(output()).toContain('Makeswift preview is off because --tunnel is set'); + }); + }); + + describe('start', () => { + it('should start a tunnel to the renderer port and print the local front door', async () => { + const { tunnelManager, output } = await startPreview(); + expect(tunnelManager.start).toHaveBeenCalledWith(rendererPort); + expect(output()).toContain('Makeswift preview is active. Open'); + expect(output()).toContain(`http://localhost:${localPort}`); + expect(output()).not.toContain(tunnelUrl); + expect(output()).not.toContain(previewSecret); + expect(output()).not.toContain(registeredSession.accessKey); + }); + + it('should register the session with a new secret', async () => { + const { previewSessionClient, preview } = await startPreview(); + expect(previewSessionClient.register).toHaveBeenCalledWith({ + storeUrl, + accessToken, + tunnelUrl, + secret: previewSecret, + }); + expect(preview.session).toEqual({ + storeUrl, + tunnelUrl, + secret: previewSecret, + ...registeredSession, + }); + }); + + it('should keep the registered session alive with a heartbeat', async () => { + const { + previewSessionHeartbeatFactory, + previewSessionClient, + logger, + preview, + } = await startPreview(); + expect(previewSessionHeartbeatFactory).toHaveBeenCalledWith({ + client: previewSessionClient, + session: preview.session, + accessToken, + logger, + }); + expect(previewSessionHeartbeatFactory.mock.results[0].value.start).toHaveBeenCalled(); + }); + + it('should fail when there is no access token', async () => { + const { preview, tunnelManager } = createPreview(); + await expect( + preview.start({ localPort, rendererPort, storeUrl, accessToken: null }), + ).rejects.toThrow('Makeswift preview requires an access token'); + expect(tunnelManager.start).not.toHaveBeenCalled(); + }); + + it('should continue without a session when the tunnel fails', async () => { + const tunnelManager = { + start: jest.fn().mockRejectedValue(new Error('cloudflared was not found.')), + }; + const { preview, processObj, output } = await startPreview({ tunnelManager }); + expect(output()).toContain( + 'Makeswift preview is not available. cloudflared was not found.', + ); + expect(processObj.on).not.toHaveBeenCalled(); + expect(preview.session).toBeNull(); + }); + + it('should stop the tunnel and continue without a session when registration fails', async () => { + const previewSessionClient = getPreviewSessionClientStub(); + previewSessionClient.register.mockRejectedValue( + new Error('Could not register the Makeswift preview session (HTTP 401).'), + ); + const { + tunnelManager, + preview, + output, + previewSessionHeartbeatFactory, + } = await startPreview({ previewSessionClient }); + const { stop } = await tunnelManager.start.mock.results[0].value; + expect(stop).toHaveBeenCalled(); + expect(previewSessionHeartbeatFactory).not.toHaveBeenCalled(); + expect(preview.session).toBeNull(); + expect(previewSessionClient.revoke).not.toHaveBeenCalled(); + expect(output()).toContain('Makeswift preview is not available. Could not register'); + }); + }); + + describe('getBrowserSyncOptions', () => { + it('should serve a signing proxy to the storefront on loopback only', async () => { + const { preview, previewProxyFactory, logger } = await startPreview(); + expect(preview.getBrowserSyncOptions()).toEqual({ + listen: 'localhost', + server: { baseDir: [] }, + middleware: [previewProxyMiddleware], + tunnel: false, + }); + expect(previewProxyFactory).toHaveBeenCalledWith({ + previewSession: preview.session, + logger, + }); + }); + }); + + describe('shutdown', () => { + it('should stop the heartbeat and tunnel, revoke the session, and exit on SIGINT', async () => { + const { + tunnelManager, + processObj, + previewSessionClient, + previewSessionHeartbeatFactory, + } = await startPreview(); + const { stop } = await tunnelManager.start.mock.results[0].value; + + await signalHandler(processObj, 'SIGINT')('SIGINT'); + + expect(previewSessionHeartbeatFactory.mock.results[0].value.stop).toHaveBeenCalled(); + expect(stop).toHaveBeenCalledTimes(1); + expect(previewSessionClient.revoke).toHaveBeenCalledWith({ + storeUrl, + accessToken, + sessionId: registeredSession.sessionId, + }); + expect(processObj.exit).toHaveBeenCalledWith(130); + }); + + it('should exit with 143 on SIGTERM', async () => { + const { processObj } = await startPreview(); + + await signalHandler(processObj, 'SIGTERM')('SIGTERM'); + + expect(processObj.exit).toHaveBeenCalledWith(143); + }); + + it('should exit at once on a second signal while cleanup runs, and clean up only once', async () => { + const previewSessionClient = getPreviewSessionClientStub(); + previewSessionClient.revoke.mockReturnValue(new Promise(() => {})); + const { tunnelManager, processObj } = await startPreview({ + previewSessionClient, + shutdownTimeout: 10, + }); + const { stop } = await tunnelManager.start.mock.results[0].value; + + signalHandler(processObj, 'SIGINT')('SIGINT'); + await signalHandler(processObj, 'SIGTERM')('SIGTERM'); + + expect(processObj.exit).toHaveBeenCalledTimes(1); + expect(processObj.exit).toHaveBeenCalledWith(1); + expect(previewSessionClient.revoke).toHaveBeenCalledTimes(1); + expect(stop).toHaveBeenCalledTimes(1); + }); + + it('should stop the tunnel and exit without output when the revoke fails', async () => { + const previewSessionClient = getPreviewSessionClientStub(); + previewSessionClient.revoke.mockRejectedValue( + new Error('Could not revoke the Makeswift preview session (HTTP 503).'), + ); + const { tunnelManager, processObj, logger } = await startPreview({ + previewSessionClient, + }); + const { stop } = await tunnelManager.start.mock.results[0].value; + logger.log.mockClear(); + logger.error.mockClear(); + + await signalHandler(processObj, 'SIGINT')('SIGINT'); + + expect(stop).toHaveBeenCalled(); + expect(processObj.exit).toHaveBeenCalledWith(130); + expect(logger.log).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should exit after the shutdown timeout when the revoke does not complete', async () => { + const previewSessionClient = getPreviewSessionClientStub(); + previewSessionClient.revoke.mockReturnValue(new Promise(() => {})); + const { processObj } = await startPreview({ + previewSessionClient, + shutdownTimeout: 10, + }); + + await signalHandler(processObj, 'SIGINT')('SIGINT'); + + expect(processObj.exit).toHaveBeenCalledWith(130); + }); + + it('should only stop the tunnel on a process exit that is not a signal', async () => { + const { tunnelManager, processObj, previewSessionClient } = await startPreview(); + const { stop } = await tunnelManager.start.mock.results[0].value; + const onExit = processObj.once.mock.calls.find(([name]) => name === 'exit')[1]; + + onExit(); + + expect(stop).toHaveBeenCalled(); + expect(previewSessionClient.revoke).not.toHaveBeenCalled(); + }); + + it('should revoke only once when called more than once', async () => { + const { preview, previewSessionClient } = await startPreview(); + + await Promise.all([preview.shutdown(), preview.shutdown()]); + + expect(previewSessionClient.revoke).toHaveBeenCalledTimes(1); + }); + + it('should not revoke when there is no session', async () => { + const { preview, previewSessionClient } = createPreview(); + + await preview.shutdown(); + + expect(previewSessionClient.revoke).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lib/PreviewSessionClient.js b/lib/PreviewSessionClient.js index 1cf62660..d11488e0 100644 --- a/lib/PreviewSessionClient.js +++ b/lib/PreviewSessionClient.js @@ -26,7 +26,7 @@ function isValidRegistration(data) { } /** - * Registers and renews Makeswift preview sessions. + * Registers, renews, and revokes Makeswift preview sessions. */ class PreviewSessionClient { constructor({ fetch = globalThis.fetch, timeout = REQUEST_TIMEOUT_MS } = {}) { @@ -52,7 +52,8 @@ class PreviewSessionClient { * @returns {Promise<{ sessionId: string, accessKey: string, expiresIn: number }>} */ async register({ storeUrl, accessToken, tunnelUrl, secret }) { - const data = await this._post({ + const data = await this._request({ + method: 'POST', storeUrl, path: SESSIONS_PATH, accessToken, @@ -75,7 +76,8 @@ class PreviewSessionClient { * @returns {Promise<{ expiresIn: number }>} */ async heartbeat({ storeUrl, accessToken, sessionId }) { - const { expiresIn } = await this._post({ + const { expiresIn } = await this._request({ + method: 'POST', storeUrl, path: `${SESSIONS_PATH}/${encodeURIComponent(sessionId)}/heartbeat`, accessToken, @@ -85,7 +87,29 @@ class PreviewSessionClient { return { expiresIn }; } - async _post({ storeUrl, path, accessToken, body, action, isValid }) { + /** + * Ends the session immediately. The worker responds 204 also when the session is already gone. + * + * @param {object} options + * @param {string} options.storeUrl + * @param {string} options.accessToken + * @param {string} options.sessionId + * @returns {Promise} + */ + async revoke({ storeUrl, accessToken, sessionId }) { + await this._request({ + method: 'DELETE', + storeUrl, + path: `${SESSIONS_PATH}/${encodeURIComponent(sessionId)}`, + accessToken, + action: 'revoke', + }); + } + + /** + * @returns {Promise} the validated JSON body, or undefined without `isValid` + */ + async _request({ method, storeUrl, path, accessToken, body, action, isValid }) { const url = new URL(path, storeUrl); if (url.protocol !== 'https:') { throw previewSessionError('Makeswift preview requires an HTTPS store URL.'); @@ -95,7 +119,7 @@ class PreviewSessionClient { let response; try { response = await this._fetch(url, { - method: 'POST', + method, headers: { ...(body && { 'content-type': 'application/json' }), [ACCESS_TOKEN_HEADER]: accessToken, @@ -117,6 +141,9 @@ class PreviewSessionClient { }); } + if (!isValid) { + return undefined; + } const data = await response.json().catch(() => null); if (!isValid(data)) { throw previewSessionError(`${failure}: the response (HTTP ${status}) is not valid.`, { diff --git a/lib/PreviewSessionClient.spec.js b/lib/PreviewSessionClient.spec.js index ff0f3a62..5e39b5cb 100644 --- a/lib/PreviewSessionClient.spec.js +++ b/lib/PreviewSessionClient.spec.js @@ -122,3 +122,48 @@ describe('PreviewSessionClient.heartbeat', () => { }); }); }); + +describe('PreviewSessionClient.revoke', () => { + const options = { + storeUrl: 'https://store.example.com', + accessToken: 'token-123', + sessionId: 'a/b', + }; + + it('deletes the session with the access token', async () => { + const fetch = jest.fn().mockResolvedValue(new Response(null, { status: 204 })); + const client = new PreviewSessionClient({ fetch }); + + await expect(client.revoke(options)).resolves.toBeUndefined(); + + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toBe('https://store.example.com/api/makeswift/preview-sessions/a%2Fb'); + expect(init).toMatchObject({ + method: 'DELETE', + headers: { 'x-stencil-access-token': 'token-123' }, + redirect: 'manual', + }); + expect(init.body).toBeUndefined(); + }); + + it.each([ + [401, false], + [503, true], + ])('rejects HTTP %s with the status', async (status, retryable) => { + const fetch = jest.fn().mockResolvedValue(new Response('{}', { status })); + const client = new PreviewSessionClient({ fetch }); + + await expect(client.revoke(options)).rejects.toMatchObject({ + message: expect.stringContaining('Could not revoke'), + status, + retryable, + }); + }); + + it('rejects a network error', async () => { + const fetch = jest.fn().mockRejectedValue(new TypeError('fetch failed')); + const client = new PreviewSessionClient({ fetch }); + + await expect(client.revoke(options)).rejects.toMatchObject({ retryable: true }); + }); +}); diff --git a/lib/TunnelManager.js b/lib/TunnelManager.js index 95e1796a..b4eae9fc 100644 --- a/lib/TunnelManager.js +++ b/lib/TunnelManager.js @@ -11,9 +11,10 @@ const INSTALL_HINT = * Starts a Cloudflare Quick Tunnel (no Cloudflare account needed) to a local port. */ class TunnelManager { - constructor({ spawn = nodeSpawn, startTimeout = 30000 } = {}) { + constructor({ spawn = nodeSpawn, startTimeout = 30000, killTimeout = 2000 } = {}) { this._spawn = spawn; this._startTimeout = startTimeout; + this._killTimeout = killTimeout; } /** @@ -40,10 +41,14 @@ class TunnelManager { let stopPromise; const stop = () => { stopPromise ??= (async () => { + let killTimer; if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); + killTimer = setTimeout(() => child.kill('SIGKILL'), this._killTimeout); + killTimer.unref(); } await exited.catch(() => {}); + clearTimeout(killTimer); })(); return stopPromise; }; diff --git a/lib/TunnelManager.spec.js b/lib/TunnelManager.spec.js index 64b46920..d1679fe4 100644 --- a/lib/TunnelManager.spec.js +++ b/lib/TunnelManager.spec.js @@ -77,4 +77,20 @@ describe('TunnelManager', () => { expect(child.kill).toHaveBeenCalledTimes(1); }); + + it('stop() kills cloudflared when it does not exit after SIGTERM', async () => { + const { child, tunnel } = startTunnel({ killTimeout: 10 }); + child.stderr.write('https://preview.trycloudflare.com'); + const { stop } = await tunnel; + child.kill.mockImplementation((signal) => { + if (signal === 'SIGKILL') { + child.signalCode = signal; + child.emit('exit', null, signal); + } + }); + + await stop(); + + expect(child.kill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]); + }); }); diff --git a/lib/stencil-start.js b/lib/stencil-start.js index 3de3bb44..80274878 100755 --- a/lib/stencil-start.js +++ b/lib/stencil-start.js @@ -16,13 +16,7 @@ import themeApiClientModule from './theme-api-client.js'; import storeSettingsApiClientModule from './store-settings-api-client.js'; import LangHelper from './lang-helper.js'; import LangValidator from './lang/validator.js'; -import makeswiftProxyHealthModule from './makeswift-proxy-health.js'; -import tunnelManagerModule from './TunnelManager.js'; -import previewSessionClientModule from './PreviewSessionClient.js'; -import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js'; -import { createPreviewProxy } from './preview-proxy.js'; - -const PREVIEW_LISTEN_HOST = 'localhost'; +import MakeswiftPreview from './MakeswiftPreview.js'; class StencilStart { constructor({ @@ -40,12 +34,7 @@ class StencilStart { stencilPushUtils = stencilPushUtilsModule, logger = console, langValidator = new LangValidator(THEME_PATH), - makeswiftProxyHealth = makeswiftProxyHealthModule, - tunnelManager = tunnelManagerModule, - previewSessionClient = previewSessionClientModule, - previewProxyFactory = createPreviewProxy, - previewSessionHeartbeatFactory = (options) => new PreviewSessionHeartbeat(options), - processObj = process, + makeswiftPreview = new MakeswiftPreview({ logger }), } = {}) { this._browserSync = browserSync; this._themeApiClient = themeApiClient; @@ -61,15 +50,7 @@ class StencilStart { this._stencilPushUtils = stencilPushUtils; this._logger = logger; this._langValidator = langValidator; - this._makeswiftProxyHealth = makeswiftProxyHealth; - this._tunnelManager = tunnelManager; - this._previewSessionClient = previewSessionClient; - this._previewProxyFactory = previewProxyFactory; - this._previewSessionHeartbeatFactory = previewSessionHeartbeatFactory; - this._process = processObj; - this._tunnel = null; - this._previewSession = null; - this._previewSessionHeartbeat = null; + this._makeswiftPreview = makeswiftPreview; } async run(cliOptions) { @@ -94,140 +75,35 @@ class StencilStart { updatedStencilConfig, ); const previewStoreUrl = channelUrl || updatedStencilConfig.storeUrl; - const makeswiftPreview = await this.isMakeswiftPreviewEnabled(cliOptions, previewStoreUrl); - if (makeswiftPreview && !updatedStencilConfig.accessToken) { - throw new Error( - 'Makeswift preview requires an access token. Add accessToken to your .stencil file.'.red, - ); - } + const makeswiftPreview = await this._makeswiftPreview.isEnabled({ + storeUrl: previewStoreUrl, + tunnel: cliOptions.tunnel, + }); if (makeswiftPreview) { - await this.startMakeswiftPreview({ + await this._makeswiftPreview.start({ localPort: browserSyncPort, rendererPort: this.getRendererPort(browserSyncPort), storeUrl: previewStoreUrl, accessToken: updatedStencilConfig.accessToken, - secret: this._previewSessionClient.createSecret(), }); } - await this.startLocalServer(cliOptions, updatedStencilConfig, this._previewSession?.secret); - this._logger.log(this.getStartUpInfo(updatedStencilConfig)); - await this.startBrowserSync(cliOptions, browserSyncPort); - } - - /** - * The registered Makeswift preview session, or null when preview is off. - * - * @returns {{ storeUrl: string, tunnelUrl: string, secret: string, sessionId: string, accessKey: string, expiresIn: number } | null} - */ - get previewSession() { - return this._previewSession; - } - - getRendererPort(browserSyncPort) { - return Number(browserSyncPort) + 1; - } - - /** - * Preview mode turns on when the storefront host is routed through the Makeswift proxy. - * A failed check never stops `stencil start`; it only turns preview mode off. - * - * @param {Object} cliOptions - * @param {string} storeUrl - * @returns {Promise} - */ - async isMakeswiftPreviewEnabled(cliOptions, storeUrl) { - const enabled = await this._makeswiftProxyHealth.isMakeswiftProxyActive({ storeUrl }); - if (enabled && cliOptions.tunnel) { - this._logger.log( - `${'Notice'.yellow}: Makeswift preview is off because --tunnel is set. ` + - 'Remove --tunnel to use Makeswift preview.', - ); - return false; - } - return enabled; - } - - /** - * Starts the Cloudflare tunnel to the renderer port (not the BrowserSync port) and registers - * 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({ 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); - } catch (err) { - this.warnPreviewUnavailable(err); - return; - } - this.registerShutdownHandlers(); try { - const tunnelUrl = this._tunnel.url; - const registration = await this._previewSessionClient.register({ - storeUrl, - accessToken, - tunnelUrl, - secret, - }); - this._previewSession = { storeUrl, tunnelUrl, secret, ...registration }; + await this.startLocalServer( + cliOptions, + updatedStencilConfig, + this._makeswiftPreview.session?.secret, + ); + this._logger.log(this.getStartUpInfo(updatedStencilConfig)); + await this.startBrowserSync(cliOptions, browserSyncPort); } catch (err) { - await this.stopMakeswiftPreview(); - this.warnPreviewUnavailable(err); - return; + // The CLI exits on this error. Revoke the session before it does. + await this._makeswiftPreview.shutdown(); + throw err; } - this._previewSessionHeartbeat = this._previewSessionHeartbeatFactory({ - client: this._previewSessionClient, - session: this._previewSession, - accessToken, - logger: this._logger, - }); - this._previewSessionHeartbeat.start(); - const localUrl = `http://${PREVIEW_LISTEN_HOST}:${localPort}`; - this._logger.log( - `Makeswift preview is active. Open ${localUrl.cyan}\n` + - 'The Makeswift preview session ends when you stop stencil start.', - ); } - warnPreviewUnavailable(err) { - this._logger.error( - `${'Warning'.yellow}: Makeswift preview is not available. ${err.message}\n` + - 'The local server continues without Makeswift preview.', - ); - } - - registerShutdownHandlers() { - let shuttingDown = false; - const onSignal = async (signal) => { - if (shuttingDown) { - this._process.exit(1); - return; - } - shuttingDown = true; - try { - await this.stopMakeswiftPreview(); - } finally { - this._process.exit(signal === 'SIGINT' ? 130 : 143); - } - }; - this._process.on('SIGINT', onSignal); - this._process.on('SIGTERM', onSignal); - // Other exits (for example process.exit after an error) must not leave cloudflared running. - this._process.once('exit', () => this.stopMakeswiftPreview()); - } - - stopMakeswiftPreview() { - this._previewSessionHeartbeat?.stop(); - this._previewSessionHeartbeat = null; - const tunnel = this._tunnel; - this._tunnel = null; - return tunnel ? tunnel.stop() : Promise.resolve(); + getRendererPort(browserSyncPort) { + return Number(browserSyncPort) + 1; } async getStoreSettingsLocale(cliOptions, stencilConfig) { @@ -374,8 +250,8 @@ class StencilStart { ignoreInitial: true, ignored: watchIgnored.map((val) => path.join(themePath, val)), }, - ...(this._previewSession - ? this.getPreviewBrowserSyncOptions() + ...(this._makeswiftPreview.session + ? this._makeswiftPreview.getBrowserSyncOptions() : { proxy: `localhost:${this.getRendererPort(browserSyncPort)}`, tunnel }), }); // Handle manual reloading of browsers by typing 'rs'; @@ -395,30 +271,6 @@ 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 36b5e50a..f2d95acc 100644 --- a/lib/stencil-start.spec.js +++ b/lib/stencil-start.spec.js @@ -44,32 +44,19 @@ describe('StencilStart unit tests', () => { log: jest.fn(), error: jest.fn(), }); - const getMakeswiftProxyHealthStub = (active = false) => ({ - isMakeswiftProxyActive: jest.fn().mockResolvedValue(active), - }); - const getTunnelManagerStub = () => ({ - start: jest.fn().mockResolvedValue({ - url: 'https://preview-123.trycloudflare.com', - stop: jest.fn().mockResolvedValue(), - }), - }); const previewSecret = 's'.repeat(64); - const registeredSession = { - sessionId: '0b6f9c3e-4d2a-4f7b-9a1c-2e3d4f5a6b7c', - accessKey: 'k'.repeat(64), - expiresIn: 300, + const getMakeswiftPreviewStub = ({ enabled = false, session = null } = {}) => { + const stub = { + session: null, + isEnabled: jest.fn().mockResolvedValue(enabled), + start: jest.fn().mockImplementation(async () => { + stub.session = session; + }), + getBrowserSyncOptions: jest.fn().mockReturnValue({ listen: 'localhost' }), + shutdown: jest.fn().mockResolvedValue(), + }; + return stub; }; - const getPreviewSessionClientStub = () => ({ - createSecret: jest.fn().mockReturnValue(previewSecret), - register: jest.fn().mockResolvedValue(registeredSession), - }); - const getHeartbeatFactoryStub = () => - jest.fn().mockReturnValue({ start: jest.fn(), stop: jest.fn() }); - const getProcessStub = () => ({ - on: jest.fn(), - once: jest.fn(), - exit: jest.fn(), - }); const getStoreSettingsApiClientStub = () => ({ getStoreSettingsLocale: jest.fn().mockResolvedValue({ default_shopper_language: 'en_US' }), }); @@ -86,12 +73,7 @@ describe('StencilStart unit tests', () => { stencilPushUtils, logger, storeSettingsApiClient, - makeswiftProxyHealth, - tunnelManager, - previewSessionClient, - previewProxyFactory, - previewSessionHeartbeatFactory, - processObj, + makeswiftPreview, } = {}) => { const passedArgs = { browserSync: browserSync || getBrowserSyncStub(), @@ -106,13 +88,7 @@ describe('StencilStart unit tests', () => { stencilPushUtils: stencilPushUtils || getStencilPushUtilsStub(), logger: logger || getLoggerStub(), storeSettingsApiClient: storeSettingsApiClient || getStoreSettingsApiClientStub(), - makeswiftProxyHealth: makeswiftProxyHealth || getMakeswiftProxyHealthStub(), - tunnelManager: tunnelManager || getTunnelManagerStub(), - previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), - previewProxyFactory: previewProxyFactory || jest.fn().mockReturnValue(jest.fn()), - previewSessionHeartbeatFactory: - previewSessionHeartbeatFactory || getHeartbeatFactoryStub(), - processObj: processObj || getProcessStub(), + makeswiftPreview: makeswiftPreview || getMakeswiftPreviewStub(), }; const instance = new StencilStart(passedArgs); return { @@ -210,191 +186,99 @@ describe('StencilStart unit tests', () => { describe('Makeswift preview', () => { const port = 3000; - const previewProxyMiddleware = jest.fn(); + const session = { storeUrl: 'https://www.example.com', secret: previewSecret }; const runStart = async ({ cliOptions = {}, - makeswiftActive = true, - tunnelManager, - previewSessionClient, + makeswiftPreview = getMakeswiftPreviewStub({ enabled: true, session }), accessToken = 'token-123', + startLocalServer = jest.fn(), } = {}) => { const deps = { browserSync: getBrowserSyncStub(), - logger: getLoggerStub(), - processObj: getProcessStub(), - makeswiftProxyHealth: getMakeswiftProxyHealthStub(makeswiftActive), - tunnelManager: tunnelManager || getTunnelManagerStub(), - previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), - previewProxyFactory: jest.fn().mockReturnValue(previewProxyMiddleware), - previewSessionHeartbeatFactory: getHeartbeatFactoryStub(), + makeswiftPreview, stencilConfigManager: getStencilConfigManagerStub({ port, accessToken }), }; const { instance } = createStencilStartInstance(deps); - instance.startLocalServer = jest.fn(); + instance.startLocalServer = startLocalServer; instance.getStartUpInfo = jest.fn().mockReturnValue('Start up info'); instance.checkLangFiles = jest.fn(); await instance.run({ channelId: 5, ...cliOptions }); - const output = [...deps.logger.log.mock.calls, ...deps.logger.error.mock.calls] - .flat() - .join('\n'); - return { ...deps, instance, output }; + return { ...deps, instance }; }; - const signalHandler = (processObj, signal) => - processObj.on.mock.calls.find(([name]) => name === signal)[1]; - 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({ + it('should check the storefront URL and the --tunnel option', async () => { + const { makeswiftPreview } = await runStart({ cliOptions: { tunnel: 'name' } }); + expect(makeswiftPreview.isEnabled).toHaveBeenCalledWith({ storeUrl: 'https://www.example.com', + tunnel: 'name', }); - expect(tunnelManager.start).toHaveBeenCalledWith(port + 1); - 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); }); - 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, + it('should start preview before the renderer and require the session secret', async () => { + const { makeswiftPreview, instance } = await runStart(); + expect(makeswiftPreview.start).toHaveBeenCalledWith({ + localPort: port, + rendererPort: port + 1, + storeUrl: 'https://www.example.com', + accessToken: 'token-123', }); + expect(makeswiftPreview.start.mock.invocationCallOrder[0]).toBeLessThan( + instance.startLocalServer.mock.invocationCallOrder[0], + ); + expect(instance.startLocalServer).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + previewSecret, + ); + }); + + it('should use the preview BrowserSync options when a session exists', async () => { + const { browserSync } = await runStart({ cliOptions: { open: true } }); const options = browserSync.init.mock.calls[0][0]; expect(options).toEqual( - expect.objectContaining({ - open: true, - port, - listen: 'localhost', - server: { baseDir: [] }, - middleware: [previewProxyMiddleware], - tunnel: false, - }), + expect.objectContaining({ open: true, port, listen: 'localhost' }), ); 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 makeswiftPreview = getMakeswiftPreviewStub(); + const { browserSync, instance } = await runStart({ makeswiftPreview }); + expect(makeswiftPreview.start).not.toHaveBeenCalled(); + expect(makeswiftPreview.getBrowserSyncOptions).not.toHaveBeenCalled(); + expect(instance.startLocalServer.mock.calls[0][2]).toBeUndefined(); 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 () => { - const { tunnelManager, processObj, output } = await runStart({ - makeswiftActive: false, - }); - expect(tunnelManager.start).not.toHaveBeenCalled(); - expect(processObj.on).not.toHaveBeenCalled(); - expect(output).not.toContain('Makeswift'); }); - it('should not start a Makeswift tunnel when --tunnel is set', async () => { - const { tunnelManager, output } = await runStart({ cliOptions: { tunnel: true } }); - expect(tunnelManager.start).not.toHaveBeenCalled(); - expect(output).toContain('Makeswift preview is off because --tunnel is set'); - }); - - it('should continue without Makeswift preview when the tunnel fails', async () => { - const tunnelManager = { - start: jest.fn().mockRejectedValue(new Error('cloudflared was not found.')), - }; - const { browserSync, processObj, output } = await runStart({ tunnelManager }); - expect(output).toContain( - 'Makeswift preview is not available. cloudflared was not found.', - ); - expect(processObj.on).not.toHaveBeenCalled(); - expect(browserSync.init).toHaveBeenCalled(); - }); - - it('should register the session and require its secret on the renderer', async () => { - const { previewSessionClient, instance } = await runStart(); - expect(previewSessionClient.register).toHaveBeenCalledWith({ - storeUrl: 'https://www.example.com', - accessToken: 'token-123', - tunnelUrl: 'https://preview-123.trycloudflare.com', - secret: previewSecret, - }); - expect(instance.previewSession).toEqual({ - storeUrl: 'https://www.example.com', - tunnelUrl: 'https://preview-123.trycloudflare.com', - secret: previewSecret, - ...registeredSession, - }); - expect(instance.startLocalServer).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - previewSecret, - ); - }); - - it('should keep the registered session alive with a heartbeat', async () => { - const { - previewSessionHeartbeatFactory, - previewSessionClient, - logger, - instance, - } = await runStart(); - expect(previewSessionHeartbeatFactory).toHaveBeenCalledWith({ - client: previewSessionClient, - session: instance.previewSession, - accessToken: 'token-123', - logger, - }); - expect(previewSessionHeartbeatFactory.mock.results[0].value.start).toHaveBeenCalled(); - }); - - it('should stop the tunnel and continue without preview when registration fails', async () => { - const previewSessionClient = getPreviewSessionClientStub(); - previewSessionClient.register.mockRejectedValue( - new Error('Could not register the Makeswift preview session (HTTP 401).'), - ); - const { - tunnelManager, - browserSync, - instance, - output, - previewSessionHeartbeatFactory, - } = await runStart({ previewSessionClient }); - const { stop } = await tunnelManager.start.mock.results[0].value; - expect(stop).toHaveBeenCalled(); - expect(previewSessionHeartbeatFactory).not.toHaveBeenCalled(); - expect(instance.previewSession).toBeNull(); + it('should proxy BrowserSync to the renderer when preview start gives no session', async () => { + const makeswiftPreview = getMakeswiftPreviewStub({ enabled: true, session: null }); + const { browserSync, instance } = await runStart({ makeswiftPreview }); + expect(makeswiftPreview.start).toHaveBeenCalled(); expect(instance.startLocalServer.mock.calls[0][2]).toBeUndefined(); - expect(output).toContain('Makeswift preview is not available. Could not register'); - expect(browserSync.init).toHaveBeenCalled(); + expect(browserSync.init.mock.calls[0][0]).toHaveProperty('proxy'); }); - it('should fail to start when .stencil has no access token', async () => { - const tunnelManager = getTunnelManagerStub(); - await expect(runStart({ tunnelManager, accessToken: null })).rejects.toThrow( - 'Makeswift preview requires an access token', + it('should fail to start when preview start fails', async () => { + const makeswiftPreview = getMakeswiftPreviewStub({ enabled: true }); + makeswiftPreview.start.mockRejectedValue(new Error('requires an access token')); + await expect(runStart({ makeswiftPreview })).rejects.toThrow( + 'requires an access token', ); - expect(tunnelManager.start).not.toHaveBeenCalled(); }); - it('should stop the heartbeat and tunnel and exit on SIGINT, and exit at once on a second signal', async () => { - const { tunnelManager, processObj, previewSessionHeartbeatFactory } = await runStart(); - const { stop } = await tunnelManager.start.mock.results[0].value; - - await signalHandler(processObj, 'SIGINT')('SIGINT'); - expect(previewSessionHeartbeatFactory.mock.results[0].value.stop).toHaveBeenCalled(); - expect(stop).toHaveBeenCalledTimes(1); - expect(processObj.exit).toHaveBeenCalledWith(130); - - await signalHandler(processObj, 'SIGTERM')('SIGTERM'); - expect(stop).toHaveBeenCalledTimes(1); - expect(processObj.exit).toHaveBeenLastCalledWith(1); + it('should shut down preview when start fails after registration', async () => { + const makeswiftPreview = getMakeswiftPreviewStub({ enabled: true, session }); + await expect( + runStart({ + makeswiftPreview, + startLocalServer: jest.fn().mockRejectedValue(new Error('port in use')), + }), + ).rejects.toThrow('port in use'); + expect(makeswiftPreview.shutdown).toHaveBeenCalledTimes(1); }); });