From 31f7ccbb15235485ebb1eb3a9a5da54933d15522 Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Thu, 8 Oct 2026 13:46:29 -0500 Subject: [PATCH 1/3] TRAC-2008: feat - Return Makeswift page identity headers from the local renderer --- .../renderer/responses/pencil-response.js | 14 +++++++++++ .../responses/pencil-response.spec.js | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/server/plugins/renderer/responses/pencil-response.js b/server/plugins/renderer/responses/pencil-response.js index c204834b..8cd0a18a 100644 --- a/server/plugins/renderer/responses/pencil-response.js +++ b/server/plugins/renderer/responses/pencil-response.js @@ -56,6 +56,15 @@ const isSupportedHandlebarsVersion = (version) => * @param {string} version */ const compatibilizeTemplateEngine = (version) => version.replace('_', '-'); +/** + * Storefront headers that the Makeswift worker reads from the rendered page to find its + * Makeswift content. + */ +const MAKESWIFT_PAGE_HEADERS = [ + 'x-makeswift-stable-page-reference', + 'x-makeswift-page-locale', + 'x-makeswift-storefront-token', +]; /** * Output post-processing * @@ -134,6 +143,11 @@ class PencilResponse { if (this.data.headers['set-cookie']) { response.header('set-cookie', this.data.headers['set-cookie']); } + for (const name of MAKESWIFT_PAGE_HEADERS) { + if (this.data.headers[name]) { + response.header(name, this.data.headers[name]); + } + } return response; } catch (err) { console.error(err.message.red); diff --git a/server/plugins/renderer/responses/pencil-response.spec.js b/server/plugins/renderer/responses/pencil-response.spec.js index 77470f4f..63e4a672 100644 --- a/server/plugins/renderer/responses/pencil-response.spec.js +++ b/server/plugins/renderer/responses/pencil-response.spec.js @@ -1,4 +1,5 @@ import { jest } from '@jest/globals'; +import * as Hapi from '@hapi/hapi'; import path from 'path'; import { promisify } from 'util'; import PencilResponse from './pencil-response.js'; @@ -73,6 +74,29 @@ describe('PencilResponse', () => { expect(h.response).toHaveBeenCalledTimes(1); }); + it('should return the Makeswift page headers from the storefront response', async () => { + data.statusCode = 200; + data.headers = { + 'x-makeswift-stable-page-reference': 'product-12', + 'x-makeswift-page-locale': 'fr', + 'x-makeswift-storefront-token': 'token', + 'x-frame-options': 'DENY', + }; + const server = Hapi.server(); + server.route({ + method: 'GET', + path: '/', + handler: (req, toolkit) => new PencilResponse(data, assembler).respond(req, toolkit), + }); + const { headers } = await server.inject('/'); + expect(headers).toMatchObject({ + 'x-makeswift-stable-page-reference': 'product-12', + 'x-makeswift-page-locale': 'fr', + 'x-makeswift-storefront-token': 'token', + }); + expect(headers).not.toHaveProperty('x-frame-options'); + }); + describe('it should successfully render a tempalte with dynamic partials', () => { it('should render a template with dynamic partials', async () => { let result = ''; From 6c491e66e6ea6bbf66c750a17aa906c42f231cf8 Mon Sep 17 00:00:00 2001 From: Parth Shah <48393781+parthshahp@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:00:06 -0400 Subject: [PATCH 2/3] feat: automatically install cloudflared for Makeswift preview (#1420) * feat: automatically install cloudflared for Makeswift preview * fix: stop Makeswift preview when cloudflared exits unexpectedly --- README.md | 12 ++ lib/MakeswiftPreview.js | 21 +++ lib/MakeswiftPreview.spec.js | 79 +++++++++- lib/TunnelManager.js | 59 ++++--- lib/TunnelManager.spec.js | 64 ++++++-- lib/cloudflared-binary.js | 266 ++++++++++++++++++++++++++++++++ lib/cloudflared-binary.spec.js | 270 +++++++++++++++++++++++++++++++++ lib/cloudflared-manifest.js | 41 +++++ package-lock.json | 1 + package.json | 1 + 10 files changed, 783 insertions(+), 31 deletions(-) create mode 100644 lib/cloudflared-binary.js create mode 100644 lib/cloudflared-binary.spec.js create mode 100644 lib/cloudflared-manifest.js diff --git a/README.md b/README.md index 7fb9371d..14721693 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,18 @@ Run with `-o` or `--open` to automatically open up a browser. - While stencil is running, you can type "rs" and then hit enter to auto-reload all browsers. This is similar to Nodemon's rs option. +#### Makeswift preview + +If Makeswift is enabled for the storefront, `stencil start` opens a Cloudflare quick tunnel with +[cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/). +If `cloudflared` is not on your PATH (or is too old), Stencil CLI asks if it can download a pinned, checksum-verified +release. If you agree, it caches the release in `/stencil-cli/cloudflared//`. If you decline, or +the terminal is not interactive, `stencil start` continues without Makeswift preview. The cache dir is `~/Library/Caches` on +macOS, `%LOCALAPPDATA%` on Windows, and `$XDG_CACHE_HOME` or `~/.cache` on other systems. + +To use your own binary and turn off the download (for example, offline), set `STENCIL_CLOUDFLARED_PATH` to the path +of `cloudflared`. + Run `stencil bundle` to validate your code and create a zip bundle file that can be uploaded to BigCommerce. Run `stencil release` to tag a new version of your theme, create a [GitHub release](https://help.github.com/articles/about-releases/) diff --git a/lib/MakeswiftPreview.js b/lib/MakeswiftPreview.js index 45231b90..bc3c8d89 100644 --- a/lib/MakeswiftPreview.js +++ b/lib/MakeswiftPreview.js @@ -120,6 +120,8 @@ class MakeswiftPreview { logger: this._logger, }); this._heartbeat.start(); + // Watch only after the session exists, so an exit during registration also revokes it. + this._watchTunnel(this._tunnel); const localUrl = `http://${PREVIEW_LISTEN_HOST}:${localPort}`; this._logger.log( `Makeswift preview is active. Open ${localUrl.cyan}\n` + @@ -221,6 +223,25 @@ class MakeswiftPreview { this._process.once('exit', () => this.stop()); } + /** + * If cloudflared exits while preview is active, the tunnel URL is dead. Stop the heartbeat and + * revoke the session, so the session does not stay alive for a tunnel that is gone. + */ + _watchTunnel(tunnel) { + tunnel.exited.then(async ({ reason, output }) => { + // stop() clears this._tunnel first, so an exit that we started is not an error. + if (this._tunnel !== tunnel) { + return; + } + this._logger.error( + `${'Error'.red}: cloudflared stopped unexpectedly (${reason}). ` + + 'Makeswift preview no longer works. Restart stencil start.' + + (output ? `\ncloudflared output:\n${output}` : ''), + ); + await this.shutdown(); + }); + } + _warnUnavailable(err) { this._logger.error( `${'Warning'.yellow}: Makeswift preview is not available. ${err.message}\n` + diff --git a/lib/MakeswiftPreview.spec.js b/lib/MakeswiftPreview.spec.js index 972d0896..7ffe2e99 100644 --- a/lib/MakeswiftPreview.spec.js +++ b/lib/MakeswiftPreview.spec.js @@ -15,9 +15,22 @@ describe('MakeswiftPreview', () => { }; const previewProxyMiddleware = jest.fn(); - const getTunnelManagerStub = () => ({ - start: jest.fn().mockResolvedValue({ url: tunnelUrl, stop: jest.fn().mockResolvedValue() }), - }); + // exit() resolves the tunnel's `exited` promise, like cloudflared exiting. + const getTunnelManagerStub = () => { + let exit; + const exited = new Promise((resolve) => { + exit = resolve; + }); + return { + start: jest.fn().mockResolvedValue({ + url: tunnelUrl, + stop: jest.fn().mockResolvedValue(), + exited, + }), + exit: (info) => exit(info), + }; + }; + const flush = () => new Promise(setImmediate); const getPreviewSessionClientStub = () => ({ createSecret: jest.fn().mockReturnValue(previewSecret), register: jest.fn().mockResolvedValue(registeredSession), @@ -165,6 +178,66 @@ describe('MakeswiftPreview', () => { }); }); + describe('when cloudflared exits', () => { + it('should stop the heartbeat, revoke the session, and show an error', async () => { + const { + tunnelManager, + previewSessionClient, + previewSessionHeartbeatFactory, + output, + } = await startPreview(); + + tunnelManager.exit({ reason: 'code 1', output: 'ERR connection lost' }); + await flush(); + + expect(previewSessionHeartbeatFactory.mock.results[0].value.stop).toHaveBeenCalled(); + expect(previewSessionClient.revoke).toHaveBeenCalledWith({ + storeUrl, + accessToken, + sessionId: registeredSession.sessionId, + }); + expect(output()).toContain('cloudflared stopped unexpectedly (code 1)'); + expect(output()).toContain('cloudflared output:\nERR connection lost'); + }); + + it('should revoke the session when cloudflared exits during registration', async () => { + const tunnelManager = getTunnelManagerStub(); + const previewSessionClient = getPreviewSessionClientStub(); + previewSessionClient.register.mockImplementation(async () => { + tunnelManager.exit({ reason: 'SIGKILL', output: '' }); + await flush(); + return registeredSession; + }); + + await startPreview({ tunnelManager, previewSessionClient }); + await flush(); + + expect(previewSessionClient.revoke).toHaveBeenCalledTimes(1); + }); + + it('should not show an error or revoke again when the exit comes from shutdown', async () => { + const { preview, tunnelManager, previewSessionClient, logger } = await startPreview(); + + await preview.shutdown(); + tunnelManager.exit({ reason: 'SIGTERM', output: '' }); + await flush(); + + expect(previewSessionClient.revoke).toHaveBeenCalledTimes(1); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should exit at once on a signal after cloudflared exited', async () => { + const { tunnelManager, processObj, previewSessionClient } = await startPreview(); + tunnelManager.exit({ reason: 'code 1', output: '' }); + await flush(); + + await signalHandler(processObj, 'SIGINT')('SIGINT'); + + expect(previewSessionClient.revoke).toHaveBeenCalledTimes(1); + expect(processObj.exit).toHaveBeenCalledWith(130); + }); + }); + describe('getBrowserSyncOptions', () => { it('should serve a signing proxy to the storefront on loopback only', async () => { const { preview, previewProxyFactory, logger } = await startPreview(); diff --git a/lib/TunnelManager.js b/lib/TunnelManager.js index b4eae9fc..19448c08 100644 --- a/lib/TunnelManager.js +++ b/lib/TunnelManager.js @@ -1,29 +1,40 @@ import { spawn as nodeSpawn } from 'child_process'; import { once } from 'events'; +import cloudflaredBinaryModule, { INSTALL_URL, PATH_ENV } from './cloudflared-binary.js'; -// TODO: Automatically install cloudflared for Makeswift users const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/; -const INSTALL_HINT = - 'Install cloudflared and make sure it is on your PATH: ' + - 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'; +const INSTALL_HINT = `Install cloudflared, or set ${PATH_ENV} to the path of cloudflared: ${INSTALL_URL}`; +// Keep only the end of the cloudflared log, for error messages. +const LOG_TAIL_LENGTH = 16 * 1024; +const LOG_TAIL_LINES = 5; /** * Starts a Cloudflare Quick Tunnel (no Cloudflare account needed) to a local port. */ class TunnelManager { - constructor({ spawn = nodeSpawn, startTimeout = 30000, killTimeout = 2000 } = {}) { + constructor({ + spawn = nodeSpawn, + cloudflaredBinary = cloudflaredBinaryModule, + startTimeout = 30000, + killTimeout = 2000, + } = {}) { this._spawn = spawn; + this._cloudflaredBinary = cloudflaredBinary; this._startTimeout = startTimeout; this._killTimeout = killTimeout; } /** + * `exited` resolves when cloudflared exits for any reason, including stop(). It does not + * reject. `reason` is the exit code or signal; `output` is the last lines of the log. + * * @param {number} port - * @returns {Promise<{ url: string, stop: () => Promise }>} + * @returns {Promise<{ url: string, stop: () => Promise, exited: Promise<{ reason: string, output: string }> }>} */ async start(port) { + const binaryPath = await this._cloudflaredBinary.resolve(); const child = this._spawn( - 'cloudflared', + binaryPath, // --grace-period: on SIGTERM, exit after 1s instead of the default 30s. [ 'tunnel', @@ -38,6 +49,17 @@ class TunnelManager { ); // Rejects on a spawn error, for example ENOENT when cloudflared is not installed. const exited = once(child, 'exit'); + let log = ''; + const onLog = (chunk) => { + log = (log + chunk).slice(-LOG_TAIL_LENGTH); + }; + // This listener also keeps reading stderr, so cloudflared does not block on a full pipe. + child.stderr.on('data', onLog); + const lastLines = () => log.trim().split('\n').slice(-LOG_TAIL_LINES).join('\n'); + const exitInfo = exited.then( + ([code, signal]) => ({ reason: signal || `code ${code}`, output: lastLines() }), + (err) => ({ reason: err.message, output: lastLines() }), + ); let stopPromise; const stop = () => { stopPromise ??= (async () => { @@ -53,19 +75,18 @@ class TunnelManager { return stopPromise; }; - let log = ''; - let onLog; + let onUrl; let timer; try { const url = await new Promise((resolve, reject) => { - onLog = (chunk) => { - log += chunk; + onUrl = () => { const match = log.match(TUNNEL_URL_PATTERN); if (match) { resolve(match[0]); } }; - child.stderr.on('data', onLog); + // Added after onLog, so `log` already has the chunk. + child.stderr.on('data', onUrl); exited.then( ([code, signal]) => reject( @@ -78,7 +99,9 @@ class TunnelManager { (err) => reject( err.code === 'ENOENT' - ? new Error(`cloudflared was not found. ${INSTALL_HINT}`) + ? new Error( + `cloudflared was not found at ${binaryPath}. ${INSTALL_HINT}`, + ) : err, ), ); @@ -94,17 +117,15 @@ class TunnelManager { this._startTimeout, ); }); - return { url, stop }; + return { url, stop, exited: exitInfo }; } catch (err) { await stop(); - const lastLines = log.trim().split('\n').slice(-5).join('\n'); - err.message += lastLines ? `\ncloudflared output:\n${lastLines}` : ''; + const output = lastLines(); + err.message += output ? `\ncloudflared output:\n${output}` : ''; throw err; } finally { clearTimeout(timer); - child.stderr.off('data', onLog); - // Keep reading stderr so cloudflared does not block on a full pipe. - child.stderr.resume(); + child.stderr.off('data', onUrl); } } } diff --git a/lib/TunnelManager.spec.js b/lib/TunnelManager.spec.js index d1679fe4..d866115a 100644 --- a/lib/TunnelManager.spec.js +++ b/lib/TunnelManager.spec.js @@ -15,16 +15,21 @@ const createChild = () => { return child; }; -const startTunnel = (options) => { +const BINARY_PATH = '/cache/cloudflared'; + +// Resolves after start() spawns cloudflared, so tests can emit events on the child. +const startTunnel = async (options) => { const child = createChild(); const spawn = jest.fn().mockReturnValue(child); - const tunnel = new TunnelManager({ spawn, ...options }).start(3001); + const cloudflaredBinary = { resolve: jest.fn().mockResolvedValue(BINARY_PATH) }; + const tunnel = new TunnelManager({ spawn, cloudflaredBinary, ...options }).start(3001); + await new Promise(setImmediate); return { child, spawn, tunnel }; }; describe('TunnelManager', () => { it('starts cloudflared on the port and returns the URL from its log', async () => { - const { child, spawn, tunnel } = startTunnel(); + const { child, spawn, tunnel } = await startTunnel(); child.stderr.write('INF Requesting new quick Tunnel...\nINF | https://some-'); child.stderr.write('words.trycloudflare.com |\n'); @@ -32,24 +37,39 @@ describe('TunnelManager', () => { await expect(tunnel).resolves.toMatchObject({ url: 'https://some-words.trycloudflare.com', }); + expect(spawn.mock.calls[0][0]).toBe(BINARY_PATH); expect(spawn.mock.calls[0][1]).toEqual( expect.arrayContaining(['tunnel', '--url', 'http://localhost:3001']), ); }); it('reports a missing cloudflared binary', async () => { - const { child, tunnel } = startTunnel(); + const { child, tunnel } = await startTunnel(); child.emit( 'error', Object.assign(new Error('spawn cloudflared ENOENT'), { code: 'ENOENT' }), ); - await expect(tunnel).rejects.toThrow('cloudflared was not found. Install cloudflared'); + await expect(tunnel).rejects.toThrow( + `cloudflared was not found at ${BINARY_PATH}. Install cloudflared, or set STENCIL_CLOUDFLARED_PATH`, + ); + }); + + it('rejects without spawning when cloudflared cannot be resolved', async () => { + const spawn = jest.fn(); + const cloudflaredBinary = { + resolve: jest.fn().mockRejectedValue(new Error('Could not install cloudflared')), + }; + + await expect(new TunnelManager({ spawn, cloudflaredBinary }).start(3001)).rejects.toThrow( + 'Could not install cloudflared', + ); + expect(spawn).not.toHaveBeenCalled(); }); it('reports an early exit with the cloudflared output', async () => { - const { child, tunnel } = startTunnel(); + const { child, tunnel } = await startTunnel(); child.stderr.write('ERR failed to request quick Tunnel\n'); await new Promise(setImmediate); @@ -61,14 +81,14 @@ describe('TunnelManager', () => { }); it('stops cloudflared when no URL comes before the timeout', async () => { - const { child, tunnel } = startTunnel({ startTimeout: 10 }); + const { child, tunnel } = await startTunnel({ startTimeout: 10 }); await expect(tunnel).rejects.toThrow('did not return a tunnel URL after 0.01s'); expect(child.kill).toHaveBeenCalledWith('SIGTERM'); }); it('stop() stops cloudflared once and can be called again', async () => { - const { child, tunnel } = startTunnel(); + const { child, tunnel } = await startTunnel(); child.stderr.write('https://preview.trycloudflare.com'); const { stop } = await tunnel; @@ -78,8 +98,34 @@ describe('TunnelManager', () => { expect(child.kill).toHaveBeenCalledTimes(1); }); + it('exited resolves with the reason and the end of the log when cloudflared exits', async () => { + const { child, tunnel } = await startTunnel(); + child.stderr.write('INF | https://preview.trycloudflare.com |\n'); + const { exited } = await tunnel; + + child.stderr.write('ERR connection lost\n'); + await new Promise(setImmediate); + child.exitCode = 1; + child.emit('exit', 1, null); + + await expect(exited).resolves.toEqual({ + reason: 'code 1', + output: expect.stringMatching(/trycloudflare\.com.*\n.*ERR connection lost$/), + }); + }); + + it('exited resolves after stop()', async () => { + const { child, tunnel } = await startTunnel(); + child.stderr.write('https://preview.trycloudflare.com'); + const { stop, exited } = await tunnel; + + await stop(); + + await expect(exited).resolves.toMatchObject({ reason: 'SIGTERM' }); + }); + it('stop() kills cloudflared when it does not exit after SIGTERM', async () => { - const { child, tunnel } = startTunnel({ killTimeout: 10 }); + const { child, tunnel } = await startTunnel({ killTimeout: 10 }); child.stderr.write('https://preview.trycloudflare.com'); const { stop } = await tunnel; child.kill.mockImplementation((signal) => { diff --git a/lib/cloudflared-binary.js b/lib/cloudflared-binary.js new file mode 100644 index 00000000..8dc4af9f --- /dev/null +++ b/lib/cloudflared-binary.js @@ -0,0 +1,266 @@ +import 'colors'; +import { execFile as nodeExecFile } from 'child_process'; +import crypto from 'crypto'; +import fsModule from 'fs'; +import os from 'os'; +import path from 'path'; +import { Transform } from 'stream'; +import { pipeline } from 'stream/promises'; +import { promisify } from 'util'; +import axios from 'axios'; +import inquirerModule from 'inquirer'; +import ProgressBar from 'progress'; +import semver from 'semver'; +import * as tar from 'tar'; +import CLOUDFLARED_MANIFEST from './cloudflared-manifest.js'; + +// Cloudflare supports cloudflared releases for about one year. Raise this when bumping the pin. +const MIN_VERSION = '2025.10.0'; +const PATH_ENV = 'STENCIL_CLOUDFLARED_PATH'; +const DOWNLOAD_BASE_URL = 'https://github.com/cloudflare/cloudflared/releases/download'; +const INSTALL_URL = + 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'; + +/** + * Finds a cloudflared binary for Makeswift preview. Downloads a pinned release on first use. + */ +class CloudflaredBinary { + constructor({ + fs = fsModule, + httpClient = axios, + execFile = promisify(nodeExecFile), + platform = process.platform, + arch = process.arch, + env = process.env, + homedir = os.homedir, + logger = console, + inquirer = inquirerModule, + stdin = process.stdin, + progressStream = process.stderr, + manifest = CLOUDFLARED_MANIFEST, + minVersion = MIN_VERSION, + } = {}) { + this._fs = fs; + this._httpClient = httpClient; + this._execFile = execFile; + this._platform = platform; + this._arch = arch; + this._env = env; + this._homedir = homedir; + this._logger = logger; + this._inquirer = inquirer; + this._stdin = stdin; + this._progressStream = progressStream; + this._manifest = manifest; + this._minVersion = minVersion; + this._resolvePromise = null; + } + + /** + * Concurrent calls share one lookup and one download. + * + * @returns {Promise} absolute path or command name for cloudflared + */ + resolve() { + this._resolvePromise ??= this._resolve().catch((err) => { + // Let a later call try again. + this._resolvePromise = null; + throw err; + }); + return this._resolvePromise; + } + + async _resolve() { + const override = this._env[PATH_ENV]; + if (override) { + return override; + } + if (await this._isPathBinaryUsable()) { + return 'cloudflared'; + } + const asset = this._manifest.assets[`${this._platform}-${this._arch}`]; + if (!asset) { + throw new Error( + `cloudflared is not available for ${this._platform}/${this._arch}. ` + + `Install cloudflared and set ${PATH_ENV} to its path: ${INSTALL_URL}`, + ); + } + const binaryPath = path.join( + this._getCacheDir(), + 'stencil-cli', + 'cloudflared', + this._manifest.version, + this._platform === 'win32' ? 'cloudflared.exe' : 'cloudflared', + ); + if (await this._isFile(binaryPath)) { + return binaryPath; + } + await this._confirmDownload(path.dirname(binaryPath)); + await this._download(asset, binaryPath); + return binaryPath; + } + + async _isPathBinaryUsable() { + let output; + try { + const { stdout, stderr } = await this._execFile('cloudflared', ['--version'], { + timeout: 10000, + }); + output = `${stdout}${stderr}`; + } catch { + return false; + } + const match = output.match(/cloudflared version (\S+)/); + const version = match && semver.coerce(match[1]); + if (version && semver.gte(version, this._minVersion)) { + return true; + } + const reason = version + ? `is version ${match[1]}, older than ${this._minVersion}` + : 'has an unknown version'; + this._logger.log( + `${'Notice'.yellow}: cloudflared on your PATH ${reason}. ` + + `Using cloudflared ${this._manifest.version} managed by stencil-cli.`, + ); + return false; + } + + /** + * Asks the user before the download. Without a TTY there is nobody to ask, so do not download. + */ + async _confirmDownload(dir) { + const { version } = this._manifest; + const notInstalled = `cloudflared was not found. Install cloudflared, or set ${PATH_ENV} to the path of cloudflared: ${INSTALL_URL}`; + if (!this._stdin?.isTTY) { + throw new Error(notInstalled); + } + const { install } = await this._inquirer.prompt([ + { + type: 'confirm', + name: 'install', + message: + `Makeswift preview needs cloudflared. Download cloudflared ${version} ` + + `from GitHub to ${dir}?`, + default: true, + }, + ]); + if (!install) { + throw new Error(notInstalled); + } + } + + _getCacheDir() { + const home = this._homedir(); + if (this._platform === 'darwin') { + return path.join(home, 'Library', 'Caches'); + } + if (this._platform === 'win32') { + return this._env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + } + return this._env.XDG_CACHE_HOME || path.join(home, '.cache'); + } + + async _isFile(filePath) { + try { + return (await this._fs.promises.stat(filePath)).isFile(); + } catch { + return false; + } + } + + async _download(asset, binaryPath) { + const { version } = this._manifest; + const url = `${DOWNLOAD_BASE_URL}/${version}/${asset.name}`; + const dir = path.dirname(binaryPath); + // Unique temp names, so concurrent `stencil start` runs do not collide. + const suffix = `${process.pid}-${crypto.randomBytes(4).toString('hex')}`; + const tmpAsset = path.join(dir, `.${asset.name}.${suffix}.download`); + const tmpExtractDir = path.join(dir, `.extract.${suffix}`); + const tmpBinary = path.join(dir, `.${path.basename(binaryPath)}.${suffix}.tmp`); + + this._logger.log(`Downloading cloudflared ${version} (first run only)...`); + try { + await this._fs.promises.mkdir(dir, { recursive: true }); + const digest = await this._fetch(url, tmpAsset); + if (digest !== asset.sha256) { + throw new Error( + `The cloudflared download from ${url} failed the checksum check ` + + `(expected sha256 ${asset.sha256}, got ${digest}).`, + ); + } + if (asset.name.endsWith('.tgz')) { + await this._extract(tmpAsset, tmpExtractDir, tmpBinary); + } else { + await this._fs.promises.rename(tmpAsset, tmpBinary); + } + if (this._platform !== 'win32') { + await this._fs.promises.chmod(tmpBinary, 0o755); + } + try { + await this._fs.promises.rename(tmpBinary, binaryPath); + } catch (err) { + // Another run can install it first. On Windows, rename fails if that file is in use. + if (!(await this._isFile(binaryPath))) { + throw err; + } + } + } catch (err) { + err.message = + `Could not install cloudflared ${version}. ${err.message} ` + + `To use your own cloudflared, set ${PATH_ENV} to its path.`; + throw err; + } finally { + await Promise.all( + [tmpAsset, tmpExtractDir, tmpBinary].map((p) => + this._fs.promises.rm(p, { recursive: true, force: true }).catch(() => {}), + ), + ); + } + } + + /** + * Streams the URL to a file and returns the sha256 hex digest. + */ + async _fetch(url, filePath) { + const response = await this._httpClient.get(url, { + responseType: 'stream', + timeout: 60000, + }); + const hash = crypto.createHash('sha256'); + const total = Number(response.headers?.['content-length']); + const bar = + this._progressStream?.isTTY && total > 0 + ? new ProgressBar('cloudflared [:bar] :percent', { + total, + width: 30, + stream: this._progressStream, + }) + : null; + const hasher = new Transform({ + transform(chunk, encoding, callback) { + hash.update(chunk); + bar?.tick(chunk.length); + callback(null, chunk); + }, + }); + await pipeline(response.data, hasher, this._fs.createWriteStream(filePath)); + return hash.digest('hex'); + } + + async _extract(archivePath, extractDir, binaryPath) { + await this._fs.promises.mkdir(extractDir, { recursive: true }); + await tar.x({ + file: archivePath, + cwd: extractDir, + filter: (entryPath) => entryPath.replace(/^\.\//, '') === 'cloudflared', + }); + const extracted = path.join(extractDir, 'cloudflared'); + if (!(await this._isFile(extracted))) { + throw new Error('The cloudflared archive does not contain a cloudflared file.'); + } + await this._fs.promises.rename(extracted, binaryPath); + } +} + +export { CloudflaredBinary, INSTALL_URL, MIN_VERSION, PATH_ENV }; +export default new CloudflaredBinary(); diff --git a/lib/cloudflared-binary.spec.js b/lib/cloudflared-binary.spec.js new file mode 100644 index 00000000..e34f9220 --- /dev/null +++ b/lib/cloudflared-binary.spec.js @@ -0,0 +1,270 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { Readable } from 'stream'; +import { jest } from '@jest/globals'; +import * as tar from 'tar'; +import tmp from 'tmp-promise'; +import { CloudflaredBinary, INSTALL_URL } from './cloudflared-binary.js'; + +const VERSION = '2026.10.0'; +const BINARY = Buffer.from('#!/bin/sh\necho fake cloudflared\n'); +const sha256 = (buffer) => crypto.createHash('sha256').update(buffer).digest('hex'); +const enoent = () => Object.assign(new Error('spawn cloudflared ENOENT'), { code: 'ENOENT' }); + +describe('CloudflaredBinary', () => { + let tmpDir; + let home; + + beforeEach(async () => { + tmpDir = await tmp.dir({ unsafeCleanup: true }); + home = tmpDir.path; + }); + + afterEach(async () => { + await tmpDir.cleanup(); + }); + + const linuxDir = () => path.join(home, '.cache', 'stencil-cli', 'cloudflared', VERSION); + + const create = ({ asset = BINARY, manifest, ...options } = {}) => { + const httpClient = { + get: jest.fn(async () => ({ + data: Readable.from([asset]), + headers: { 'content-length': String(asset.length) }, + })), + }; + const logger = { log: jest.fn() }; + const execFile = jest.fn().mockRejectedValue(enoent()); + const inquirer = { prompt: jest.fn().mockResolvedValue({ install: true }) }; + const binary = new CloudflaredBinary({ + httpClient, + execFile, + logger, + inquirer, + stdin: { isTTY: true }, + platform: 'linux', + arch: 'x64', + env: {}, + homedir: () => home, + progressStream: { isTTY: false }, + manifest: manifest ?? { + version: VERSION, + assets: { 'linux-x64': { name: 'cloudflared-linux-amd64', sha256: sha256(asset) } }, + }, + ...options, + }); + return { binary, httpClient, logger, execFile, inquirer }; + }; + + const writeCached = async (dir = linuxDir()) => { + await fs.promises.mkdir(dir, { recursive: true }); + const file = path.join(dir, 'cloudflared'); + await fs.promises.writeFile(file, BINARY); + return file; + }; + + it('uses STENCIL_CLOUDFLARED_PATH as-is', async () => { + const { binary, execFile, httpClient } = create({ + env: { STENCIL_CLOUDFLARED_PATH: '/opt/cloudflared' }, + }); + + await expect(binary.resolve()).resolves.toBe('/opt/cloudflared'); + expect(execFile).not.toHaveBeenCalled(); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('uses cloudflared on the PATH when it is new enough', async () => { + const execFile = jest.fn().mockResolvedValue({ + stdout: 'cloudflared version 2026.10.0 (built 2026-10-01-1200 UTC)\n', + stderr: '', + }); + const { binary, httpClient } = create({ execFile }); + + await expect(binary.resolve()).resolves.toBe('cloudflared'); + expect(execFile).toHaveBeenCalledWith('cloudflared', ['--version'], expect.any(Object)); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('uses the managed cloudflared when the one on the PATH is too old', async () => { + const execFile = jest.fn().mockResolvedValue({ + stdout: 'cloudflared version 2024.1.5 (built 2024-01-22-1200 UTC)\n', + stderr: '', + }); + const { binary, httpClient, logger } = create({ execFile }); + const cached = await writeCached(); + + await expect(binary.resolve()).resolves.toBe(cached); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining('is version 2024.1.5, older than 2025.10.0'), + ); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('uses the cached cloudflared when there is none on the PATH', async () => { + const { binary, httpClient, inquirer } = create(); + const cached = await writeCached(); + + await expect(binary.resolve()).resolves.toBe(cached); + expect(httpClient.get).not.toHaveBeenCalled(); + expect(inquirer.prompt).not.toHaveBeenCalled(); + }); + + it('uses XDG_CACHE_HOME for the cache on Linux', async () => { + const xdg = path.join(home, 'xdg'); + const { binary } = create({ env: { XDG_CACHE_HOME: xdg } }); + const cached = await writeCached(path.join(xdg, 'stencil-cli', 'cloudflared', VERSION)); + + await expect(binary.resolve()).resolves.toBe(cached); + }); + + it('downloads, verifies, and installs the pinned binary', async () => { + const { binary, httpClient, logger, inquirer } = create(); + + const result = await binary.resolve(); + + expect(result).toBe(path.join(linuxDir(), 'cloudflared')); + expect(inquirer.prompt).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'confirm', + message: expect.stringContaining(`Download cloudflared ${VERSION}`), + }), + ]); + expect(httpClient.get).toHaveBeenCalledWith( + `https://github.com/cloudflare/cloudflared/releases/download/${VERSION}/cloudflared-linux-amd64`, + expect.objectContaining({ responseType: 'stream' }), + ); + expect(logger.log).toHaveBeenCalledWith( + `Downloading cloudflared ${VERSION} (first run only)...`, + ); + expect(await fs.promises.readFile(result)).toEqual(BINARY); + // eslint-disable-next-line no-bitwise + expect((await fs.promises.stat(result)).mode & 0o777).toBe(0o755); + expect(await fs.promises.readdir(linuxDir())).toEqual(['cloudflared']); + }); + + it('does not download when the user declines', async () => { + const { binary, httpClient, inquirer } = create(); + inquirer.prompt.mockResolvedValue({ install: false }); + + await expect(binary.resolve()).rejects.toThrow( + `cloudflared was not found. Install cloudflared, or set STENCIL_CLOUDFLARED_PATH`, + ); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('does not prompt or download without a TTY', async () => { + const { binary, httpClient, inquirer } = create({ stdin: { isTTY: false } }); + + await expect(binary.resolve()).rejects.toThrow(INSTALL_URL); + expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('extracts cloudflared from a .tgz asset', async () => { + const srcDir = path.join(home, 'src'); + await fs.promises.mkdir(srcDir); + await fs.promises.writeFile(path.join(srcDir, 'cloudflared'), BINARY); + await fs.promises.writeFile(path.join(srcDir, 'README'), 'not this'); + const archive = path.join(home, 'cloudflared-darwin-arm64.tgz'); + await tar.c({ gzip: true, file: archive, cwd: srcDir }, ['cloudflared', 'README']); + const asset = await fs.promises.readFile(archive); + const { binary } = create({ + asset, + platform: 'darwin', + arch: 'arm64', + manifest: { + version: VERSION, + assets: { + 'darwin-arm64': { name: 'cloudflared-darwin-arm64.tgz', sha256: sha256(asset) }, + }, + }, + }); + const dir = path.join(home, 'Library', 'Caches', 'stencil-cli', 'cloudflared', VERSION); + + const result = await binary.resolve(); + + expect(result).toBe(path.join(dir, 'cloudflared')); + expect(await fs.promises.readFile(result)).toEqual(BINARY); + expect(await fs.promises.readdir(dir)).toEqual(['cloudflared']); + }); + + it('fails and leaves no file when the checksum does not match', async () => { + const { binary } = create({ + manifest: { + version: VERSION, + assets: { + 'linux-x64': { name: 'cloudflared-linux-amd64', sha256: 'f'.repeat(64) }, + }, + }, + }); + + await expect(binary.resolve()).rejects.toThrow(/failed the checksum check/); + expect(await fs.promises.readdir(linuxDir())).toEqual([]); + }); + + it('fails with the install hint on an unsupported platform', async () => { + const { binary, httpClient } = create({ platform: 'aix', arch: 'ppc64' }); + + await expect(binary.resolve()).rejects.toThrow(INSTALL_URL); + await expect(binary.resolve()).rejects.toThrow('not available for aix/ppc64'); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('tries again on the next call after a failed download', async () => { + const { binary, httpClient } = create(); + const { get } = httpClient; + const succeed = get.getMockImplementation(); + get.mockRejectedValueOnce(new Error('socket hang up')).mockImplementation(succeed); + + await expect(binary.resolve()).rejects.toThrow('socket hang up'); + await expect(binary.resolve()).resolves.toBe(path.join(linuxDir(), 'cloudflared')); + expect(get).toHaveBeenCalledTimes(2); + }); + + describe('when the last rename fails', () => { + const finalPath = () => path.join(linuxDir(), 'cloudflared'); + const busy = () => Object.assign(new Error('EBUSY: resource busy'), { code: 'EBUSY' }); + const withRename = (rename) => ({ + ...fs, + promises: { ...fs.promises, rename }, + }); + + it('uses the binary that another run installed first', async () => { + const rename = jest.fn(async (from, to) => { + if (to === finalPath()) { + await fs.promises.writeFile(to, BINARY); + throw busy(); + } + return fs.promises.rename(from, to); + }); + const { binary } = create({ fs: withRename(rename) }); + + await expect(binary.resolve()).resolves.toBe(finalPath()); + expect(await fs.promises.readdir(linuxDir())).toEqual(['cloudflared']); + }); + + it('fails when no binary is at the final path', async () => { + const rename = jest.fn(async (from, to) => { + if (to === finalPath()) { + throw busy(); + } + return fs.promises.rename(from, to); + }); + const { binary } = create({ fs: withRename(rename) }); + + await expect(binary.resolve()).rejects.toThrow(/Could not install cloudflared.*EBUSY/); + expect(await fs.promises.readdir(linuxDir())).toEqual([]); + }); + }); + + it('prompts and downloads once for concurrent calls', async () => { + const { binary, httpClient, inquirer } = create(); + + const results = await Promise.all([binary.resolve(), binary.resolve(), binary.resolve()]); + + expect(new Set(results)).toEqual(new Set([path.join(linuxDir(), 'cloudflared')])); + expect(inquirer.prompt).toHaveBeenCalledTimes(1); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/cloudflared-manifest.js b/lib/cloudflared-manifest.js new file mode 100644 index 00000000..534dba7a --- /dev/null +++ b/lib/cloudflared-manifest.js @@ -0,0 +1,41 @@ +// Pinned cloudflared release. To update, set the version and copy each asset's sha256 from the +// `digest` field of https://api.github.com/repos/cloudflare/cloudflared/releases/tags/. +const CLOUDFLARED_MANIFEST = { + version: '2026.10.0', + assets: { + 'darwin-x64': { + name: 'cloudflared-darwin-amd64.tgz', + sha256: '903845b81828c8cb3c5d13d816a2de71c06a3da5785469df8eb0e1b736d92f9f', + }, + 'darwin-arm64': { + name: 'cloudflared-darwin-arm64.tgz', + sha256: 'a2f79ff7b9420aa537d74af239f376da170bbabeb529aec416002adac6a72e70', + }, + 'linux-x64': { + name: 'cloudflared-linux-amd64', + sha256: 'd33ff2d14475178d2012c2c56beba87389ac5ded27649519f198a7d3134a99db', + }, + 'linux-arm64': { + name: 'cloudflared-linux-arm64', + sha256: 'e6422b9d4f72d3194bc5a38676f13667c06666523217b842a877d72a80b5ac08', + }, + 'linux-arm': { + name: 'cloudflared-linux-arm', + sha256: '1dbe8e4ec17e74bb7f49cf91db6a4903bd0f9fe41984556c7503e40b765fd099', + }, + 'linux-ia32': { + name: 'cloudflared-linux-386', + sha256: 'f6fbd789e6ce9c824d4d560cbbfad2753d55ce398ece16b4c9fbf17271dceab3', + }, + 'win32-x64': { + name: 'cloudflared-windows-amd64.exe', + sha256: '86aee4017b26625cee8484c113558f48effa4cd47f7aa05fcf425604e5d2b23c', + }, + 'win32-ia32': { + name: 'cloudflared-windows-386.exe', + sha256: '0630a8779e9823a1a3b091698b8e71874e0f7b205559219f52fdd301466b5546', + }, + }, +}; + +export default CLOUDFLARED_MANIFEST; diff --git a/package-lock.json b/package-lock.json index cd62df10..df3e270f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "recursive-readdir": "^2.2.2", "semver": "^7.3.2", "simple-git": "^3.26.0", + "tar": "^7.5.2", "tarjan-graph": "^2.0.0", "tmp-promise": "^3.0.2", "upath": "^1.2.0", diff --git a/package.json b/package.json index a0c9012e..34d3d7a5 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "recursive-readdir": "^2.2.2", "semver": "^7.3.2", "simple-git": "^3.26.0", + "tar": "^7.5.2", "tarjan-graph": "^2.0.0", "tmp-promise": "^3.0.2", "upath": "^1.2.0", From 41b9258e63d4c648ebf8498917e4e5747c9bae5e Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Mon, 19 Oct 2026 15:04:40 -0400 Subject: [PATCH 3/3] test: expect Windows file mode in cloudflared install test --- lib/cloudflared-binary.spec.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/cloudflared-binary.spec.js b/lib/cloudflared-binary.spec.js index e34f9220..ef30e4f9 100644 --- a/lib/cloudflared-binary.spec.js +++ b/lib/cloudflared-binary.spec.js @@ -138,8 +138,10 @@ describe('CloudflaredBinary', () => { `Downloading cloudflared ${VERSION} (first run only)...`, ); expect(await fs.promises.readFile(result)).toEqual(BINARY); + // Windows has no POSIX file modes. A writable file always reports 0o666 there. + const expectedMode = process.platform === 'win32' ? 0o666 : 0o755; // eslint-disable-next-line no-bitwise - expect((await fs.promises.stat(result)).mode & 0o777).toBe(0o755); + expect((await fs.promises.stat(result)).mode & 0o777).toBe(expectedMode); expect(await fs.promises.readdir(linuxDir())).toEqual(['cloudflared']); });