Skip to content
Open
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
76 changes: 53 additions & 23 deletions lib/PreviewSessionClient.js
Original file line number Diff line number Diff line change
@@ -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;

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

Expand Down
42 changes: 42 additions & 0 deletions lib/PreviewSessionClient.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
117 changes: 117 additions & 0 deletions lib/PreviewSessionHeartbeat.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading