@swell/cli
Version:
Swell's command line interface/utility
624 lines (623 loc) • 21.4 kB
JavaScript
const originalConsole = {
log: console.log,
info: console.info,
debug: console.debug,
warn: console.warn,
error: console.error,
};
const WORKFLOW_PARAMS_MAX_BYTES = 128 * 1024;
addEventListener('fetch', (event) => {
event.respondWith(request(event.request, event.env, event));
});
/**
* Handle a function request.
* @param {Request} originalRequest from cloudflare
* @param {*} _env cloudflare environment vars
* @param {*} context cloudflare conttext
* @returns {Promise<SwellResponse>}
*/
async function request(originalRequest, _env, context) {
const req = new SwellRequest(originalRequest, context);
await req.initialize();
let response;
try {
response = await executeModuleHandler(req, context);
}
catch (error) {
// Log the error for Swell
console.error(error);
const errorBody = {
...(error.body ?? { error: error.message }),
retry: error.retry === false ? false : undefined,
};
response = new SwellResponse(errorBody, { status: error.status || 500 });
}
return SwellResponse._respond(req, response, context);
}
/**
* Invokes one of the function types that are available.
*
* Type-1: Named export function handlers
* ```
* export function post (req) {
*
* }
* ```
*
* Important note:
* - `delete` func cannot be implemented in Type-1 since it's a reserved name. So, consider using Type-3.
*
* Type-2: Default export function handlers
* ```
* export default function (req) {
*
* }
* ```
*
* Type-3: Default object with named function handlers
* ```
* export default {
* delete(req) {
*
* },
* post(req) {
*
* },
* }
* ```
*
* @param {SwellRequest} req
* @param {Event} context
*/
async function executeModuleHandler(req, context) {
const method = req.method.toLowerCase();
const defaults = moduleExports.default;
if (moduleExports[method]) {
return moduleExports[method](req, context);
}
if (defaults) {
if (typeof defaults === 'function') {
return defaults(req, context);
}
if (defaults[method]) {
return defaults[method](req, context);
}
}
throw new Error(`Function does not export a method to handle ${method.toUpperCase()} requests`);
}
/**
* Class representing a Swell request.
*/
class SwellRequest {
constructor(req, context) {
this.originalRequest = req;
this.assignRequestProps(req);
// Set environment specific variables
this.context = context;
// Slug-form app identifier (e.g. 'klaviyo') matching keys in record.$app[...].
// Derived from the app's private_id with the leading underscore stripped.
this.appId = req.headers.get('Swell-App-Id');
this.storeId = req.headers.get('Swell-Store-Id');
this.accessToken = req.headers.get('Swell-Access-Token');
this.publicKey = req.headers.get('Swell-Public-Key');
this.store = this.parseJson(req.headers.get('Swell-Store-Details'));
this.session = this.parseJson(req.headers.get('Swell-Session'));
this.logParams = this.parseJson(req.headers.get('Swell-Request-Log'));
this.apiHost = req.headers.get('Swell-API-Host') || 'https://api.schema.io';
this.id = req.headers.get('Swell-Request-ID') || this.logParams?.req_id;
// Check if the request is from a local development environment
this.isLocalDev = req.headers.get('Swell-Local-Dev') === 'true';
// Swell client
this.swell = new SwellAPI(this, context);
// URL of the original request
this.url;
// Raw request body text, untouched by parsing.
// Use on route triggers for HMAC/webhook signature verification.
this.rawBody = '';
// Parsed JSON body as object, or raw text string when body isn't JSON.
this.body = {};
// URL query parameters as an object
this.query = {};
// Combined object of body and query parameters (query keys overwrite body keys)
this.data = {};
// Internal logs
this._logs = [];
}
assignRequestProps(req) {
for (const prop of ['ur', 'method', 'headers', 'referrer', 'credentials']) {
this[prop] = req[prop];
}
}
async initialize() {
this.rawBody = await this.originalRequest.text();
this.body = this.rawBody;
try {
this.data = JSON.parse(this.rawBody);
this.body = { ...this.data };
}
catch {
this.data = {};
}
this.url = new URL(this.originalRequest.url);
// Convert the query parameters to an object
for (const [key, value] of this.url.searchParams.entries()) {
this.query[key] = value;
this.data[key] = value;
}
// Bind the console methods to the request
console.log = this.log.bind(this, 'info');
console.info = this.log.bind(this, 'info');
console.debug = this.log.bind(this, 'debug');
console.warn = this.log.bind(this, 'warn');
console.error = this.log.bind(this, 'error');
}
parseJson(input) {
try {
return JSON.parse(input);
}
catch {
return {};
}
}
log(level, ...line) {
originalConsole[level]?.(...line);
this._logs.push({
date: Date.now(),
line: line.map((l) => (l instanceof Error ? l.stack : JSON.stringify(l))),
...(level === 'info' ? {} : { level }),
});
}
getIngestableLogs(response) {
if (!this.logParams || this.isLocalDev) {
return;
}
if (this.logParams.$start) {
this.logParams.time = Date.now() - this.logParams.$start;
delete this.logParams.$start;
}
return {
params: {
...this.logParams,
message: {
...this.logParams?.message,
data: this.logParams?.message?.data || this.formatRequestData(),
logs: this._logs,
status: response.status,
},
},
};
}
formatRequestData() {
try {
const stringData = JSON.stringify(this.body ?? null);
return stringData.slice(0, 1024000);
}
catch {
return '';
}
}
async ingestLogs(response) {
const ingestableLogs = this.getIngestableLogs(response);
if (!ingestableLogs) {
return;
}
const result = await this.swell.post('/:logs', {
$ingest_function_logs: ingestableLogs,
});
if (!result?.success) {
console.error('Error ingesting logs', result);
}
}
/**
* Merge values into app data for the current request.
* @param {object|string} idOrValues string to indicate app ID, or values to merge
* @param {object|undefined} values values to merge into app data
* @returns {object} existing app data merged with values
* @throws {Error} if app id is missing or values is not a plain object
*/
appValues(idOrValues, values) {
const appId = typeof idOrValues === 'string' ? idOrValues : this.appId;
const appValues = typeof idOrValues === 'string' ? values : idOrValues;
if (!appId) {
throw new Error('appValues: missing app id (req.appId is empty)');
}
if (!isOrdinaryObject(appValues)) {
throw new Error('appValues: values must be a plain object (arrays, class instances, null, and primitives are not allowed)');
}
return {
$app: {
[appId]: appValues,
},
};
}
reject(code, message, options = {}) {
return new SwellRejection(code, message, options);
}
}
/**
* Class representing the Swell backend API.
*/
class SwellAPI {
constructor(req, context) {
this.request = req;
this.baseUrl = req.apiHost;
this.basicAuth = `${req.storeId}:${req.accessToken}`;
this.context = context;
this.workflows = {
create: (name, params) => this.createWorkflow(name, params),
};
}
toBase64(inputString) {
const utf8Bytes = new TextEncoder().encode(inputString);
let base64String = '';
for (let i = 0; i < utf8Bytes.length; i += 3) {
const chunk = utf8Bytes.slice(i, i + 3);
base64String += btoa(String.fromCharCode(...chunk));
}
return base64String;
}
stringifyQuery(queryObject, prefix) {
const result = [];
for (const [key, value] of Object.entries(queryObject)) {
const prefixKey = prefix ? `${prefix}[${key}]` : key;
const isObject = value !== null && typeof value === 'object';
const encodedResult = isObject
? this.stringifyQuery(value, prefixKey)
: `${encodeURIComponent(prefixKey)}=${encodeURIComponent(value)}`;
result.push(encodedResult);
}
return result.join('&');
}
async makeRequest(method, url, data) {
const requestOptions = {
method,
headers: {
Authorization: `Basic ${this.toBase64(this.basicAuth)}`,
'User-Agent': 'swell-functions/1.0',
'Content-Type': 'application/json',
...(this.request.id ? { 'Swell-Request-ID': this.request.id } : {}),
},
};
let query = '';
if (data) {
try {
if (method === 'GET') {
query = `?${this.stringifyQuery(data)}`;
}
else {
requestOptions.body = JSON.stringify(data);
requestOptions.headers['Content-Length'] = requestOptions.body.length;
}
}
catch {
throw new Error(`Error serializing data: ${data}`);
}
}
const endpointUrl = String(url).startsWith('/') ? url.slice(1) : url;
const response = await fetch(`${this.baseUrl}/${endpointUrl}${query}`, requestOptions);
const responseText = await response.text();
let result;
try {
result = JSON.parse(responseText);
}
catch {
result = String(responseText || '').trim();
}
if (response.status > 299) {
throw new SwellError(result, {
status: response.status,
method,
endpointUrl,
});
}
else if (method !== 'GET' && result?.errors) {
throw new SwellError(result.errors, { status: 400, method, endpointUrl });
}
return result;
}
async get(url, query) {
return this.makeRequest('GET', url, query);
}
async put(url, data) {
return this.makeRequest('PUT', url, data);
}
async post(url, data) {
return this.makeRequest('POST', url, data);
}
async delete(url, data) {
return this.makeRequest('DELETE', url, data);
}
async settings(id = this.request.appId) {
return this.makeRequest('GET', `/settings/${id}`);
}
async createWorkflow(name, params) {
const data = {
workflow_name: name,
};
if (params !== undefined) {
data.params = validateWorkflowParams(params);
}
return this.makeRequest('POST', '/:workflows/instances', data);
}
/**
* Atomic multi-op write. Throws SwellError with a stable `error.code`
* (transaction_conflict | transaction_timeout | transaction_throttled
* | transaction_op_failed | transaction_error). Retry is off by
* default — opt in with `{ retry: true }` or pass overrides.
*
* @param {Array<{method: string, url: string, data?: any}>} ops
* @param {{ retry?: true | { limit?: number, base?: number, max?: number, jitter?: boolean } }} [opts]
* @returns {Promise<any[]>}
*/
async transaction(ops, opts = {}) {
if (!opts.retry) {
return this.makeRequest('POST', '/:transaction', ops);
}
const cfg = {
...DEFAULT_RETRY,
...(opts.retry === true ? {} : opts.retry),
};
let attempt = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await this.makeRequest('POST', '/:transaction', ops);
}
catch (error) {
if (!(error instanceof SwellError) ||
!error.isRetryable ||
attempt >= cfg.limit) {
throw error;
}
const delay = Math.min(cfg.base * 2 ** attempt, cfg.max);
const wait = cfg.jitter ? delay * (0.5 + Math.random() * 0.5) : delay;
await new Promise((resolve) => setTimeout(resolve, wait));
attempt++;
}
}
}
}
const DEFAULT_RETRY = Object.freeze({
limit: 3,
base: 100,
max: 5000,
jitter: true,
});
const RETRYABLE_CODES = new Set([
'transaction_conflict',
'transaction_throttled',
]);
/**
* Class representing a Swell error.
*/
class SwellError extends Error {
constructor(message, options = {}) {
const body = typeof message === 'string' ? undefined : message;
let formattedMessage;
if (typeof message === 'string') {
formattedMessage = message;
}
else if (typeof body?.error?.message === 'string') {
formattedMessage = body.error.message;
}
else {
formattedMessage = JSON.stringify(message, null, 2);
}
if (options.method && options.endpointUrl) {
formattedMessage = `${options.method} /${options.endpointUrl}\n${formattedMessage}`;
}
super(formattedMessage);
this.name = 'SwellError';
this.status = options.status || 500;
this.body = body;
this.code = options.code || body?.error?.code;
this.retry = options.retry;
}
get isRetryable() {
return RETRYABLE_CODES.has(this.code);
}
}
class SwellRejection extends Error {
constructor(code, message, options = {}) {
const status = typeof options.status === 'number' &&
options.status >= 400 &&
options.status < 500
? options.status
: 422;
super(message || 'Request rejected by function');
this.name = 'SwellRejection';
this.status = status;
this.code = code;
this.body = {
$reject: {
code,
message: this.message,
status,
},
};
}
}
/**
* Class representing a Swell response.
*/
class SwellResponse extends Response {
constructor(data, options = {}) {
const resultHeaders = {};
let result = '';
if (typeof data === 'string') {
result = data;
resultHeaders['Content-Type'] = 'text/plain;charset=UTF-8';
}
else if (data !== undefined) {
result = JSON.stringify(data, null, 2);
resultHeaders['Content-Type'] = 'application/json;charset=UTF-8';
}
super(result?.toString?.('utf-8'), {
status: 200,
...options,
headers: {
...resultHeaders,
...options.headers,
},
});
// Saved for future access
this._swellData = data;
this._swellOptions = options || {};
}
static async _respond(req, response, context) {
let finalResponse = response;
const isHook = Boolean(req.data?.$event?.hook);
if (finalResponse instanceof Response &&
!(finalResponse instanceof SwellResponse)) {
// Non-hook responses pass through unchanged so the handler's body,
// status, and headers reach the caller intact. Hooks need the parsed
// body so $logs can be merged into the payload below.
if (isHook) {
finalResponse =
await SwellResponse._consumeNativeResponse(finalResponse);
}
}
else if (!(finalResponse instanceof SwellResponse)) {
finalResponse = new SwellResponse(response);
}
// Send logs back with the response for event hooks
if (isHook) {
return SwellResponse._respondWithLogs(finalResponse, req);
}
// Ingest logs in the background
context.waitUntil(req.ingestLogs(finalResponse));
return finalResponse;
}
static async _consumeNativeResponse(response) {
const headers = {};
for (const [key, value] of response.headers.entries()) {
headers[key] = value;
}
try {
const text = await response.text();
let data;
try {
data = JSON.parse(text);
}
catch {
data = text;
}
return new SwellResponse(data, {
status: response.status,
headers,
});
}
catch (error) {
return new SwellResponse({ error: `Unable to read response body: ${error.message}` }, { status: 500 });
}
}
static _respondWithLogs(response, req) {
const ingestableLogs = req.getIngestableLogs(response);
// Rebuild response with logs
const responseData = response instanceof SwellResponse ? response?._swellData : response;
const resultData = ingestableLogs
? {
$logs: ingestableLogs,
$data: responseData,
}
: responseData;
return new SwellResponse(resultData, response?._swellOptions);
}
}
/**
* Helper to determine if a value is an ordinary object.
* @param {any} obj
* @returns {boolean}
*/
function isOrdinaryObject(val) {
return (typeof val === 'object' &&
val !== null &&
Object.getPrototypeOf(val) === Object.prototype);
}
function validateWorkflowParams(params) {
validateWorkflowParamValue(params);
const serialized = JSON.stringify(params);
const size = new TextEncoder().encode(serialized).length;
if (size > WORKFLOW_PARAMS_MAX_BYTES) {
throw createWorkflowParamsError('workflow_params_too_large', 'Workflow params are too large; pass identifiers and re-fetch data inside the workflow');
}
return params;
}
function validateWorkflowParamValue(value, seen = new WeakSet()) {
if (value === null) {
return;
}
switch (typeof value) {
case 'string':
case 'boolean':
return;
case 'number':
if (Number.isFinite(value)) {
return;
}
break;
case 'object': {
if (Array.isArray(value)) {
validateWorkflowParamArray(value, seen);
return;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
break;
}
validateWorkflowParamObject(value, seen);
return;
}
default:
break;
}
throw createWorkflowParamsError('workflow_params_unserializable', 'Workflow params must be JSON-safe values');
}
function validateWorkflowParamArray(value, seen) {
if (seen.has(value)) {
throw createWorkflowParamsError('workflow_params_unserializable', 'Workflow params must be JSON-safe values');
}
seen.add(value);
if (Object.getOwnPropertySymbols(value).length > 0) {
throw createWorkflowParamsError('workflow_params_unserializable', 'Workflow params must be JSON-safe values');
}
for (const key of Object.keys(value)) {
if (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= value.length) {
throw createWorkflowParamsError('workflow_params_unserializable', 'Workflow params must be JSON-safe values');
}
}
for (let i = 0; i < value.length; i += 1) {
if (!Object.prototype.hasOwnProperty.call(value, i)) {
throw createWorkflowParamsError('workflow_params_unserializable', 'Workflow params must be JSON-safe values');
}
validateWorkflowParamValue(value[i], seen);
}
seen.delete(value);
}
function validateWorkflowParamObject(value, seen) {
if (seen.has(value)) {
throw createWorkflowParamsError('workflow_params_unserializable', 'Workflow params must be JSON-safe values');
}
seen.add(value);
if (Object.getOwnPropertySymbols(value).length > 0) {
throw createWorkflowParamsError('workflow_params_unserializable', 'Workflow params must be JSON-safe values');
}
for (const item of Object.values(value)) {
validateWorkflowParamValue(item, seen);
}
seen.delete(value);
}
function createWorkflowParamsError(code, message) {
return new SwellError({
error: {
code,
message,
status: 400,
retryable: false,
},
}, {
code,
status: 400,
});
}
export {};