UNPKG

@swell/cli

Version:

Swell's command line interface/utility

417 lines (376 loc) 12.3 kB
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'); }