@swell/cli
Version:
Swell's command line interface/utility
395 lines (394 loc) • 13.1 kB
JavaScript
"use strict";
const originalConsole = {
log: console.log,
info: console.info,
debug: console.debug,
warn: console.warn,
error: console.error,
};
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 (err) {
// Log the error for Swell
console.error(err);
response = new SwellResponse({ error: err.message }, { status: err.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);
}
else if (defaults) {
if (typeof defaults === 'function') {
return defaults(req, context);
}
else 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;
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;
// Original body of the request, JSON if applicable
this.body = {};
// URL query parameters as an object
this.query = {};
// Combined object of body and query parameters
this.data = {};
// Internal logs
this._logs = [];
}
assignRequestProps(req) {
['ur', 'method', 'headers', 'referrer', 'credentials'].forEach((prop) => {
this[prop] = req[prop];
});
}
async initialize() {
this.body = await this.originalRequest.text();
try {
this.data = JSON.parse(this.body);
this.body = { ...this.data };
}
catch (err) {
this.data = {};
}
this.url = new URL(this.originalRequest.url);
// Convert the query parameters to an object
this.url.searchParams.forEach((value, key) => {
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 (err) {
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,
logs: this._logs,
status: response.status,
},
},
};
}
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|undefined} existing app data merged with values if passed
*/
appValues(idOrValues, values = undefined) {
const appId = typeof idOrValues === 'string' ? appIdOrValues : this.appId;
const appValues = typeof idOrValues === 'string' ? values : idOrValues;
if (!appId || !isOrdinaryObject(appValues)) {
return undefined;
}
return {
$app: {
[appId]: appValues,
},
};
}
}
/**
* 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;
}
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.substring(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}`);
}
}
/**
* Class representing a Swell error.
*/
class SwellError extends Error {
constructor(message, options = {}) {
let formattedMessage;
if (typeof message === 'string') {
formattedMessage = 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;
}
}
/**
* 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 _respond(req, response, context) {
let finalResponse = response;
// Convert a plain Response instance to SwellResponse
if (finalResponse instanceof Response &&
!(finalResponse instanceof SwellResponse)) {
finalResponse = new SwellResponse(response.body, {
status: response.status,
headers: response.headers,
});
}
else if (!(finalResponse instanceof SwellResponse)) {
finalResponse = new SwellResponse(response);
}
// Send logs back with the response for event hooks
if (req.data?.$event?.hook) {
return SwellResponse._respondWithLogs(finalResponse, req);
}
// Ingest logs in the background
context.waitUntil(req.ingestLogs(finalResponse));
return finalResponse;
}
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);
}