UNPKG

@swell/cli

Version:

Swell's command line interface/utility

624 lines (623 loc) 21.4 kB
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 {};