@bigcommerce/stencil-cli 10.0.0 → 10.1.1

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.
package/README.md CHANGED
@@ -47,6 +47,18 @@ Run with `-o` or `--open` to automatically open up a browser.
47
47
  - While stencil is running, you can type "rs" and then hit enter to auto-reload all browsers. This is similar to
48
48
  Nodemon's rs option.
49
49
 
50
+ #### Makeswift preview
51
+
52
+ If Makeswift is enabled for the storefront, `stencil start` opens a Cloudflare quick tunnel with
53
+ [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/).
54
+ If `cloudflared` is not on your PATH (or is too old), Stencil CLI asks if it can download a pinned, checksum-verified
55
+ release. If you agree, it caches the release in `<cache dir>/stencil-cli/cloudflared/<version>/`. If you decline, or
56
+ the terminal is not interactive, `stencil start` continues without Makeswift preview. The cache dir is `~/Library/Caches` on
57
+ macOS, `%LOCALAPPDATA%` on Windows, and `$XDG_CACHE_HOME` or `~/.cache` on other systems.
58
+
59
+ To use your own binary and turn off the download (for example, offline), set `STENCIL_CLOUDFLARED_PATH` to the path
60
+ of `cloudflared`.
61
+
50
62
  Run `stencil bundle` to validate your code and create a zip bundle file that can be uploaded to BigCommerce.
51
63
 
