@shopify/plugin-cloudflare
Version:
Enables the creation of Cloudflare tunnels from `shopify app dev`, allowing previews from any device
196 lines • 7.78 kB
JavaScript
import { TUNNEL_PROVIDER } from './provider.js';
import install from './install-cloudflared.js';
import { startTunnel, TunnelError, } from '@shopify/cli-kit/node/plugins/tunnel';
import { err, ok } from '@shopify/cli-kit/node/result';
import { exec, sleep } from '@shopify/cli-kit/node/system';
import { AbortController } from '@shopify/cli-kit/node/abort';
import { joinPath, dirname } from '@shopify/cli-kit/node/path';
import { outputDebug } from '@shopify/cli-kit/node/output';
import { isUnitTest } from '@shopify/cli-kit/node/context/local';
import { BugError } from '@shopify/cli-kit/node/error';
import { Writable } from 'stream';
import { fileURLToPath } from 'url';
export default startTunnel({ provider: TUNNEL_PROVIDER, action: hookStart });
// How much time to wait for a tunnel to be established. in seconds.
const TUNNEL_TIMEOUT = isUnitTest() ? 0.2 : 40;
// if the tunnel process crashes, we'll retry this many times before giving up
// If we retry too many times, we might get rate limited by cloudflare
const MAX_RETRIES = 5;
export async function hookStart(port) {
try {
const client = new TunnelClientInstance(port);
await client.startTunnel();
return ok(client);
// eslint-disable-next-line no-catch-all/no-catch-all, @typescript-eslint/no-explicit-any
}
catch (error) {
const tunnelError = new TunnelError('unknown', error.message);
return err(tunnelError);
}
}
class TunnelClientInstance {
constructor(port) {
this.provider = TUNNEL_PROVIDER;
this.currentStatus = { status: 'not-started' };
this.abortController = undefined;
this.port = port;
}
async startTunnel() {
try {
await install();
this.tunnel();
// eslint-disable-next-line no-catch-all/no-catch-all, @typescript-eslint/no-explicit-any
}
catch (error) {
this.currentStatus = { status: 'error', message: error.message, tryMessage: whatToTry() };
}
}
getTunnelStatus() {
return this.currentStatus;
}
stopTunnel() {
this.abortController?.abort();
}
tunnel(retries = 0) {
this.abortController = new AbortController();
let resolved = false;
if (retries >= MAX_RETRIES) {
resolved = true;
this.currentStatus = {
status: 'error',
message: 'Could not start Cloudflare tunnel: max retries reached.',
tryMessage: whatToTry(),
};
return;
}
const args = ['tunnel', '--url', `http://localhost:${this.port}`, '--no-autoupdate'];
const errors = [];
let connected = false;
let url;
this.currentStatus = { status: 'starting' };
setTimeout(() => {
if (!resolved) {
resolved = true;
const lastErrors = [...new Set(errors)].slice(-5).join('\n');
if (lastErrors === '') {
this.currentStatus = {
status: 'error',
message: 'Could not start Cloudflare tunnel: unknown error.',
tryMessage: whatToTry(),
};
}
else {
this.currentStatus = { status: 'error', message: lastErrors, tryMessage: whatToTry() };
}
this.abortController?.abort();
}
}, TUNNEL_TIMEOUT * 1000);
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const customStdout = new Writable({
write(chunk, _, callback) {
outputDebug(chunk.toString());
if (resolved)
return;
if (!url)
url = findUrl(chunk);
if (findConnection(chunk))
connected = true;
if (connected) {
if (url) {
resolved = true;
self.currentStatus = { status: 'connected', url };
}
else {
self.currentStatus = { status: 'error', message: 'Could not start Cloudflare tunnel: URL not found.' };
}
}
const errorMessage = findError(chunk);
if (errorMessage)
errors.push(errorMessage);
callback();
},
});
// eslint-disable-next-line @typescript-eslint/no-floating-promises
exec(getBinPathTarget(), args, {
stdout: customStdout,
stderr: customStdout,
signal: this.abortController.signal,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
externalErrorHandler: async (error) => {
// If already resolved, means that the CLI already received the tunnel URL.
// Can't retry because the CLI is running with an invalid URL
if (resolved) {
throw new BugError(`Could not start Cloudflare tunnel: process crashed after stablishing a connection: ${error.message}`, whatToTry());
}
outputDebug(`Cloudflare tunnel crashed: ${error.message}, restarting...`);
// wait 1 second before restarting the tunnel, to avoid rate limiting
if (!isUnitTest())
await sleep(1);
this.tunnel(retries + 1);
},
});
}
}
function whatToTry() {
return [
'You can run the command again, or try networking with Shopify via',
{
command: '--use-localhost',
},
'or',
{
command: '--tunnel-url <custom tunnel>',
},
'.',
{
link: {
label: 'See documentation for details.',
url: 'https://shopify.dev/docs/apps/build/cli-for-apps/networking-options',
},
},
];
}
function findUrl(data) {
const regex = new RegExp(`(https:\\/\\/[^\\s]+\\.${getTunnelDomain()})`);
const match = data.toString().match(regex) ?? undefined;
return match && match[1];
}
function findError(data) {
const knownErrors = [
/failed to request quick Tunnel/,
/failed to unmarshal quick Tunnel/,
/failed to parse quick Tunnel ID/,
/failed to provision routing/,
/ERR Couldn't start tunnel/,
/ERR Failed to serve quic connection/,
/ERR Failed to create new quic connection error/,
];
const match = knownErrors.some((error) => error.test(data.toString()));
if (!match)
return undefined;
return `Could not start Cloudflare tunnel: ${cleanCloudflareLog(data.toString())}`;
}
function cleanCloudflareLog(input) {
const prefixRegex = /^[0-9TZ:-]+ (ERR )?/g;
const suffixRegex = /connIndex.*/g;
return input.replace(prefixRegex, '').replace(suffixRegex, '');
}
function findConnection(data) {
const match = data.toString().match(/(INF Registered tunnel connection|INF Connection)/) ?? undefined;
return match && match[0];
}
/**
* Get the path where the binary was installed.
* If the environment variable SHOPIFY_CLI_CLOUDFLARED_PATH is set, use that.
*/
function getBinPathTarget() {
if (process.env.SHOPIFY_CLI_CLOUDFLARED_PATH) {
return process.env.SHOPIFY_CLI_CLOUDFLARED_PATH;
}
return joinPath(dirname(fileURLToPath(import.meta.url)), '..', 'bin', process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
}
function getTunnelDomain() {
return process.env.SHOPIFY_CLI_CLOUDFLARED_DOMAIN ?? 'trycloudflare.com';
}
//# sourceMappingURL=tunnel.js.map