@swell/cli
Version:
Swell's command line interface/utility
417 lines (376 loc) • 12.3 kB
JavaScript
import * as esbuild from 'esbuild';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
export async function bundleFunction(filePath) {
try {
const buildResult = await esbuild.build({
bundle: true,
entryPoints: [filePath],
format: 'cjs',
metafile: true,
platform: 'node',
target: ['chrome58'],
write: false,
logLevel: 'silent', // Do not output compile errors
});
const { metafile: { inputs }, outputFiles: [{ text: origCode }], } = buildResult;
// TODO: make a separate method to get dependencies for watch command
const dependencies = Object.keys(inputs).filter((input) => !input.startsWith('node_modules/'));
let code = replaceModuleExports(origCode);
// Remove comments with to get a unique hash in the API
// Because esbuild puts the file name as a comment
code = code.replaceAll(/^\n?\/\/.*/gm, '');
// eslint-disable-next-line no-new-func
const evalFn = new Function(`${code}\nreturn { ...moduleExports }`);
const { config, ...moduleExports } = evalFn();
code = await getSwellFunctionWrapper()
.then((wrapper) => minifyCode(code + wrapper, {
format: 'cjs',
platform: 'node',
target: ['chrome58'],
}))
.then(replaceModuleExports);
return { code, config, dependencies, moduleExports };
}
catch (error) {
throw new Error(`Unable to compile function "${filePath}": ${error.message}`, { cause: error });
}
}
export async function bundleWorkflow(filePath, analysis) {
try {
const entrySource = getSwellWorkflowEntrypoint(filePath);
const buildResult = await esbuild.build({
bundle: true,
stdin: {
contents: entrySource,
loader: 'js',
resolveDir: path.dirname(filePath),
},
external: ['cloudflare:workers', 'cloudflare:workflows'],
format: 'esm',
metafile: true,
platform: 'browser',
target: ['es2022'],
write: false,
logLevel: 'silent',
});
const { metafile: { inputs }, outputFiles: [{ text: code }], } = buildResult;
const dependencies = Object.keys(inputs).filter((input) => !input.startsWith('node_modules/'));
return { code, config: analysis.config, dependencies };
}
catch (error) {
throw new Error(`Unable to compile workflow "${filePath}": ${error.message}`, { cause: error });
}
}
export async function bundleComponent(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
const stdinLoader = filePath.endsWith('.tsx') ? 'tsx' : 'jsx';
const buildResult = await esbuild.build({
stdin: {
contents: `
${content}
import { render, h } from "preact";
export const preact = { render, h };
`,
loader: stdinLoader,
resolveDir: path.dirname(filePath),
},
bundle: true,
minify: true,
platform: 'browser',
format: 'iife',
globalName: 'Component',
loader: {
'.ts': 'ts',
'.jsx': 'jsx',
'.tsx': 'tsx',
},
jsxFactory: 'h',
jsxFragment: 'Fragment',
write: false,
logLevel: 'silent',
});
const { outputFiles: [{ text: code }], } = buildResult;
// eslint-disable-next-line no-new-func
const evalFn = new Function(`${code}\nreturn { ...Component }`);
const { config } = evalFn();
return { code, config };
}
catch (error) {
throw new Error(`Unable to compile component "${filePath}": ${error.message}`, { cause: error });
}
}
async function getSwellFunctionWrapper() {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
return fs.readFile(path.join(__dirname, './swell-function-wrapper.js'), 'utf8');
}
function getSwellWorkflowEntrypoint(filePath) {
const workflowImport = `./${path.basename(filePath)}`;
return `
import { WorkflowEntrypoint } from 'cloudflare:workers';
import { NonRetryableError } from 'cloudflare:workflows';
import UserWorkflow from ${JSON.stringify(workflowImport)};
const RUNTIME_PROXY_PATH = '/:workflows/runtime/request';
const RUNTIME_TOKEN_PATTERN = /\\bwf_rt_[0-9A-Za-z]+_[0-9A-Za-z]{12,}\\b/g;
class SwellWorkflowError extends Error {
constructor(body) {
const error = body && body.error ? body.error : body;
super(error && error.message ? error.message : 'Workflow runtime request failed');
this.name = 'SwellError';
this.body = body;
this.code = error && error.code;
this.status = error && error.status;
this.retryable = Boolean(error && error.retryable);
this.category = error && error.category;
}
}
export class SwellWorkflowEntrypoint extends WorkflowEntrypoint {
async run(event, step) {
const invocation = getInvocation(event);
const runtime = invocation.runtime;
const runtimeToken = invocation.runtime_token;
const startedAt = Date.now();
const proxy = new SwellWorkflowRuntimeProxy(runtime, runtimeToken);
const workflow = new UserWorkflow();
const req = buildWorkflowRequest(invocation, proxy);
const workflowStep = buildWorkflowStep(step, proxy);
await proxy.log({ phase: 'run', status: 'started' });
try {
const result = await workflow.run(req, workflowStep);
await proxy.log({ phase: 'run', status: 'completed', time: Date.now() - startedAt });
await proxy.lifecycle('completed');
return result;
} catch (error) {
await proxy.log({
phase: 'run',
status: 'failed',
time: Date.now() - startedAt,
error: formatError(error),
});
try {
await proxy.lifecycle('failed', formatError(error));
} catch (closeoutError) {
await proxy.log({
phase: 'lifecycle',
status: 'failed',
error: formatError(closeoutError),
});
}
throw error;
}
}
}
function getInvocation(event) {
const payload = event && Object.prototype.hasOwnProperty.call(event, 'payload')
? event.payload
: event;
if (!payload || typeof payload !== 'object') {
throw new Error('Workflow invocation payload is required');
}
if (!payload.runtime || !payload.runtime_token) {
throw new Error('Workflow runtime identity is required');
}
return payload;
}
function buildWorkflowRequest(invocation, proxy) {
const runtime = invocation.runtime;
return {
id: runtime.request_id,
appId: runtime.app_slug || runtime.app_id,
store: {
id: runtime.store_id,
},
data: invocation.data,
workflow: {
workflow_id: runtime.workflow_id,
workflow_name: runtime.workflow_name,
workflow_instance_id: runtime.workflow_instance_id,
trigger: runtime.trigger,
request_id: runtime.request_id,
},
isLocalDev: false,
swell: {
get: (path, data) => proxy.api('get', path, data),
post: (path, data) => proxy.api('post', path, data),
put: (path, data) => proxy.api('put', path, data),
delete: (path, data) => proxy.api('delete', path, data),
settings: () => proxy.settings(),
},
};
}
function buildWorkflowStep(cloudflareStep, proxy) {
return Object.freeze({
do(name, optionsOrCallback, maybeCallback) {
const hasOptions = typeof optionsOrCallback !== 'function';
const options = hasOptions ? optionsOrCallback : undefined;
const callback = hasOptions ? maybeCallback : optionsOrCallback;
const wrappedCallback = async () => {
await proxy.log({ phase: 'step', step_name: name, status: 'started' });
try {
const result = await callback();
await proxy.log({ phase: 'step', step_name: name, status: 'completed' });
return result;
} catch (error) {
await proxy.log({
phase: 'step',
step_name: name,
status: 'failed',
error: formatError(error),
});
throw error;
}
};
return hasOptions
? cloudflareStep.do(name, options, wrappedCallback)
: cloudflareStep.do(name, wrappedCallback);
},
sleep(name, duration) {
return cloudflareStep.sleep(name, duration);
},
sleepUntil(name, date) {
return cloudflareStep.sleepUntil(name, date);
},
});
}
class SwellWorkflowRuntimeProxy {
constructor(runtime, runtimeToken) {
this.runtime = runtime;
this.runtimeToken = runtimeToken;
this.apiHost = getRuntimeApiHost(runtime);
}
api(method, path, data) {
return this.request({
type: 'api',
method,
path,
data,
});
}
settings() {
return this.request({
type: 'settings',
});
}
lifecycle(status, error) {
return this.request({
type: 'lifecycle',
status,
error,
});
}
async log(entry) {
try {
return await this.request({
type: 'workflow_log',
entries: [sanitizeLogEntry(entry)],
});
} catch {
return undefined;
}
}
async request(operation) {
const response = await fetch(\`\${this.apiHost}\${RUNTIME_PROXY_PATH}\`, {
method: 'POST',
headers: {
Authorization: \`Bearer \${this.runtimeToken}\`,
'Content-Type': 'application/json',
'User-Agent': 'swell-workflows/1.0',
},
body: JSON.stringify({
runtime: runtimeIdentity(this.runtime),
operation,
}),
});
const text = await response.text();
const body = parseJson(text);
if (!response.ok) {
throw mapRuntimeError(body, response.status);
}
return body;
}
}
function getRuntimeApiHost(runtime) {
const apiHost = runtime && runtime.api_host;
if (!apiHost || typeof apiHost !== 'string') {
throw new Error('Workflow runtime API host is required');
}
if (!/^https?:\\/\\//.test(apiHost)) {
throw new Error('Workflow runtime API host must be an HTTP URL');
}
return apiHost.replace(/\\/$/, '');
}
function runtimeIdentity(runtime) {
return {
app_id: runtime.app_id,
store_id: runtime.store_id,
environment_id: runtime.environment_id || null,
workflow_id: runtime.workflow_id,
workflow_name: runtime.workflow_name,
workflow_instance_id: runtime.workflow_instance_id,
};
}
function parseJson(text) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
function mapRuntimeError(body, status) {
const error = body && body.error ? body.error : {};
const runtimeError = new SwellWorkflowError({
error: {
...error,
status: error.status || status,
},
});
if (error.retryable === false) {
return new NonRetryableError(runtimeError.message);
}
return runtimeError;
}
function sanitizeLogEntry(entry) {
return {
...entry,
error: entry && entry.error ? formatError(entry.error) : undefined,
};
}
function formatError(error) {
if (!error) {
return undefined;
}
const code = error.code ? String(error.code).replace(RUNTIME_TOKEN_PATTERN, '[redacted]') : undefined;
const message = String(error.message || error)
.replace(RUNTIME_TOKEN_PATTERN, '[redacted]')
.slice(0, 1000);
return {
code,
message,
};
}
`;
}
async function minifyCode(code, options) {
try {
const result = await esbuild.transform(code, {
...options,
minifyIdentifiers: false,
minifySyntax: false,
minifyWhitespace: true,
});
return result.code;
}
catch (error) {
throw new Error(`Code minification failed: ${error.message}`, {
cause: error,
});
}
}
function replaceModuleExports(code) {
return code
.replaceAll(/^module\.exports\b/gm, 'var moduleExports')
.replaceAll(/\bmodule\.exports\b/gm, 'moduleExports');
}