52
64
  Run `stencil release` to tag a new version of your theme, create a [GitHub release](https://help.github.com/articles/about-releases/)
@@ -0,0 +1,253 @@
1
+ import 'colors';
2
+ import makeswiftProxyHealthModule from './makeswift-proxy-health.js';
3
+ import tunnelManagerModule from './TunnelManager.js';
4
+ import previewSessionClientModule from './PreviewSessionClient.js';
5
+ import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js';
6
+ import { createPreviewProxy } from './preview-proxy.js';
7
+ import { createStorefrontLinkRewriteRule } from './storefront-link-rewrite.js';
8
+
9
+ const PREVIEW_LISTEN_HOST = 'localhost';
10
+ // Ctrl-C must not be slow. A session that is not revoked expires with its TTL.
11
+ const SHUTDOWN_TIMEOUT_MS = 3000;
12
+
13
+ /**
14
+ * Makeswift preview for `stencil start`: the tunnel, the preview session and its heartbeat,
15
+ * the signing front door, and the cleanup on exit.
16
+ */
17
+ class MakeswiftPreview {
18
+ constructor({
19
+ makeswiftProxyHealth = makeswiftProxyHealthModule,
20
+ tunnelManager = tunnelManagerModule,
21
+ previewSessionClient = previewSessionClientModule,
22
+ previewProxyFactory = createPreviewProxy,
23
+ previewSessionHeartbeatFactory = (options) => new PreviewSessionHeartbeat(options),
24
+ processObj = process,
25
+ logger = console,
26
+ shutdownTimeout = SHUTDOWN_TIMEOUT_MS,
27
+ } = {}) {
28
+ this._makeswiftProxyHealth = makeswiftProxyHealth;
29
+ this._tunnelManager = tunnelManager;
30
+ this._previewSessionClient = previewSessionClient;
31
+ this._previewProxyFactory = previewProxyFactory;
32
+ this._previewSessionHeartbeatFactory = previewSessionHeartbeatFactory;
33
+ this._process = processObj;
34
+ this._logger = logger;
35
+ this._shutdownTimeout = shutdownTimeout;
36
+ this._tunnel = null;
37
+ this._session = null;
38
+ this._accessToken = null;
39
+ this._heartbeat = null;
40
+ this._shutdownPromise = null;
41
+ }
42
+
43
+ /**
44
+ * The registered preview session, or null when preview is off.
45
+ *
46
+ * @returns {{ storeUrl: string, tunnelUrl: string, secret: string, sessionId: string, accessKey: string, expiresIn: number } | null}
47
+ */
48
+ get session() {
49
+ return this._session;
50
+ }
51
+
52
+ /**
53
+ * Preview mode turns on when the storefront host is routed through the Makeswift proxy.
54
+ * A failed check never stops `stencil start`; it only turns preview mode off.
55
+ *
56
+ * @param {object} options
57
+ * @param {string} options.storeUrl
58
+ * @param {boolean | string} [options.tunnel] - the `--tunnel` CLI option
59
+ * @returns {Promise<boolean>}
60
+ */
61
+ async isEnabled({ storeUrl, tunnel }) {
62
+ const enabled = await this._makeswiftProxyHealth.isMakeswiftProxyActive({ storeUrl });
63
+ if (enabled && tunnel) {
64
+ this._logger.log(
65
+ `${'Notice'.yellow}: Makeswift preview is off because --tunnel is set. ` +
66
+ 'Remove --tunnel to use Makeswift preview.',
67
+ );
68
+ return false;
69
+ }
70
+ return enabled;
71
+ }
72
+
73
+ /**
74
+ * Starts the Cloudflare tunnel to the renderer port (not the local front door) and registers
75
+ * the preview session. If either fails, `stencil start` continues without Makeswift preview.
76
+ * Call it before the renderer starts, so the renderer requires the secret only when a
77
+ * session exists.
78
+ *
79
+ * @param {object} options
80
+ * @param {number} options.localPort - BrowserSync port, the local front door
81
+ * @param {number} options.rendererPort
82
+ * @param {string} options.storeUrl
83
+ * @param {string} [options.accessToken]
84
+ * @throws {Error} when there is no access token
85
+ */
86
+ async start({ localPort, rendererPort, storeUrl, accessToken }) {
87
+ if (!accessToken) {
88
+ throw new Error(
89
+ 'Makeswift preview requires an access token. Add accessToken to your .stencil file.'.red,
90
+ );
91
+ }
92
+ this._logger.log('Makeswift is enabled for this storefront. Starting Makeswift preview...');
93
+ try {
94
+ this._tunnel = await this._tunnelManager.start(rendererPort);
95
+ } catch (err) {
96
+ this._warnUnavailable(err);
97
+ return;
98
+ }
99
+ this._registerShutdownHandlers();
100
+ const secret = this._previewSessionClient.createSecret();
101
+ try {
102
+ const tunnelUrl = this._tunnel.url;
103
+ const registration = await this._previewSessionClient.register({
104
+ storeUrl,
105
+ accessToken,
106
+ tunnelUrl,
107
+ secret,
108
+ });
109
+ this._session = { storeUrl, tunnelUrl, secret, ...registration };
110
+ this._accessToken = accessToken;
111
+ } catch (err) {
112
+ await this.stop();
113
+ this._warnUnavailable(err);
114
+ return;
115
+ }
116
+ this._heartbeat = this._previewSessionHeartbeatFactory({
117
+ client: this._previewSessionClient,
118
+ session: this._session,
119
+ accessToken,
120
+ logger: this._logger,
121
+ });
122
+ this._heartbeat.start();
123
+ // Watch only after the session exists, so an exit during registration also revokes it.
124
+ this._watchTunnel(this._tunnel);
125
+ const localUrl = `http://${PREVIEW_LISTEN_HOST}:${localPort}`;
126
+ this._logger.log(
127
+ `Makeswift preview is active. Open ${localUrl.cyan}\n` +
128
+ 'The Makeswift preview session ends when you stop stencil start.',
129
+ );
130
+ }
131
+
132
+ /**
133
+ * The local front door proxies to the storefront (not the renderer) and signs each request.
134
+ * The worker sends the request back to the renderer through the tunnel. It listens on
135
+ * localhost (a loopback address) only, so other devices cannot send signed requests.
136
+ *
137
+ * @param {number} localPort - BrowserSync port, the local front door
138
+ * @returns {object} BrowserSync options
139
+ */
140
+ getBrowserSyncOptions(localPort) {
141
+ return {
142
+ listen: PREVIEW_LISTEN_HOST,
143
+ // No static files. BrowserSync's own routes and HTML snippet injection still run
144
+ // first; the proxy middleware handles all other requests.
145
+ server: { baseDir: [] },
146
+ middleware: [
147
+ this._previewProxyFactory({
148
+ previewSession: this._session,
149
+ localPort,
150
+ logger: this._logger,
151
+ }),
152
+ ],
153
+ rewriteRules: [createStorefrontLinkRewriteRule(this._session.storeUrl)],
154
+ tunnel: false,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Stops the heartbeat and the tunnel, and revokes the session. Safe to call more than once.
160
+ * It does not fail and it takes at most the shutdown timeout. A failed revoke is not shown:
161
+ * the session expires with its TTL.
162
+ *
163
+ * @returns {Promise<void>}
164
+ */
165
+ shutdown() {
166
+ this._shutdownPromise ??= this._shutdown();
167
+ return this._shutdownPromise;
168
+ }
169
+
170
+ /**
171
+ * Stops the heartbeat and the tunnel. It does not revoke the session.
172
+ *
173
+ * @returns {Promise<void>}
174
+ */
175
+ stop() {
176
+ this._heartbeat?.stop();
177
+ this._heartbeat = null;
178
+ const tunnel = this._tunnel;
179
+ this._tunnel = null;
180
+ return tunnel ? tunnel.stop() : Promise.resolve();
181
+ }
182
+
183
+ async _shutdown() {
184
+ const session = this._session;
185
+ const accessToken = this._accessToken;
186
+ this._accessToken = null;
187
+ // Stop the heartbeat first, so it cannot change or renew the session that is revoked.
188
+ const stopped = this.stop();
189
+ const revoked = session
190
+ ? this._previewSessionClient.revoke({
191
+ storeUrl: session.storeUrl,
192
+ accessToken,
193
+ sessionId: session.sessionId,
194
+ })
195
+ : undefined;
196
+ let timer;
197
+ const timedOut = new Promise((resolve) => {
198
+ timer = setTimeout(resolve, this._shutdownTimeout);
199
+ timer.unref();
200
+ });
201
+ await Promise.race([Promise.allSettled([stopped, revoked]), timedOut]);
202
+ clearTimeout(timer);
203
+ }
204
+
205
+ _registerShutdownHandlers() {
206
+ let shuttingDown = false;
207
+ const onSignal = async (signal) => {
208
+ if (shuttingDown) {
209
+ this._process.exit(1);
210
+ return;
211
+ }
212
+ shuttingDown = true;
213
+ try {
214
+ await this.shutdown();
215
+ } finally {
216
+ this._process.exit(signal === 'SIGINT' ? 130 : 143);
217
+ }
218
+ };
219
+ this._process.on('SIGINT', onSignal);
220
+ this._process.on('SIGTERM', onSignal);
221
+ // Other exits must not leave cloudflared running. This handler must be synchronous, so it
222
+ // cannot revoke the session; the session TTL ends it.
223
+ this._process.once('exit', () => this.stop());
224
+ }
225
+
226
+ /**
227
+ * If cloudflared exits while preview is active, the tunnel URL is dead. Stop the heartbeat and
228
+ * revoke the session, so the session does not stay alive for a tunnel that is gone.
229
+ */
230
+ _watchTunnel(tunnel) {
231
+ tunnel.exited.then(async ({ reason, output }) => {
232
+ // stop() clears this._tunnel first, so an exit that we started is not an error.
233
+ if (this._tunnel !== tunnel) {
234
+ return;
235
+ }
236
+ this._logger.error(
237
+ `${'Error'.red}: cloudflared stopped unexpectedly (${reason}). ` +
238
+ 'Makeswift preview no longer works. Restart stencil start.' +
239
+ (output ? `\ncloudflared output:\n${output}` : ''),
240
+ );
241
+ await this.shutdown();
242
+ });
243
+ }
244
+
245
+ _warnUnavailable(err) {
246
+ this._logger.error(
247
+ `${'Warning'.yellow}: Makeswift preview is not available. ${err.message}\n` +
248
+ 'The local server continues without Makeswift preview.',
249
+ );
250
+ }
251
+ }
252
+
253
+ export default MakeswiftPreview;
@@ -0,0 +1,158 @@
1
+ import crypto from 'crypto';
2
+
3
+ const SESSIONS_PATH = '/api/makeswift/preview-sessions';
4
+ const ACCESS_TOKEN_HEADER = 'x-stencil-access-token';
5
+ const REQUEST_TIMEOUT_MS = 10000;
6
+
7
+ /**
8
+ * @param {string} message
9
+ * @param {{ status?: number, retryable?: boolean }} [options]
10
+ * @returns {Error & { status?: number, retryable: boolean }}
11
+ */
12
+ function previewSessionError(message, { status, retryable = false } = {}) {
13
+ return Object.assign(new Error(message), { status, retryable });
14
+ }
15
+
16
+ const isNonEmptyString = (value) => typeof value === 'string' && value !== '';
17
+
18
+ const isValidExpiresIn = (data) => Number.isFinite(data?.expiresIn) && data.expiresIn > 0;
19
+
20
+ function isValidRegistration(data) {
21
+ return (
22
+ isNonEmptyString(data?.sessionId) &&
23
+ isNonEmptyString(data?.accessKey) &&
24
+ isValidExpiresIn(data)
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Registers, renews, and revokes Makeswift preview sessions.
30
+ */
31
+ class PreviewSessionClient {
32
+ constructor({ fetch = globalThis.fetch, timeout = REQUEST_TIMEOUT_MS } = {}) {
33
+ this._fetch = fetch;
34
+ this._timeout = timeout;
35
+ }
36
+
37
+ /**
38
+ * The worker sends this secret to the local renderer on each tunnel request.
39
+ *
40
+ * @returns {string}
41
+ */
42
+ createSecret() {
43
+ return crypto.randomBytes(32).toString('hex');
44
+ }
45
+
46
+ /**
47
+ * @param {object} options
48
+ * @param {string} options.storeUrl - channel URL, or the store URL when there is no channel URL
49
+ * @param {string} options.accessToken
50
+ * @param {string} options.tunnelUrl
51
+ * @param {string} options.secret
52
+ * @returns {Promise<{ sessionId: string, accessKey: string, expiresIn: number }>}
53
+ */
54
+ async register({ storeUrl, accessToken, tunnelUrl, secret }) {
55
+ const data = await this._request({
56
+ method: 'POST',
57
+ storeUrl,
58
+ path: SESSIONS_PATH,
59
+ accessToken,
60
+ body: { tunnelUrl, secret },
61
+ action: 'register',
62
+ isValid: isValidRegistration,
63
+ });
64
+ const { sessionId, accessKey, expiresIn } = data;
65
+ return { sessionId, accessKey, expiresIn };
66
+ }
67
+
68
+ /**
69
+ * Renews the session TTL. The access key does not change.
70
+ * A 404 error means the session is gone and must be registered again.
71
+ *
72
+ * @param {object} options
73
+ * @param {string} options.storeUrl
74
+ * @param {string} options.accessToken
75
+ * @param {string} options.sessionId
76
+ * @returns {Promise<{ expiresIn: number }>}
77
+ */
78
+ async heartbeat({ storeUrl, accessToken, sessionId }) {
79
+ const { expiresIn } = await this._request({
80
+ method: 'POST',
81
+ storeUrl,
82
+ path: `${SESSIONS_PATH}/${encodeURIComponent(sessionId)}/heartbeat`,
83
+ accessToken,
84
+ action: 'renew',
85
+ isValid: isValidExpiresIn,
86
+ });
87
+ return { expiresIn };
88
+ }
89
+
90
+ /**
91
+ * Ends the session immediately. The worker responds 204 also when the session is already gone.
92
+ *
93
+ * @param {object} options
94
+ * @param {string} options.storeUrl
95
+ * @param {string} options.accessToken
96
+ * @param {string} options.sessionId
97
+ * @returns {Promise<void>}
98
+ */
99
+ async revoke({ storeUrl, accessToken, sessionId }) {
100
+ await this._request({
101
+ method: 'DELETE',
102
+ storeUrl,
103
+ path: `${SESSIONS_PATH}/${encodeURIComponent(sessionId)}`,
104
+ accessToken,
105
+ action: 'revoke',
106
+ });
107
+ }
108
+
109
+ /**
110
+ * @returns {Promise<object | undefined>} the validated JSON body, or undefined without `isValid`
111
+ */
112
+ async _request({ method, storeUrl, path, accessToken, body, action, isValid }) {
113
+ const url = new URL(path, storeUrl);
114
+ if (url.protocol !== 'https:') {
115
+ throw previewSessionError('Makeswift preview requires an HTTPS store URL.');
116
+ }
117
+ const failure = `Could not ${action} the Makeswift preview session`;
118
+
119
+ let response;
120
+ try {
121
+ response = await this._fetch(url, {
122
+ method,
123
+ headers: {
124
+ ...(body && { 'content-type': 'application/json' }),
125
+ [ACCESS_TOKEN_HEADER]: accessToken,
126
+ },
127
+ body: body && JSON.stringify(body),
128
+ redirect: 'manual',
129
+ signal: AbortSignal.timeout(this._timeout),
130
+ });
131
+ } catch {
132
+ throw previewSessionError(`${failure}: the request failed.`, { retryable: true });
133
+ }
134
+
135
+ const { status } = response;
136
+ if (!response.ok) {
137
+ const hint = status === 401 ? ' Make sure the access token in .stencil is valid.' : '';
138
+ throw previewSessionError(`${failure} (HTTP ${status}).${hint}`, {
139
+ status,
140
+ retryable: status === 429 || status >= 500,
141
+ });
142
+ }
143
+
144
+ if (!isValid) {
145
+ return undefined;
146
+ }
147
+ const data = await response.json().catch(() => null);
148
+ if (!isValid(data)) {
149
+ throw previewSessionError(`${failure}: the response (HTTP ${status}) is not valid.`, {
150
+ status,
151
+ });
152
+ }
153
+ return data;
154
+ }
155
+ }
156
+
157
+ export { PreviewSessionClient };
158
+ export default new PreviewSessionClient();
@@ -0,0 +1,117 @@
1
+ import 'colors';
2
+
3
+ // The worker TTL is 300s, so 4 heartbeats in a row can fail before the session expires.
4
+ const INTERVAL_MS = 60000;
5
+ const WARN_AFTER_FAILURES = 2;
6
+
7
+ /**
8
+ * Keeps a Makeswift preview session alive with a heartbeat every 60s. A failed heartbeat is
9
+ * retried on the next tick. When the session is gone (404), it registers a new one and updates
10
+ * the live session object, so the preview proxy uses it on the next request.
11
+ */
12
+ class PreviewSessionHeartbeat {
13
+ /**
14
+ * @param {object} options
15
+ * @param {{ heartbeat: Function, register: Function }} options.client - PreviewSessionClient
16
+ * @param {{ storeUrl: string, tunnelUrl: string, secret: string, sessionId: string, accessKey: string, expiresIn: number }} options.session -
17
+ * the live session object; changed in place
18
+ * @param {string} options.accessToken
19
+ * @param {{ log: Function, error: Function }} [options.logger]
20
+ */
21
+ constructor({ client, session, accessToken, logger = console }) {
22
+ this._client = client;
23
+ this._session = session;
24
+ this._accessToken = accessToken;
25
+ this._logger = logger;
26
+ this._timer = null;
27
+ this._busy = false;
28
+ this._failures = 0;
29
+ }
30
+
31
+ start() {
32
+ this.stop();
33
+ this._failures = 0;
34
+ const timer = setInterval(() => this._beat(timer), INTERVAL_MS);
35
+ timer.unref();
36
+ this._timer = timer;
37
+ }
38
+
39
+ stop() {
40
+ clearInterval(this._timer);
41
+ this._timer = null;
42
+ }
43
+
44
+ async _beat(timer) {
45
+ // The client times out after 10s, so this only skips a tick in unusual cases.
46
+ if (this._busy) {
47
+ return;
48
+ }
49
+ this._busy = true;
50
+ const stopped = () => this._timer !== timer;
51
+ try {
52
+ await this._keepAlive(stopped);
53
+ if (stopped()) {
54
+ return;
55
+ }
56
+ if (this._failures >= WARN_AFTER_FAILURES) {
57
+ this._logger.log('The Makeswift preview session is renewed.');
58
+ }
59
+ this._failures = 0;
60
+ } catch (err) {
61
+ if (!stopped()) {
62
+ this._onFailure(err);
63
+ }
64
+ } finally {
65
+ this._busy = false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * @param {() => boolean} stopped - true after stop(); then the session must not change
71
+ */
72
+ async _keepAlive(stopped) {
73
+ const { storeUrl, tunnelUrl, secret, sessionId } = this._session;
74
+ const accessToken = this._accessToken;
75
+ try {
76
+ await this._client.heartbeat({ storeUrl, accessToken, sessionId });
77
+ return;
78
+ } catch (err) {
79
+ if (err.status !== 404 || stopped()) {
80
+ throw err;
81
+ }
82
+ }
83
+ const registration = await this._client.register({
84
+ storeUrl,
85
+ accessToken,
86
+ tunnelUrl,
87
+ secret,
88
+ });
89
+ if (stopped()) {
90
+ return;
91
+ }
92
+ const { sessionId: newSessionId, accessKey, expiresIn } = registration;
93
+ Object.assign(this._session, { sessionId: newSessionId, accessKey, expiresIn });
94
+ this._logger.log('The Makeswift preview session expired. A new session is registered.');
95
+ }
96
+
97
+ _onFailure(err) {
98
+ if (!err.retryable) {
99
+ this.stop();
100
+ this._logger.error(
101
+ `${'Error'.red}: ${err.message}\n` +
102
+ 'Makeswift preview stops when the session expires. ' +
103
+ 'Correct the problem, then restart stencil start.',
104
+ );
105
+ return;
106
+ }
107
+ this._failures += 1;
108
+ if (this._failures === WARN_AFTER_FAILURES) {
109
+ this._logger.error(
110
+ `${'Warning'.yellow}: ${err.message} Retrying in ${INTERVAL_MS / 1000}s.\n` +
111
+ 'Makeswift preview can stop working until the session is renewed.',
112
+ );
113
+ }
114
+ }
115
+ }
116
+
117
+ export default PreviewSessionHeartbeat;
@@ -0,0 +1,134 @@
1
+ import { spawn as nodeSpawn } from 'child_process';
2
+ import { once } from 'events';
3
+ import cloudflaredBinaryModule, { INSTALL_URL, PATH_ENV } from './cloudflared-binary.js';
4
+
5
+ const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
6
+ const INSTALL_HINT = `Install cloudflared, or set ${PATH_ENV} to the path of cloudflared: ${INSTALL_URL}`;
7
+ // Keep only the end of the cloudflared log, for error messages.
8
+ const LOG_TAIL_LENGTH = 16 * 1024;
9
+ const LOG_TAIL_LINES = 5;
10
+
11
+ /**
12
+ * Starts a Cloudflare Quick Tunnel (no Cloudflare account needed) to a local port.
13
+ */
14
+ class TunnelManager {
15
+ constructor({
16
+ spawn = nodeSpawn,
17
+ cloudflaredBinary = cloudflaredBinaryModule,
18
+ startTimeout = 30000,
19
+ killTimeout = 2000,
20
+ } = {}) {
21
+ this._spawn = spawn;
22
+ this._cloudflaredBinary = cloudflaredBinary;
23
+ this._startTimeout = startTimeout;
24
+ this._killTimeout = killTimeout;
25
+ }
26
+
27
+ /**
28
+ * `exited` resolves when cloudflared exits for any reason, including stop(). It does not
29
+ * reject. `reason` is the exit code or signal; `output` is the last lines of the log.
30
+ *
31
+ * @param {number} port
32
+ * @returns {Promise<{ url: string, stop: () => Promise<void>, exited: Promise<{ reason: string, output: string }> }>}
33
+ */
34
+ async start(port) {
35
+ const binaryPath = await this._cloudflaredBinary.resolve();
36
+ const child = this._spawn(
37
+ binaryPath,
38
+ // --grace-period: on SIGTERM, exit after 1s instead of the default 30s.
39
+ [
40
+ 'tunnel',
41
+ '--url',
42
+ `http://localhost:${port}`,
43
+ '--no-autoupdate',
44
+ '--grace-period',
45
+ '1s',
46
+ ],
47
+ // cloudflared writes its log, including the tunnel URL, to stderr.
48
+ { stdio: ['ignore', 'ignore', 'pipe'] },
49
+ );
50
+ // Rejects on a spawn error, for example ENOENT when cloudflared is not installed.
51
+ const exited = once(child, 'exit');
52
+ let log = '';
53
+ const onLog = (chunk) => {
54
+ log = (log + chunk).slice(-LOG_TAIL_LENGTH);
55
+ };
56
+ // This listener also keeps reading stderr, so cloudflared does not block on a full pipe.
57
+ child.stderr.on('data', onLog);
58
+ const lastLines = () => log.trim().split('\n').slice(-LOG_TAIL_LINES).join('\n');
59
+ const exitInfo = exited.then(
60
+ ([code, signal]) => ({ reason: signal || `code ${code}`, output: lastLines() }),
61
+ (err) => ({ reason: err.message, output: lastLines() }),
62
+ );
63
+ let stopPromise;
64
+ const stop = () => {
65
+ stopPromise ??= (async () => {
66
+ let killTimer;
67
+ if (child.exitCode === null && child.signalCode === null) {
68
+ child.kill('SIGTERM');
69
+ killTimer = setTimeout(() => child.kill('SIGKILL'), this._killTimeout);
70
+ killTimer.unref();
71
+ }
72
+ await exited.catch(() => {});
73
+ clearTimeout(killTimer);
74
+ })();
75
+ return stopPromise;
76
+ };
77
+
78
+ let onUrl;
79
+ let timer;
80
+ try {
81
+ const url = await new Promise((resolve, reject) => {
82
+ onUrl = () => {
83
+ const match = log.match(TUNNEL_URL_PATTERN);
84
+ if (match) {
85
+ resolve(match[0]);
86
+ }
87
+ };
88
+ // Added after onLog, so `log` already has the chunk.
89
+ child.stderr.on('data', onUrl);
90
+ exited.then(
91
+ ([code, signal]) =>
92
+ reject(
93
+ new Error(
94
+ `cloudflared exited (${
95
+ signal || `code ${code}`
96
+ }) before it returned a tunnel URL.`,
97
+ ),
98
+ ),
99
+ (err) =>
100
+ reject(
101
+ err.code === 'ENOENT'
102
+ ? new Error(
103
+ `cloudflared was not found at ${binaryPath}. ${INSTALL_HINT}`,
104
+ )
105
+ : err,
106
+ ),
107
+ );
108
+ timer = setTimeout(
109
+ () =>
110
+ reject(
111
+ new Error(
112
+ `cloudflared did not return a tunnel URL after ${
113
+ this._startTimeout / 1000
114
+ }s. Check your network connection.`,
115
+ ),
116
+ ),
117
+ this._startTimeout,
118
+ );
119
+ });
120
+ return { url, stop, exited: exitInfo };
121
+ } catch (err) {
122
+ await stop();
123
+ const output = lastLines();
124
+ err.message += output ? `\ncloudflared output:\n${output}` : '';
125
+ throw err;
126
+ } finally {
127
+ clearTimeout(timer);
128
+ child.stderr.off('data', onUrl);
129
+ }
130
+ }
131
+ }
132
+
133
+ export { TunnelManager };
134
+ export default new TunnelManager();