@bigcommerce/stencil-cli 10.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ import 'colors';
2
+ import { execFile as nodeExecFile } from 'child_process';
3
+ import crypto from 'crypto';
4
+ import fsModule from 'fs';
5
+ import os from 'os';
6
+ import path from 'path';
7
+ import { Transform } from 'stream';
8
+ import { pipeline } from 'stream/promises';
9
+ import { promisify } from 'util';
10
+ import axios from 'axios';
11
+ import inquirerModule from 'inquirer';
12
+ import ProgressBar from 'progress';
13
+ import semver from 'semver';
14
+ import * as tar from 'tar';
15
+ import CLOUDFLARED_MANIFEST from './cloudflared-manifest.js';
16
+
17
+ // Cloudflare supports cloudflared releases for about one year. Raise this when bumping the pin.
18
+ const MIN_VERSION = '2025.10.0';
19
+ const PATH_ENV = 'STENCIL_CLOUDFLARED_PATH';
20
+ const DOWNLOAD_BASE_URL = 'https://github.com/cloudflare/cloudflared/releases/download';
21
+ const INSTALL_URL =
22
+ 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
23
+
24
+ /**
25
+ * Finds a cloudflared binary for Makeswift preview. Downloads a pinned release on first use.
26
+ */
27
+ class CloudflaredBinary {
28
+ constructor({
29
+ fs = fsModule,
30
+ httpClient = axios,
31
+ execFile = promisify(nodeExecFile),
32
+ platform = process.platform,
33
+ arch = process.arch,
34
+ env = process.env,
35
+ homedir = os.homedir,
36
+ logger = console,
37
+ inquirer = inquirerModule,
38
+ stdin = process.stdin,
39
+ progressStream = process.stderr,
40
+ manifest = CLOUDFLARED_MANIFEST,
41
+ minVersion = MIN_VERSION,
42
+ } = {}) {
43
+ this._fs = fs;
44
+ this._httpClient = httpClient;
45
+ this._execFile = execFile;
46
+ this._platform = platform;
47
+ this._arch = arch;
48
+ this._env = env;
49
+ this._homedir = homedir;
50
+ this._logger = logger;
51
+ this._inquirer = inquirer;
52
+ this._stdin = stdin;
53
+ this._progressStream = progressStream;
54
+ this._manifest = manifest;
55
+ this._minVersion = minVersion;
56
+ this._resolvePromise = null;
57
+ }
58
+
59
+ /**
60
+ * Concurrent calls share one lookup and one download.
61
+ *
62
+ * @returns {Promise<string>} absolute path or command name for cloudflared
63
+ */
64
+ resolve() {
65
+ this._resolvePromise ??= this._resolve().catch((err) => {
66
+ // Let a later call try again.
67
+ this._resolvePromise = null;
68
+ throw err;
69
+ });
70
+ return this._resolvePromise;
71
+ }
72
+
73
+ async _resolve() {
74
+ const override = this._env[PATH_ENV];
75
+ if (override) {
76
+ return override;
77
+ }
78
+ if (await this._isPathBinaryUsable()) {
79
+ return 'cloudflared';
80
+ }
81
+ const asset = this._manifest.assets[`${this._platform}-${this._arch}`];
82
+ if (!asset) {
83
+ throw new Error(
84
+ `cloudflared is not available for ${this._platform}/${this._arch}. ` +
85
+ `Install cloudflared and set ${PATH_ENV} to its path: ${INSTALL_URL}`,
86
+ );
87
+ }
88
+ const binaryPath = path.join(
89
+ this._getCacheDir(),
90
+ 'stencil-cli',
91
+ 'cloudflared',
92
+ this._manifest.version,
93
+ this._platform === 'win32' ? 'cloudflared.exe' : 'cloudflared',
94
+ );
95
+ if (await this._isFile(binaryPath)) {
96
+ return binaryPath;
97
+ }
98
+ await this._confirmDownload(path.dirname(binaryPath));
99
+ await this._download(asset, binaryPath);
100
+ return binaryPath;
101
+ }
102
+
103
+ async _isPathBinaryUsable() {
104
+ let output;
105
+ try {
106
+ const { stdout, stderr } = await this._execFile('cloudflared', ['--version'], {
107
+ timeout: 10000,
108
+ });
109
+ output = `${stdout}${stderr}`;
110
+ } catch {
111
+ return false;
112
+ }
113
+ const match = output.match(/cloudflared version (\S+)/);
114
+ const version = match && semver.coerce(match[1]);
115
+ if (version && semver.gte(version, this._minVersion)) {
116
+ return true;
117
+ }
118
+ const reason = version
119
+ ? `is version ${match[1]}, older than ${this._minVersion}`
120
+ : 'has an unknown version';
121
+ this._logger.log(
122
+ `${'Notice'.yellow}: cloudflared on your PATH ${reason}. ` +
123
+ `Using cloudflared ${this._manifest.version} managed by stencil-cli.`,
124
+ );
125
+ return false;
126
+ }
127
+
128
+ /**
129
+ * Asks the user before the download. Without a TTY there is nobody to ask, so do not download.
130
+ */
131
+ async _confirmDownload(dir) {
132
+ const { version } = this._manifest;
133
+ const notInstalled = `cloudflared was not found. Install cloudflared, or set ${PATH_ENV} to the path of cloudflared: ${INSTALL_URL}`;
134
+ if (!this._stdin?.isTTY) {
135
+ throw new Error(notInstalled);
136
+ }
137
+ const { install } = await this._inquirer.prompt([
138
+ {
139
+ type: 'confirm',
140
+ name: 'install',
141
+ message:
142
+ `Makeswift preview needs cloudflared. Download cloudflared ${version} ` +
143
+ `from GitHub to ${dir}?`,
144
+ default: true,
145
+ },
146
+ ]);
147
+ if (!install) {
148
+ throw new Error(notInstalled);
149
+ }
150
+ }
151
+
152
+ _getCacheDir() {
153
+ const home = this._homedir();
154
+ if (this._platform === 'darwin') {
155
+ return path.join(home, 'Library', 'Caches');
156
+ }
157
+ if (this._platform === 'win32') {
158
+ return this._env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
159
+ }
160
+ return this._env.XDG_CACHE_HOME || path.join(home, '.cache');
161
+ }
162
+
163
+ async _isFile(filePath) {
164
+ try {
165
+ return (await this._fs.promises.stat(filePath)).isFile();
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ async _download(asset, binaryPath) {
172
+ const { version } = this._manifest;
173
+ const url = `${DOWNLOAD_BASE_URL}/${version}/${asset.name}`;
174
+ const dir = path.dirname(binaryPath);
175
+ // Unique temp names, so concurrent `stencil start` runs do not collide.
176
+ const suffix = `${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
177
+ const tmpAsset = path.join(dir, `.${asset.name}.${suffix}.download`);
178
+ const tmpExtractDir = path.join(dir, `.extract.${suffix}`);
179
+ const tmpBinary = path.join(dir, `.${path.basename(binaryPath)}.${suffix}.tmp`);
180
+
181
+ this._logger.log(`Downloading cloudflared ${version} (first run only)...`);
182
+ try {
183
+ await this._fs.promises.mkdir(dir, { recursive: true });
184
+ const digest = await this._fetch(url, tmpAsset);
185
+ if (digest !== asset.sha256) {
186
+ throw new Error(
187
+ `The cloudflared download from ${url} failed the checksum check ` +
188
+ `(expected sha256 ${asset.sha256}, got ${digest}).`,
189
+ );
190
+ }
191
+ if (asset.name.endsWith('.tgz')) {
192
+ await this._extract(tmpAsset, tmpExtractDir, tmpBinary);
193
+ } else {
194
+ await this._fs.promises.rename(tmpAsset, tmpBinary);
195
+ }
196
+ if (this._platform !== 'win32') {
197
+ await this._fs.promises.chmod(tmpBinary, 0o755);
198
+ }
199
+ try {
200
+ await this._fs.promises.rename(tmpBinary, binaryPath);
201
+ } catch (err) {
202
+ // Another run can install it first. On Windows, rename fails if that file is in use.
203
+ if (!(await this._isFile(binaryPath))) {
204
+ throw err;
205
+ }
206
+ }
207
+ } catch (err) {
208
+ err.message =
209
+ `Could not install cloudflared ${version}. ${err.message} ` +
210
+ `To use your own cloudflared, set ${PATH_ENV} to its path.`;
211
+ throw err;
212
+ } finally {
213
+ await Promise.all(
214
+ [tmpAsset, tmpExtractDir, tmpBinary].map((p) =>
215
+ this._fs.promises.rm(p, { recursive: true, force: true }).catch(() => {}),
216
+ ),
217
+ );
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Streams the URL to a file and returns the sha256 hex digest.
223
+ */
224
+ async _fetch(url, filePath) {
225
+ const response = await this._httpClient.get(url, {
226
+ responseType: 'stream',
227
+ timeout: 60000,
228
+ });
229
+ const hash = crypto.createHash('sha256');
230
+ const total = Number(response.headers?.['content-length']);
231
+ const bar =
232
+ this._progressStream?.isTTY && total > 0
233
+ ? new ProgressBar('cloudflared [:bar] :percent', {
234
+ total,
235
+ width: 30,
236
+ stream: this._progressStream,
237
+ })
238
+ : null;
239
+ const hasher = new Transform({
240
+ transform(chunk, encoding, callback) {
241
+ hash.update(chunk);
242
+ bar?.tick(chunk.length);
243
+ callback(null, chunk);
244
+ },
245
+ });
246
+ await pipeline(response.data, hasher, this._fs.createWriteStream(filePath));
247
+ return hash.digest('hex');
248
+ }
249
+
250
+ async _extract(archivePath, extractDir, binaryPath) {
251
+ await this._fs.promises.mkdir(extractDir, { recursive: true });
252
+ await tar.x({
253
+ file: archivePath,
254
+ cwd: extractDir,
255
+ filter: (entryPath) => entryPath.replace(/^\.\//, '') === 'cloudflared',
256
+ });
257
+ const extracted = path.join(extractDir, 'cloudflared');
258
+ if (!(await this._isFile(extracted))) {
259
+ throw new Error('The cloudflared archive does not contain a cloudflared file.');
260
+ }
261
+ await this._fs.promises.rename(extracted, binaryPath);
262
+ }
263
+ }
264
+
265
+ export { CloudflaredBinary, INSTALL_URL, MIN_VERSION, PATH_ENV };
266
+ export default new CloudflaredBinary();
@@ -0,0 +1,41 @@
1
+ // Pinned cloudflared release. To update, set the version and copy each asset's sha256 from the
2
+ // `digest` field of https://api.github.com/repos/cloudflare/cloudflared/releases/tags/<version>.
3
+ const CLOUDFLARED_MANIFEST = {
4
+ version: '2026.10.0',
5
+ assets: {
6
+ 'darwin-x64': {
7
+ name: 'cloudflared-darwin-amd64.tgz',
8
+ sha256: '903845b81828c8cb3c5d13d816a2de71c06a3da5785469df8eb0e1b736d92f9f',
9
+ },
10
+ 'darwin-arm64': {
11
+ name: 'cloudflared-darwin-arm64.tgz',
12
+ sha256: 'a2f79ff7b9420aa537d74af239f376da170bbabeb529aec416002adac6a72e70',
13
+ },
14
+ 'linux-x64': {
15
+ name: 'cloudflared-linux-amd64',
16
+ sha256: 'd33ff2d14475178d2012c2c56beba87389ac5ded27649519f198a7d3134a99db',
17
+ },
18
+ 'linux-arm64': {
19
+ name: 'cloudflared-linux-arm64',
20
+ sha256: 'e6422b9d4f72d3194bc5a38676f13667c06666523217b842a877d72a80b5ac08',
21
+ },
22
+ 'linux-arm': {
23
+ name: 'cloudflared-linux-arm',
24
+ sha256: '1dbe8e4ec17e74bb7f49cf91db6a4903bd0f9fe41984556c7503e40b765fd099',
25
+ },
26
+ 'linux-ia32': {
27
+ name: 'cloudflared-linux-386',
28
+ sha256: 'f6fbd789e6ce9c824d4d560cbbfad2753d55ce398ece16b4c9fbf17271dceab3',
29
+ },
30
+ 'win32-x64': {
31
+ name: 'cloudflared-windows-amd64.exe',
32
+ sha256: '86aee4017b26625cee8484c113558f48effa4cd47f7aa05fcf425604e5d2b23c',
33
+ },
34
+ 'win32-ia32': {
35
+ name: 'cloudflared-windows-386.exe',
36
+ sha256: '0630a8779e9823a1a3b091698b8e71874e0f7b205559219f52fdd301466b5546',
37
+ },
38
+ },
39
+ };
40
+
41
+ export default CLOUDFLARED_MANIFEST;
@@ -0,0 +1,33 @@
1
+ import NetworkUtils from './utils/NetworkUtils.js';
2
+
3
+ const defaultNetworkUtils = new NetworkUtils();
4
+ const HEALTH_PATH = '/api/makeswift/proxy-health';
5
+ const REQUEST_TIMEOUT_MS = 5000;
6
+
7
+ /**
8
+ * Checks if the storefront host is routed through the Makeswift proxy worker.
9
+ *
10
+ * @param {object} options
11
+ * @param {string} options.storeUrl
12
+ * @param {object} [deps]
13
+ * @returns {Promise<boolean>}
14
+ */
15
+ async function isMakeswiftProxyActive({ storeUrl }, { networkUtils = defaultNetworkUtils } = {}) {
16
+ try {
17
+ const response = await networkUtils.sendApiRequest({
18
+ url: new URL(HEALTH_PATH, storeUrl).toString(),
19
+ timeout: REQUEST_TIMEOUT_MS,
20
+ // The preview session uses this host, so a redirect to another host does not count.
21
+ maxRedirects: 0,
22
+ responseType: 'text',
23
+ });
24
+ return response?.status === 200 && String(response.data).trim() === 'OK';
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ export { isMakeswiftProxyActive };
31
+ export default {
32
+ isMakeswiftProxyActive,
33
+ };
@@ -0,0 +1,224 @@
1
+ import crypto from 'crypto';
2
+ import httpProxy from 'http-proxy';
3
+
4
+ const PREVIEW_HEADER_PREFIX = 'x-stencil-preview-';
5
+ const SESSION_HEADER = 'x-stencil-preview-session';
6
+ const TIMESTAMP_HEADER = 'x-stencil-preview-timestamp';
7
+ const SIGNATURE_HEADER = 'x-stencil-preview-signature';
8
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
9
+
10
+ /**
11
+ * Normalizes the request target.
12
+ *
13
+ * @param {string} rawUrl - request target from the incoming request
14
+ * @param {string} origin - storefront origin
15
+ * @returns {string | null} null when the target is not origin-form
16
+ */
17
+ function getSignedPath(rawUrl, origin) {
18
+ if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/')) {
19
+ return null;
20
+ }
21
+ try {
22
+ const url = new URL(`${origin}${rawUrl}`);
23
+ return url.origin === origin ? `${url.pathname}${url.search}` : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function buildSigningString({ sessionId, timestamp, method, pathWithQuery }) {
30
+ return `${sessionId}\n${timestamp}\n${method.toUpperCase()}\n${pathWithQuery}`;
31
+ }
32
+
33
+ /**
34
+ * The HMAC key is the UTF-8 bytes of the hex access key string.
35
+ *
36
+ * @returns {Record<string, string>} the three proof headers
37
+ */
38
+ function signPreviewRequest({ sessionId, accessKey, timestamp, method, pathWithQuery }) {
39
+ const signature = crypto
40
+ .createHmac('sha256', accessKey)
41
+ .update(buildSigningString({ sessionId, timestamp, method, pathWithQuery }))
42
+ .digest('hex');
43
+ return {
44
+ [SESSION_HEADER]: sessionId,
45
+ [TIMESTAMP_HEADER]: String(timestamp),
46
+ [SIGNATURE_HEADER]: signature,
47
+ };
48
+ }
49
+
50
+ function parseUrl(value) {
51
+ try {
52
+ return new URL(value);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function isLoopbackHost(host) {
59
+ const url = typeof host === 'string' && host ? parseUrl(`http://${host}`) : null;
60
+ return url !== null && LOOPBACK_HOSTNAMES.has(url.hostname);
61
+ }
62
+
63
+ function isLoopbackUrl(value) {
64
+ const url = typeof value === 'string' ? parseUrl(value) : null;
65
+ return url !== null && LOOPBACK_HOSTNAMES.has(url.hostname);
66
+ }
67
+
68
+ function isExpectedLocalOrigin(value, localPort) {
69
+ const url = typeof value === 'string' ? parseUrl(value) : null;
70
+ return (
71
+ url !== null &&
72
+ url.protocol === 'http:' &&
73
+ LOOPBACK_HOSTNAMES.has(url.hostname) &&
74
+ url.port === String(Number(localPort))
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Origin and Referer from the local front door point at the storefront instead.
80
+ * Other values do not change.
81
+ */
82
+ function rewriteRequestHeaders(request, storefrontOrigin) {
83
+ const { origin, referer } = request.headers;
84
+ if (isLoopbackUrl(origin)) {
85
+ request.headers.origin = storefrontOrigin;
86
+ }
87
+ if (isLoopbackUrl(referer)) {
88
+ const { pathname, search } = new URL(referer);
89
+ request.headers.referer = `${storefrontOrigin}${pathname}${search}`;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Keeps the browser on the local front door when the storefront redirects to itself.
95
+ * A redirect to another host does not change.
96
+ */
97
+ function rewriteLocation(location, storefrontUrl, localOrigin) {
98
+ if (typeof location !== 'string' || !/^(https?:)?\/\//i.test(location)) {
99
+ return location;
100
+ }
101
+ let url;
102
+ try {
103
+ url = new URL(location, storefrontUrl);
104
+ } catch {
105
+ return location;
106
+ }
107
+ if (url.host !== storefrontUrl.host || !/^https?:$/.test(url.protocol)) {
108
+ return location;
109
+ }
110
+ return `${localOrigin}${url.pathname}${url.search}${url.hash}`;
111
+ }
112
+
113
+ function sendText(response, status, message) {
114
+ response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
115
+ response.end(message);
116
+ }
117
+
118
+ /**
119
+ * Connect middleware for the local front door in Makeswift preview mode. It forwards each
120
+ * request to the storefront with a per-request proof, so the access key never leaves the CLI.
121
+ *
122
+ * @param {object} options
123
+ * @param {{ storeUrl: string, sessionId: string, accessKey: string }} options.previewSession -
124
+ * read on each request, so a re-registered session is used at once
125
+ * @param {number} options.localPort - BrowserSync port for this preview server
126
+ * @param {Function} [options.proxyFactory]
127
+ * @param {{ error: Function }} [options.logger]
128
+ * @param {() => number} [options.now]
129
+ * @returns {(request: import('http').IncomingMessage, response: import('http').ServerResponse) => void}
130
+ */
131
+ function createPreviewProxy({
132
+ previewSession,
133
+ localPort,
134
+ proxyFactory = httpProxy.createProxyServer,
135
+ logger = console,
136
+ now = Date.now,
137
+ }) {
138
+ const storefrontUrl = new URL(previewSession.storeUrl);
139
+ const storefrontOrigin = storefrontUrl.origin;
140
+ const proxy = proxyFactory({
141
+ target: storefrontOrigin,
142
+ changeOrigin: true,
143
+ secure: true,
144
+ xfwd: false,
145
+ });
146
+
147
+ proxy.on('proxyReq', (proxyRequest, request) => {
148
+ // http-proxy collapses repeated slashes in the path. Send the exact signed path.
149
+ Object.assign(proxyRequest, { path: request.url });
150
+ });
151
+
152
+ proxy.on('proxyRes', (proxyResponse, request) => {
153
+ const { headers } = proxyResponse;
154
+ if (headers.location) {
155
+ headers.location = rewriteLocation(
156
+ headers.location,
157
+ storefrontUrl,
158
+ `http://${request.headers.host}`,
159
+ );
160
+ }
161
+ });
162
+
163
+ proxy.on('error', (error, _request, response) => {
164
+ logger.error(`Makeswift preview: the storefront request failed. ${error.message}`);
165
+ if (!response || typeof response.writeHead !== 'function') {
166
+ return;
167
+ }
168
+ if (response.headersSent) {
169
+ response.destroy();
170
+ return;
171
+ }
172
+ sendText(response, 502, 'Makeswift preview: the storefront request failed.');
173
+ });
174
+
175
+ return (request, response) => {
176
+ // Blocks DNS rebinding: another site cannot use the browser to send signed requests.
177
+ if (!isLoopbackHost(request.headers.host)) {
178
+ sendText(response, 403, 'Makeswift preview is available only on localhost.');
179
+ return;
180
+ }
181
+ if (
182
+ request.headers.origin !== undefined &&
183
+ !isExpectedLocalOrigin(request.headers.origin, localPort)
184
+ ) {
185
+ sendText(response, 403, 'Makeswift preview requests must come from this server.');
186
+ return;
187
+ }
188
+ const pathWithQuery = getSignedPath(request.url, storefrontOrigin);
189
+ if (pathWithQuery === null) {
190
+ sendText(response, 400, 'Bad request.');
191
+ return;
192
+ }
193
+
194
+ for (const name of Object.keys(request.headers)) {
195
+ if (name.startsWith(PREVIEW_HEADER_PREFIX)) {
196
+ delete request.headers[name];
197
+ }
198
+ }
199
+ delete request.headers.expect;
200
+ rewriteRequestHeaders(request, storefrontOrigin);
201
+ Object.assign(
202
+ request.headers,
203
+ signPreviewRequest({
204
+ sessionId: previewSession.sessionId,
205
+ accessKey: previewSession.accessKey,
206
+ timestamp: now(),
207
+ method: request.method,
208
+ pathWithQuery,
209
+ }),
210
+ );
211
+ request.url = pathWithQuery;
212
+
213
+ proxy.web(request, response);
214
+ };
215
+ }
216
+
217
+ export {
218
+ buildSigningString,
219
+ createPreviewProxy,
220
+ getSignedPath,
221
+ isLoopbackHost,
222
+ rewriteLocation,
223
+ signPreviewRequest,
224
+ };
@@ -16,6 +16,7 @@ import themeApiClientModule from './theme-api-client.js';
16
16
  import storeSettingsApiClientModule from './store-settings-api-client.js';
17
17
  import LangHelper from './lang-helper.js';
18
18
  import LangValidator from './lang/validator.js';
19
+ import MakeswiftPreview from './MakeswiftPreview.js';
19
20
 
20
21
  class StencilStart {
21
22
  constructor({
@@ -33,6 +34,7 @@ class StencilStart {
33
34
  stencilPushUtils = stencilPushUtilsModule,
34
35
  logger = console,
35
36
  langValidator = new LangValidator(THEME_PATH),
37
+ makeswiftPreview = new MakeswiftPreview({ logger }),
36
38
  } = {}) {
37
39
  this._browserSync = browserSync;
38
40
  this._themeApiClient = themeApiClient;
@@ -48,6 +50,7 @@ class StencilStart {
48
50
  this._stencilPushUtils = stencilPushUtils;
49
51
  this._logger = logger;
50
52
  this._langValidator = langValidator;
53
+ this._makeswiftPreview = makeswiftPreview;
51
54
  }
52
55
 
53
56
  async run(cliOptions) {
@@ -71,9 +74,36 @@ class StencilStart {
71
74
  cliOptions,
72
75
  updatedStencilConfig,
73
76
  );
74
- await this.startLocalServer(cliOptions, updatedStencilConfig);
75
- this._logger.log(this.getStartUpInfo(updatedStencilConfig));
76
- await this.startBrowserSync(cliOptions, browserSyncPort);
77
+ const previewStoreUrl = channelUrl || updatedStencilConfig.storeUrl;
78
+ const makeswiftPreview = await this._makeswiftPreview.isEnabled({
79
+ storeUrl: previewStoreUrl,
80
+ tunnel: cliOptions.tunnel,
81
+ });
82
+ if (makeswiftPreview) {
83
+ await this._makeswiftPreview.start({
84
+ localPort: browserSyncPort,
85
+ rendererPort: this.getRendererPort(browserSyncPort),
86
+ storeUrl: previewStoreUrl,
87
+ accessToken: updatedStencilConfig.accessToken,
88
+ });
89
+ }
90
+ try {
91
+ await this.startLocalServer(
92
+ cliOptions,
93
+ updatedStencilConfig,
94
+ this._makeswiftPreview.session?.secret,
95
+ );
96
+ this._logger.log(this.getStartUpInfo(updatedStencilConfig));
97
+ await this.startBrowserSync(cliOptions, browserSyncPort);
98
+ } catch (err) {
99
+ // The CLI exits on this error. Revoke the session before it does.
100
+ await this._makeswiftPreview.shutdown();
101
+ throw err;
102
+ }
103
+ }
104
+
105
+ getRendererPort(browserSyncPort) {
106
+ return Number(browserSyncPort) + 1;
77
107
  }
78
108
 
79
109
  async getStoreSettingsLocale(cliOptions, stencilConfig) {
@@ -141,8 +171,9 @@ class StencilStart {
141
171
  * @param {Object} stencilConfig
142
172
  * @return {Promise<any>}
143
173
  */
144
- async startLocalServer(cliOptions, stencilConfig) {
174
+ async startLocalServer(cliOptions, stencilConfig, previewSecret) {
145
175
  return Server.create({
176
+ previewSecret,
146
177
  dotStencilFile: stencilConfig,
147
178
  variationIndex: this._themeConfigManager.variationIndex || 0,
148
179
  useCache: cliOptions.cache,
@@ -219,8 +250,9 @@ class StencilStart {
219
250
  ignoreInitial: true,
220
251
  ignored: watchIgnored.map((val) => path.join(themePath, val)),
221
252
  },
222
- proxy: `localhost:${Number(browserSyncPort) + 1}`,
223
- tunnel,
253
+ ...(this._makeswiftPreview.session
254
+ ? this._makeswiftPreview.getBrowserSyncOptions(browserSyncPort)
255
+ : { proxy: `localhost:${this.getRendererPort(browserSyncPort)}`, tunnel }),
224
256
  });
225
257
  // Handle manual reloading of browsers by typing 'rs';
226
258
  // Borrowed from https://github.com/remy/nodemon
@@ -0,0 +1,46 @@
1
+ import { escapeRegExp } from 'lodash-es';
2
+
3
+ // The link must start here. This stops a match on the "//host" part of "ftp://host" or
4
+ // "https://other.test//host".
5
+ const LINK_START = String.raw`(?<![\w:])`;
6
+
7
+ // "https:", "http:", or nothing (a protocol-relative link).
8
+ const SCHEME = '(?:https?:)?';
9
+
10
+ // "//", or "\/\/" in JSON-escaped strings. The capture lets the output keep the same style.
11
+ const SLASHES = String.raw`(\\?/\\?/)`;
12
+
13
+ // The host must end here. This stops a match on a longer host ("host.evil.test", "host-2.com")
14
+ // or on a different port ("host:8443"). A dot that ends a sentence ("Visit https://host.") is
15
+ // not part of the host, so the link is still rewritten.
16
+ const HOST_END = String.raw`(?![\w:-]|\.[^\s"'<])`;
17
+
18
+ /**
19
+ * @param {string} storeHost - host (and port, if not default) of the storefront, e.g. `www.example.com`
20
+ * @returns {RegExp} matches absolute, protocol-relative, and JSON-escaped links to the host
21
+ */
22
+ function buildStorefrontLinkPattern(storeHost) {
23
+ return new RegExp(LINK_START + SCHEME + SLASHES + escapeRegExp(storeHost) + HOST_END, 'gi');
24
+ }
25
+
26
+ /**
27
+ * Makes a BrowserSync rewrite rule that changes absolute storefront links in HTML to the local
28
+ * front door, so that navigation stays on localhost. Normal proxy mode does the same for the
29
+ * renderer host.
30
+ *
31
+ * BrowserSync (resp-modifier) calls `fn(req, res, ...String.prototype.replace callback args)`.
32
+ *
33
+ * @param {string} storeUrl - e.g. `https://www.example.com`
34
+ * @returns {{ match: RegExp, fn: Function }}
35
+ */
36
+ function createStorefrontLinkRewriteRule(storeUrl) {
37
+ return {
38
+ match: buildStorefrontLinkPattern(new URL(storeUrl).host),
39
+ fn: (req, _res, _matched, slashes) => {
40
+ const localSlashes = slashes.includes('\\') ? '\\/\\/' : '//';
41
+ return `http:${localSlashes}${req.headers.host}`;
42
+ },
43
+ };
44
+ }
45
+
46
+ export { buildStorefrontLinkPattern, createStorefrontLinkRewriteRule };