Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cache dir>/stencil-cli/cloudflared/<version>/`. 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/)
Expand Down
21 changes: 21 additions & 0 deletions lib/MakeswiftPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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` +
Expand Down Expand Up @@ -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` +
Expand Down
79 changes: 76 additions & 3 deletions lib/MakeswiftPreview.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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();
Expand Down
59 changes: 40 additions & 19 deletions lib/TunnelManager.js
Original file line number Diff line number Diff line change
@@ -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<void> }>}
* @returns {Promise<{ url: string, stop: () => Promise<void>, 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',
Expand All @@ -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 () => {
Expand All @@ -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(
Expand All @@ -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,
),
);
Expand All @@ -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);
}
}
}
Expand Down
64 changes: 55 additions & 9 deletions lib/TunnelManager.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,41 +15,61 @@ 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');

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);
Expand All @@ -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;

Expand All @@ -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) => {
Expand Down
Loading
Loading