diff --git a/lib/PreviewSessionClient.js b/lib/PreviewSessionClient.js index 7335e9f6..1cf62660 100644 --- a/lib/PreviewSessionClient.js +++ b/lib/PreviewSessionClient.js @@ -1,6 +1,6 @@ import crypto from 'crypto'; -const REGISTER_PATH = '/api/makeswift/preview-sessions'; +const SESSIONS_PATH = '/api/makeswift/preview-sessions'; const ACCESS_TOKEN_HEADER = 'x-stencil-access-token'; const REQUEST_TIMEOUT_MS = 10000; @@ -15,17 +15,18 @@ function previewSessionError(message, { status, retryable = false } = {}) { const isNonEmptyString = (value) => typeof value === 'string' && value !== ''; +const isValidExpiresIn = (data) => Number.isFinite(data?.expiresIn) && data.expiresIn > 0; + function isValidRegistration(data) { return ( isNonEmptyString(data?.sessionId) && isNonEmptyString(data?.accessKey) && - Number.isFinite(data?.expiresIn) && - data.expiresIn > 0 + isValidExpiresIn(data) ); } /** - * Registers a Makeswift preview session. + * Registers and renews Makeswift preview sessions. */ class PreviewSessionClient { constructor({ fetch = globalThis.fetch, timeout = REQUEST_TIMEOUT_MS } = {}) { @@ -51,49 +52,78 @@ class PreviewSessionClient { * @returns {Promise<{ sessionId: string, accessKey: string, expiresIn: number }>} */ async register({ storeUrl, accessToken, tunnelUrl, secret }) { - const url = new URL(REGISTER_PATH, storeUrl); + const data = await this._post({ + storeUrl, + path: SESSIONS_PATH, + accessToken, + body: { tunnelUrl, secret }, + action: 'register', + isValid: isValidRegistration, + }); + const { sessionId, accessKey, expiresIn } = data; + return { sessionId, accessKey, expiresIn }; + } + + /** + * Renews the session TTL. The access key does not change. + * A 404 error means the session is gone and must be registered again. + * + * @param {object} options + * @param {string} options.storeUrl + * @param {string} options.accessToken + * @param {string} options.sessionId + * @returns {Promise<{ expiresIn: number }>} + */ + async heartbeat({ storeUrl, accessToken, sessionId }) { + const { expiresIn } = await this._post({ + storeUrl, + path: `${SESSIONS_PATH}/${encodeURIComponent(sessionId)}/heartbeat`, + accessToken, + action: 'renew', + isValid: isValidExpiresIn, + }); + return { expiresIn }; + } + + async _post({ storeUrl, path, accessToken, body, action, isValid }) { + const url = new URL(path, storeUrl); if (url.protocol !== 'https:') { throw previewSessionError('Makeswift preview requires an HTTPS store URL.'); } + const failure = `Could not ${action} the Makeswift preview session`; let response; try { response = await this._fetch(url, { method: 'POST', headers: { - 'content-type': 'application/json', + ...(body && { 'content-type': 'application/json' }), [ACCESS_TOKEN_HEADER]: accessToken, }, - body: JSON.stringify({ tunnelUrl, secret }), + body: body && JSON.stringify(body), redirect: 'manual', signal: AbortSignal.timeout(this._timeout), }); } catch { - throw previewSessionError( - 'Could not register the Makeswift preview session: the request failed.', - { retryable: true }, - ); + throw previewSessionError(`${failure}: the request failed.`, { retryable: true }); } const { status } = response; if (!response.ok) { const hint = status === 401 ? ' Make sure the access token in .stencil is valid.' : ''; - throw previewSessionError( - `Could not register the Makeswift preview session (HTTP ${status}).${hint}`, - { status, retryable: status === 429 || status >= 500 }, - ); + throw previewSessionError(`${failure} (HTTP ${status}).${hint}`, { + status, + retryable: status === 429 || status >= 500, + }); } const data = await response.json().catch(() => null); - if (!isValidRegistration(data)) { - throw previewSessionError( - `Could not register the Makeswift preview session: the response (HTTP ${status}) is not valid.`, - { status }, - ); + if (!isValid(data)) { + throw previewSessionError(`${failure}: the response (HTTP ${status}) is not valid.`, { + status, + }); } - - const { sessionId, accessKey, expiresIn } = data; - return { sessionId, accessKey, expiresIn }; + return data; } } diff --git a/lib/PreviewSessionClient.spec.js b/lib/PreviewSessionClient.spec.js index 3057d2fb..ff0f3a62 100644 --- a/lib/PreviewSessionClient.spec.js +++ b/lib/PreviewSessionClient.spec.js @@ -80,3 +80,45 @@ describe('PreviewSessionClient.register', () => { await expect(client.register(options)).rejects.toMatchObject({ retryable: true }); }); }); + +describe('PreviewSessionClient.heartbeat', () => { + const options = { + storeUrl: 'https://store.example.com', + accessToken: 'token-123', + sessionId: 'a/b', + }; + const respond = (status, body) => + jest.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })); + + it('renews the session with the access token', async () => { + const fetch = respond(200, { expiresIn: 300 }); + const client = new PreviewSessionClient({ fetch }); + + await expect(client.heartbeat(options)).resolves.toEqual({ expiresIn: 300 }); + + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toBe( + 'https://store.example.com/api/makeswift/preview-sessions/a%2Fb/heartbeat', + ); + expect(init).toMatchObject({ + method: 'POST', + headers: { 'x-stencil-access-token': 'token-123' }, + redirect: 'manual', + }); + }); + + it.each([ + [404, { error: 'Not found' }, false], + [401, { error: 'x' }, false], + [503, { error: 'x' }, true], + [200, {}, false], + ])('rejects HTTP %s with the status', async (status, body, retryable) => { + const client = new PreviewSessionClient({ fetch: respond(status, body) }); + + await expect(client.heartbeat(options)).rejects.toMatchObject({ + message: expect.stringContaining('Could not renew'), + status, + retryable, + }); + }); +}); diff --git a/lib/PreviewSessionHeartbeat.js b/lib/PreviewSessionHeartbeat.js new file mode 100644 index 00000000..6405f5a4 --- /dev/null +++ b/lib/PreviewSessionHeartbeat.js @@ -0,0 +1,117 @@ +import 'colors'; + +// The worker TTL is 300s, so 4 heartbeats in a row can fail before the session expires. +const INTERVAL_MS = 60000; +const WARN_AFTER_FAILURES = 2; + +/** + * Keeps a Makeswift preview session alive with a heartbeat every 60s. A failed heartbeat is + * retried on the next tick. When the session is gone (404), it registers a new one and updates + * the live session object, so the preview proxy uses it on the next request. + */ +class PreviewSessionHeartbeat { + /** + * @param {object} options + * @param {{ heartbeat: Function, register: Function }} options.client - PreviewSessionClient + * @param {{ storeUrl: string, tunnelUrl: string, secret: string, sessionId: string, accessKey: string, expiresIn: number }} options.session - + * the live session object; changed in place + * @param {string} options.accessToken + * @param {{ log: Function, error: Function }} [options.logger] + */ + constructor({ client, session, accessToken, logger = console }) { + this._client = client; + this._session = session; + this._accessToken = accessToken; + this._logger = logger; + this._timer = null; + this._busy = false; + this._failures = 0; + } + + start() { + this.stop(); + this._failures = 0; + const timer = setInterval(() => this._beat(timer), INTERVAL_MS); + timer.unref(); + this._timer = timer; + } + + stop() { + clearInterval(this._timer); + this._timer = null; + } + + async _beat(timer) { + // The client times out after 10s, so this only skips a tick in unusual cases. + if (this._busy) { + return; + } + this._busy = true; + const stopped = () => this._timer !== timer; + try { + await this._keepAlive(stopped); + if (stopped()) { + return; + } + if (this._failures >= WARN_AFTER_FAILURES) { + this._logger.log('The Makeswift preview session is renewed.'); + } + this._failures = 0; + } catch (err) { + if (!stopped()) { + this._onFailure(err); + } + } finally { + this._busy = false; + } + } + + /** + * @param {() => boolean} stopped - true after stop(); then the session must not change + */ + async _keepAlive(stopped) { + const { storeUrl, tunnelUrl, secret, sessionId } = this._session; + const accessToken = this._accessToken; + try { + await this._client.heartbeat({ storeUrl, accessToken, sessionId }); + return; + } catch (err) { + if (err.status !== 404 || stopped()) { + throw err; + } + } + const registration = await this._client.register({ + storeUrl, + accessToken, + tunnelUrl, + secret, + }); + if (stopped()) { + return; + } + const { sessionId: newSessionId, accessKey, expiresIn } = registration; + Object.assign(this._session, { sessionId: newSessionId, accessKey, expiresIn }); + this._logger.log('The Makeswift preview session expired. A new session is registered.'); + } + + _onFailure(err) { + if (!err.retryable) { + this.stop(); + this._logger.error( + `${'Error'.red}: ${err.message}\n` + + 'Makeswift preview stops when the session expires. ' + + 'Correct the problem, then restart stencil start.', + ); + return; + } + this._failures += 1; + if (this._failures === WARN_AFTER_FAILURES) { + this._logger.error( + `${'Warning'.yellow}: ${err.message} Retrying in ${INTERVAL_MS / 1000}s.\n` + + 'Makeswift preview can stop working until the session is renewed.', + ); + } + } +} + +export default PreviewSessionHeartbeat; diff --git a/lib/PreviewSessionHeartbeat.spec.js b/lib/PreviewSessionHeartbeat.spec.js new file mode 100644 index 00000000..99a0c276 --- /dev/null +++ b/lib/PreviewSessionHeartbeat.spec.js @@ -0,0 +1,163 @@ +import { jest } from '@jest/globals'; +import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js'; + +const SECOND = 1000; +const TICK = 60 * SECOND; +const error = (status, retryable = false) => + Object.assign(new Error(`HTTP ${status}`), { status, retryable }); + +describe('PreviewSessionHeartbeat', () => { + let session; + let client; + let logger; + let heartbeat; + + beforeEach(() => { + jest.useFakeTimers(); + session = { + storeUrl: 'https://store.example.com', + tunnelUrl: 'https://preview.trycloudflare.com', + secret: 's'.repeat(64), + sessionId: 'session-1', + accessKey: 'key-1', + expiresIn: 300, + }; + client = { + heartbeat: jest.fn().mockResolvedValue({ expiresIn: 300 }), + register: jest + .fn() + .mockResolvedValue({ sessionId: 'session-2', accessKey: 'key-2', expiresIn: 300 }), + }; + logger = { log: jest.fn(), error: jest.fn() }; + heartbeat = new PreviewSessionHeartbeat({ client, session, accessToken: 'token', logger }); + heartbeat.start(); + }); + + afterEach(() => { + heartbeat.stop(); + jest.useRealTimers(); + }); + + it('renews every 60s', async () => { + await jest.advanceTimersByTimeAsync(TICK - 1); + expect(client.heartbeat).not.toHaveBeenCalled(); + + // 720s: past two TTLs. + await jest.advanceTimersByTimeAsync(11 * TICK + 1); + expect(client.heartbeat).toHaveBeenCalledTimes(12); + expect(client.heartbeat).toHaveBeenCalledWith({ + storeUrl: session.storeUrl, + accessToken: 'token', + sessionId: 'session-1', + }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('registers again with the same tunnel and secret when the session is gone', async () => { + client.heartbeat.mockRejectedValueOnce(error(404)); + + await jest.advanceTimersByTimeAsync(TICK); + expect(client.register).toHaveBeenCalledWith({ + storeUrl: session.storeUrl, + accessToken: 'token', + tunnelUrl: session.tunnelUrl, + secret: session.secret, + }); + expect(session).toMatchObject({ sessionId: 'session-2', accessKey: 'key-2' }); + + await jest.advanceTimersByTimeAsync(TICK); + expect(client.heartbeat).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'session-2' }), + ); + }); + + it('retries on the next tick, warns once, and reports recovery', async () => { + client.heartbeat + .mockRejectedValueOnce(error(503, true)) + .mockRejectedValueOnce(error(503, true)) + .mockRejectedValueOnce(error(503, true)); + + await jest.advanceTimersByTimeAsync(TICK); + expect(logger.error).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(TICK); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0]).toContain('HTTP 503 Retrying in 60s.'); + await jest.advanceTimersByTimeAsync(TICK); + expect(logger.log).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(TICK); + expect(client.heartbeat).toHaveBeenCalledTimes(4); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.log).toHaveBeenCalledWith('The Makeswift preview session is renewed.'); + }); + + it('registers again on the next tick when registration fails', async () => { + client.heartbeat.mockRejectedValue(error(404)); + client.register.mockRejectedValueOnce(error(503, true)); + + await jest.advanceTimersByTimeAsync(TICK); + expect(session.sessionId).toBe('session-1'); + await jest.advanceTimersByTimeAsync(TICK); + expect(client.register).toHaveBeenCalledTimes(2); + expect(session.sessionId).toBe('session-2'); + }); + + it('shows the error and stops when the failure is not retryable', async () => { + client.heartbeat.mockRejectedValueOnce(error(401)); + + await jest.advanceTimersByTimeAsync(TICK); + expect(logger.error.mock.calls[0][0]).toContain('HTTP 401'); + expect(jest.getTimerCount()).toBe(0); + }); + + it('skips a tick while a request is in flight', async () => { + let resolve; + client.heartbeat.mockReturnValueOnce( + new Promise((res) => { + resolve = res; + }), + ); + + await jest.advanceTimersByTimeAsync(2 * TICK); + expect(client.heartbeat).toHaveBeenCalledTimes(1); + resolve({ expiresIn: 300 }); + await jest.advanceTimersByTimeAsync(TICK); + expect(client.heartbeat).toHaveBeenCalledTimes(2); + }); + + it('does not register or log after stop() during a request', async () => { + let reject; + client.heartbeat.mockReturnValueOnce( + new Promise((_, rej) => { + reject = rej; + }), + ); + + await jest.advanceTimersByTimeAsync(TICK); + heartbeat.stop(); + reject(error(404)); + await jest.runAllTimersAsync(); + + expect(client.register).not.toHaveBeenCalled(); + expect(session.sessionId).toBe('session-1'); + expect(logger.error).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); + + it('does not change the session after stop() during a registration', async () => { + let resolve; + client.heartbeat.mockRejectedValueOnce(error(404)); + client.register.mockReturnValueOnce( + new Promise((res) => { + resolve = res; + }), + ); + + await jest.advanceTimersByTimeAsync(TICK); + heartbeat.stop(); + resolve({ sessionId: 'session-2', accessKey: 'key-2', expiresIn: 300 }); + await jest.runAllTimersAsync(); + + expect(session.sessionId).toBe('session-1'); + expect(logger.log).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/stencil-start.js b/lib/stencil-start.js index 5aa196eb..1a3ca895 100755 --- a/lib/stencil-start.js +++ b/lib/stencil-start.js @@ -19,6 +19,7 @@ import LangValidator from './lang/validator.js'; import makeswiftProxyHealthModule from './makeswift-proxy-health.js'; import tunnelManagerModule from './TunnelManager.js'; import previewSessionClientModule from './PreviewSessionClient.js'; +import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js'; import { createPreviewProxy } from './preview-proxy.js'; const PREVIEW_LISTEN_HOST = 'localhost'; @@ -43,6 +44,7 @@ class StencilStart { tunnelManager = tunnelManagerModule, previewSessionClient = previewSessionClientModule, previewProxyFactory = createPreviewProxy, + previewSessionHeartbeatFactory = (options) => new PreviewSessionHeartbeat(options), processObj = process, } = {}) { this._browserSync = browserSync; @@ -63,9 +65,11 @@ class StencilStart { this._tunnelManager = tunnelManager; this._previewSessionClient = previewSessionClient; this._previewProxyFactory = previewProxyFactory; + this._previewSessionHeartbeatFactory = previewSessionHeartbeatFactory; this._process = processObj; this._tunnel = null; this._previewSession = null; + this._previewSessionHeartbeat = null; } async run(cliOptions) { @@ -173,10 +177,17 @@ class StencilStart { }); this._previewSession = { storeUrl, tunnelUrl, secret, ...registration }; } catch (err) { - await this.stopMakeswiftPreviewTunnel(); + await this.stopMakeswiftPreview(); this.warnPreviewUnavailable(err); return; } + this._previewSessionHeartbeat = this._previewSessionHeartbeatFactory({ + client: this._previewSessionClient, + session: this._previewSession, + accessToken, + logger: this._logger, + }); + this._previewSessionHeartbeat.start(); const localUrl = `http://${PREVIEW_LISTEN_HOST}:${localPort}`; this._logger.log( `Makeswift preview is active. Open ${localUrl.cyan}\n` + @@ -200,7 +211,7 @@ class StencilStart { } shuttingDown = true; try { - await this.stopMakeswiftPreviewTunnel(); + await this.stopMakeswiftPreview(); } finally { this._process.exit(signal === 'SIGINT' ? 130 : 143); } @@ -208,10 +219,12 @@ class StencilStart { this._process.on('SIGINT', onSignal); this._process.on('SIGTERM', onSignal); // Other exits (for example process.exit after an error) must not leave cloudflared running. - this._process.once('exit', () => this.stopMakeswiftPreviewTunnel()); + this._process.once('exit', () => this.stopMakeswiftPreview()); } - stopMakeswiftPreviewTunnel() { + stopMakeswiftPreview() { + this._previewSessionHeartbeat?.stop(); + this._previewSessionHeartbeat = null; const tunnel = this._tunnel; this._tunnel = null; return tunnel ? tunnel.stop() : Promise.resolve(); diff --git a/lib/stencil-start.spec.js b/lib/stencil-start.spec.js index 55fc09d0..9ba69d7a 100644 --- a/lib/stencil-start.spec.js +++ b/lib/stencil-start.spec.js @@ -63,6 +63,8 @@ describe('StencilStart unit tests', () => { createSecret: jest.fn().mockReturnValue(previewSecret), register: jest.fn().mockResolvedValue(registeredSession), }); + const getHeartbeatFactoryStub = () => + jest.fn().mockReturnValue({ start: jest.fn(), stop: jest.fn() }); const getProcessStub = () => ({ on: jest.fn(), once: jest.fn(), @@ -88,6 +90,7 @@ describe('StencilStart unit tests', () => { tunnelManager, previewSessionClient, previewProxyFactory, + previewSessionHeartbeatFactory, processObj, } = {}) => { const passedArgs = { @@ -107,6 +110,8 @@ describe('StencilStart unit tests', () => { tunnelManager: tunnelManager || getTunnelManagerStub(), previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), previewProxyFactory: previewProxyFactory || jest.fn().mockReturnValue(jest.fn()), + previewSessionHeartbeatFactory: + previewSessionHeartbeatFactory || getHeartbeatFactoryStub(), processObj: processObj || getProcessStub(), }; const instance = new StencilStart(passedArgs); @@ -221,6 +226,7 @@ describe('StencilStart unit tests', () => { tunnelManager: tunnelManager || getTunnelManagerStub(), previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), previewProxyFactory: jest.fn().mockReturnValue(previewProxyMiddleware), + previewSessionHeartbeatFactory: getHeartbeatFactoryStub(), stencilConfigManager: getStencilConfigManagerStub({ port, accessToken }), }; const { instance } = createStencilStartInstance(deps); @@ -313,16 +319,37 @@ describe('StencilStart unit tests', () => { ); }); + it('should keep the registered session alive with a heartbeat', async () => { + const { + previewSessionHeartbeatFactory, + previewSessionClient, + logger, + instance, + } = await runStart(); + expect(previewSessionHeartbeatFactory).toHaveBeenCalledWith({ + client: previewSessionClient, + session: instance.previewSession, + accessToken: 'token-123', + logger, + }); + expect(previewSessionHeartbeatFactory.mock.results[0].value.start).toHaveBeenCalled(); + }); + it('should stop the tunnel and continue without preview when registration fails', async () => { const previewSessionClient = getPreviewSessionClientStub(); previewSessionClient.register.mockRejectedValue( new Error('Could not register the Makeswift preview session (HTTP 401).'), ); - const { tunnelManager, browserSync, instance, output } = await runStart({ - previewSessionClient, - }); + const { + tunnelManager, + browserSync, + instance, + output, + previewSessionHeartbeatFactory, + } = await runStart({ previewSessionClient }); const { stop } = await tunnelManager.start.mock.results[0].value; expect(stop).toHaveBeenCalled(); + expect(previewSessionHeartbeatFactory).not.toHaveBeenCalled(); expect(instance.previewSession).toBeNull(); expect(instance.startLocalServer.mock.calls[0][2]).toBeUndefined(); expect(output).toContain('Makeswift preview is not available. Could not register'); @@ -337,11 +364,12 @@ describe('StencilStart unit tests', () => { expect(tunnelManager.start).not.toHaveBeenCalled(); }); - it('should stop the tunnel and exit on SIGINT, and exit at once on a second signal', async () => { - const { tunnelManager, processObj } = await runStart(); + it('should stop the heartbeat and tunnel and exit on SIGINT, and exit at once on a second signal', async () => { + const { tunnelManager, processObj, previewSessionHeartbeatFactory } = await runStart(); const { stop } = await tunnelManager.start.mock.results[0].value; await signalHandler(processObj, 'SIGINT')('SIGINT'); + expect(previewSessionHeartbeatFactory.mock.results[0].value.stop).toHaveBeenCalled(); expect(stop).toHaveBeenCalledTimes(1); expect(processObj.exit).toHaveBeenCalledWith(130);