@swell/cli 2.9.0 → 2.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,8 @@ import isEmpty from 'lodash/isEmpty.js';
3
3
  import { detectFilenameMime } from 'mime-detect';
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
- import { bundleFunction, bundleComponent } from '../bundle.js';
6
+ import { bundleFunction, bundleComponent, bundleWorkflow } from '../bundle.js';
7
+ import { analyzeFunctionSource, hasKindDiagnostic, workflowStaticConfigError, } from '../function-source-analysis.js';
7
8
  import { AllConfigPaths, ConfigType, filePathExists, hashFile, } from './index.js';
8
9
  export class IgnoringFileError extends Error {
9
10
  constructor(message) {
@@ -257,10 +258,32 @@ export class AppConfigFunction extends AppConfig {
257
258
  if (!fileData) {
258
259
  return;
259
260
  }
260
- const { code, config } = await bundleFunction(this.filePath);
261
+ const analysis = await analyzeFunctionSource(this.appPath);
262
+ if (analysis.kind === 'workflow') {
263
+ if (analysis.diagnostics.length > 0) {
264
+ throw new Error(analysis.diagnostics.join('\n'));
265
+ }
266
+ const { code, config } = await bundleWorkflow(this.appPath, analysis);
267
+ postData.file = {
268
+ data: fileData,
269
+ };
270
+ postData.build_file = {
271
+ content_type: 'application/javascript+module',
272
+ data: code,
273
+ };
274
+ postData.values = config;
275
+ return postData;
276
+ }
277
+ if (hasKindDiagnostic(analysis)) {
278
+ throw new Error(analysis.diagnostics.join('\n'));
279
+ }
280
+ const { code, config } = await bundleFunction(this.appPath);
261
281
  if (!config) {
262
282
  throw new IgnoringFileError('Function must export a `config` object.');
263
283
  }
284
+ if (config.kind === 'workflow') {
285
+ throw workflowStaticConfigError();
286
+ }
264
287
  // Save the original file and the bundled version
265
288
  postData.file = {
266
289
  data: fileData,
@@ -354,8 +354,11 @@ export function appAssetImage(appPath, fileName) {
354
354
  }
355
355
  }
356
356
  }
357
+ function getNameFromFilePath(filePath) {
358
+ return path.parse(filePath).name.replaceAll('_', '-');
359
+ }
357
360
  export function appConfigFromFile(filePath, configType, appPath) {
358
- const { name } = path.parse(filePath);
361
+ const name = getNameFromFilePath(filePath);
359
362
  // Notification basenames must not contain dots: the inspect identifier
360
363
  // grammar (app.<slug>.<model>.<name>) splits on '.', so a dotted name
361
364
  // would silently misparse on lookup.
@@ -376,7 +379,7 @@ export function appConfigFromFile(filePath, configType, appPath) {
376
379
  return config;
377
380
  }
378
381
  export function findAppConfig(app, filePath, configType) {
379
- const { name } = path.parse(filePath);
382
+ const name = getNameFromFilePath(filePath);
380
383
  const config = app?.configs?.find((c) => c && c.type === configType && c.name === name);
381
384
  return config;
382
385
  }
@@ -1,2 +1,4 @@
1
+ import type { FunctionSourceAnalysis } from './function-source-analysis.js';
1
2
  export declare function bundleFunction(filePath: string): Promise<any>;
3
+ export declare function bundleWorkflow(filePath: string, analysis: FunctionSourceAnalysis): Promise<any>;
2
4
  export declare function bundleComponent(filePath: string): Promise<any>;
@@ -36,6 +36,32 @@ export async function bundleFunction(filePath) {
36
36
  throw new Error(`Unable to compile function ${filePath}: ${error.message}`);
37
37
  }
38
38
  }
39
+ export async function bundleWorkflow(filePath, analysis) {
40
+ try {
41
+ const entrySource = getSwellWorkflowEntrypoint(filePath);
42
+ const buildResult = await esbuild.build({
43
+ bundle: true,
44
+ stdin: {
45
+ contents: entrySource,
46
+ loader: 'js',
47
+ resolveDir: path.dirname(filePath),
48
+ },
49
+ external: ['cloudflare:workers', 'cloudflare:workflows'],
50
+ format: 'esm',
51
+ metafile: true,
52
+ platform: 'browser',
53
+ target: ['es2022'],
54
+ write: false,
55
+ logLevel: 'silent',
56
+ });
57
+ const { metafile: { inputs }, outputFiles: [{ text: code }], } = buildResult;
58
+ const dependencies = Object.keys(inputs).filter((input) => !input.startsWith('node_modules/'));
59
+ return { code, config: analysis.config, dependencies };
60
+ }
61
+ catch (error) {
62
+ throw new Error(`Unable to compile workflow ${filePath}: ${error.message}`);
63
+ }
64
+ }
39
65
  export async function bundleComponent(filePath) {
40
66
  try {
41
67
  const content = fs.readFileSync(filePath, 'utf8');
@@ -80,6 +106,292 @@ function getSwellFunctionWrapper() {
80
106
  const __dirname = path.dirname(__filename);
81
107
  return fs.readFileSync(path.join(__dirname, './swell-function-wrapper.js'), 'utf8');
82
108
  }
109
+ function getSwellWorkflowEntrypoint(filePath) {
110
+ const workflowImport = `./${path.basename(filePath)}`;
111
+ return `
112
+ import { WorkflowEntrypoint } from 'cloudflare:workers';
113
+ import { NonRetryableError } from 'cloudflare:workflows';
114
+ import UserWorkflow from ${JSON.stringify(workflowImport)};
115
+
116
+ const RUNTIME_PROXY_PATH = '/:workflows/runtime/request';
117
+ const RUNTIME_TOKEN_PATTERN = /\\bwf_rt_[0-9A-Za-z]+_[0-9A-Za-z]{12,}\\b/g;
118
+
119
+ class SwellWorkflowError extends Error {
120
+ constructor(body) {
121
+ const error = body && body.error ? body.error : body;
122
+ super(error && error.message ? error.message : 'Workflow runtime request failed');
123
+ this.name = 'SwellError';
124
+ this.body = body;
125
+ this.code = error && error.code;
126
+ this.status = error && error.status;
127
+ this.retryable = Boolean(error && error.retryable);
128
+ this.category = error && error.category;
129
+ }
130
+ }
131
+
132
+ export class SwellWorkflowEntrypoint extends WorkflowEntrypoint {
133
+ async run(event, step) {
134
+ const invocation = getInvocation(event);
135
+ const runtime = invocation.runtime;
136
+ const runtimeToken = invocation.runtime_token;
137
+ const startedAt = Date.now();
138
+ const proxy = new SwellWorkflowRuntimeProxy(runtime, runtimeToken);
139
+ const workflow = new UserWorkflow();
140
+ const req = buildWorkflowRequest(invocation, proxy);
141
+ const workflowStep = buildWorkflowStep(step, proxy);
142
+
143
+ await proxy.log({ phase: 'run', status: 'started' });
144
+
145
+ try {
146
+ const result = await workflow.run(req, workflowStep);
147
+ await proxy.log({ phase: 'run', status: 'completed', time: Date.now() - startedAt });
148
+ await proxy.lifecycle('completed');
149
+ return result;
150
+ } catch (error) {
151
+ await proxy.log({
152
+ phase: 'run',
153
+ status: 'failed',
154
+ time: Date.now() - startedAt,
155
+ error: formatError(error),
156
+ });
157
+
158
+ try {
159
+ await proxy.lifecycle('failed', formatError(error));
160
+ } catch (closeoutError) {
161
+ await proxy.log({
162
+ phase: 'lifecycle',
163
+ status: 'failed',
164
+ error: formatError(closeoutError),
165
+ });
166
+ }
167
+
168
+ throw error;
169
+ }
170
+ }
171
+ }
172
+
173
+ function getInvocation(event) {
174
+ const payload = event && Object.prototype.hasOwnProperty.call(event, 'payload')
175
+ ? event.payload
176
+ : event;
177
+
178
+ if (!payload || typeof payload !== 'object') {
179
+ throw new Error('Workflow invocation payload is required');
180
+ }
181
+
182
+ if (!payload.runtime || !payload.runtime_token) {
183
+ throw new Error('Workflow runtime identity is required');
184
+ }
185
+
186
+ return payload;
187
+ }
188
+
189
+ function buildWorkflowRequest(invocation, proxy) {
190
+ const runtime = invocation.runtime;
191
+
192
+ return {
193
+ id: runtime.request_id,
194
+ appId: runtime.app_slug || runtime.app_id,
195
+ store: {
196
+ id: runtime.store_id,
197
+ },
198
+ data: invocation.data,
199
+ workflow: {
200
+ workflow_id: runtime.workflow_id,
201
+ workflow_name: runtime.workflow_name,
202
+ workflow_instance_id: runtime.workflow_instance_id,
203
+ trigger: runtime.trigger,
204
+ request_id: runtime.request_id,
205
+ },
206
+ isLocalDev: false,
207
+ swell: {
208
+ get: (path, data) => proxy.api('get', path, data),
209
+ post: (path, data) => proxy.api('post', path, data),
210
+ put: (path, data) => proxy.api('put', path, data),
211
+ delete: (path, data) => proxy.api('delete', path, data),
212
+ settings: () => proxy.settings(),
213
+ },
214
+ };
215
+ }
216
+
217
+ function buildWorkflowStep(cloudflareStep, proxy) {
218
+ return Object.freeze({
219
+ do(name, optionsOrCallback, maybeCallback) {
220
+ const hasOptions = typeof optionsOrCallback !== 'function';
221
+ const options = hasOptions ? optionsOrCallback : undefined;
222
+ const callback = hasOptions ? maybeCallback : optionsOrCallback;
223
+
224
+ const wrappedCallback = async () => {
225
+ await proxy.log({ phase: 'step', step_name: name, status: 'started' });
226
+
227
+ try {
228
+ const result = await callback();
229
+ await proxy.log({ phase: 'step', step_name: name, status: 'completed' });
230
+ return result;
231
+ } catch (error) {
232
+ await proxy.log({
233
+ phase: 'step',
234
+ step_name: name,
235
+ status: 'failed',
236
+ error: formatError(error),
237
+ });
238
+ throw error;
239
+ }
240
+ };
241
+
242
+ return hasOptions
243
+ ? cloudflareStep.do(name, options, wrappedCallback)
244
+ : cloudflareStep.do(name, wrappedCallback);
245
+ },
246
+
247
+ sleep(name, duration) {
248
+ return cloudflareStep.sleep(name, duration);
249
+ },
250
+
251
+ sleepUntil(name, date) {
252
+ return cloudflareStep.sleepUntil(name, date);
253
+ },
254
+ });
255
+ }
256
+
257
+ class SwellWorkflowRuntimeProxy {
258
+ constructor(runtime, runtimeToken) {
259
+ this.runtime = runtime;
260
+ this.runtimeToken = runtimeToken;
261
+ this.apiHost = getRuntimeApiHost(runtime);
262
+ }
263
+
264
+ api(method, path, data) {
265
+ return this.request({
266
+ type: 'api',
267
+ method,
268
+ path,
269
+ data,
270
+ });
271
+ }
272
+
273
+ settings() {
274
+ return this.request({
275
+ type: 'settings',
276
+ });
277
+ }
278
+
279
+ lifecycle(status, error) {
280
+ return this.request({
281
+ type: 'lifecycle',
282
+ status,
283
+ error,
284
+ });
285
+ }
286
+
287
+ async log(entry) {
288
+ try {
289
+ return await this.request({
290
+ type: 'workflow_log',
291
+ entries: [sanitizeLogEntry(entry)],
292
+ });
293
+ } catch {
294
+ return undefined;
295
+ }
296
+ }
297
+
298
+ async request(operation) {
299
+ const response = await fetch(\`\${this.apiHost}\${RUNTIME_PROXY_PATH}\`, {
300
+ method: 'POST',
301
+ headers: {
302
+ Authorization: \`Bearer \${this.runtimeToken}\`,
303
+ 'Content-Type': 'application/json',
304
+ 'User-Agent': 'swell-workflows/1.0',
305
+ },
306
+ body: JSON.stringify({
307
+ runtime: runtimeIdentity(this.runtime),
308
+ operation,
309
+ }),
310
+ });
311
+ const text = await response.text();
312
+ const body = parseJson(text);
313
+
314
+ if (!response.ok) {
315
+ throw mapRuntimeError(body, response.status);
316
+ }
317
+
318
+ return body;
319
+ }
320
+ }
321
+
322
+ function getRuntimeApiHost(runtime) {
323
+ const apiHost = runtime && runtime.api_host;
324
+
325
+ if (!apiHost || typeof apiHost !== 'string') {
326
+ throw new Error('Workflow runtime API host is required');
327
+ }
328
+
329
+ if (!/^https?:\\/\\//.test(apiHost)) {
330
+ throw new Error('Workflow runtime API host must be an HTTP URL');
331
+ }
332
+
333
+ return apiHost.replace(/\\/$/, '');
334
+ }
335
+
336
+ function runtimeIdentity(runtime) {
337
+ return {
338
+ app_id: runtime.app_id,
339
+ store_id: runtime.store_id,
340
+ environment_id: runtime.environment_id || null,
341
+ workflow_id: runtime.workflow_id,
342
+ workflow_name: runtime.workflow_name,
343
+ workflow_instance_id: runtime.workflow_instance_id,
344
+ };
345
+ }
346
+
347
+ function parseJson(text) {
348
+ try {
349
+ return JSON.parse(text);
350
+ } catch {
351
+ return text;
352
+ }
353
+ }
354
+
355
+ function mapRuntimeError(body, status) {
356
+ const error = body && body.error ? body.error : {};
357
+ const runtimeError = new SwellWorkflowError({
358
+ error: {
359
+ ...error,
360
+ status: error.status || status,
361
+ },
362
+ });
363
+
364
+ if (error.retryable === false) {
365
+ return new NonRetryableError(runtimeError.message);
366
+ }
367
+
368
+ return runtimeError;
369
+ }
370
+
371
+ function sanitizeLogEntry(entry) {
372
+ return {
373
+ ...entry,
374
+ error: entry && entry.error ? formatError(entry.error) : undefined,
375
+ };
376
+ }
377
+
378
+ function formatError(error) {
379
+ if (!error) {
380
+ return undefined;
381
+ }
382
+
383
+ const code = error.code ? String(error.code).replace(RUNTIME_TOKEN_PATTERN, '[redacted]') : undefined;
384
+ const message = String(error.message || error)
385
+ .replace(RUNTIME_TOKEN_PATTERN, '[redacted]')
386
+ .slice(0, 1000);
387
+
388
+ return {
389
+ code,
390
+ message,
391
+ };
392
+ }
393
+ `;
394
+ }
83
395
  async function minifyCode(code) {
84
396
  try {
85
397
  const result = await esbuild.transform(code, {
@@ -1,4 +1,4 @@
1
- type FunctionType = 'cron' | 'model' | 'route';
1
+ type FunctionType = 'cron' | 'model' | 'route' | 'workflow';
2
2
  type FunctionLanguage = 'js' | 'ts';
3
3
  interface CreateFunctionArgs {
4
4
  functionName: string;
@@ -14,5 +14,5 @@ interface WriteFunctionFileParams {
14
14
  schedule: string;
15
15
  trigger: FunctionType;
16
16
  }
17
- declare const getFunctionTemplate: ({ description, events, extension, language, route, schedule, trigger, }: WriteFunctionFileParams) => string;
17
+ declare const getFunctionTemplate: ({ description, events, extension, functionName, language, route, schedule, trigger, }: WriteFunctionFileParams) => string;
18
18
  export { CreateFunctionArgs, FunctionLanguage, FunctionType, WriteFunctionFileParams, getFunctionTemplate, };
@@ -1,4 +1,11 @@
1
- const getFunctionTemplate = ({ description, events, extension, language, route, schedule, trigger, }) => {
1
+ const getFunctionTemplate = ({ description, events, extension, functionName, language, route, schedule, trigger, }) => {
2
+ if (trigger === 'workflow') {
3
+ return getWorkflowTemplate({
4
+ description,
5
+ functionName,
6
+ language,
7
+ });
8
+ }
2
9
  let typeAppend = ``;
3
10
  if (trigger === 'model') {
4
11
  const eventsList = events
@@ -37,4 +44,41 @@ export default async function (req${language === 'ts' ? ': SwellRequest' : ''})
37
44
  }
38
45
  `;
39
46
  };
47
+ const getWorkflowTemplate = ({ description, functionName, language, }) => {
48
+ const className = toWorkflowClassName(functionName);
49
+ const reqParam = language === 'ts' ? 'req: SwellWorkflowRequest' : 'req';
50
+ const stepParam = language === 'ts' ? 'step: SwellWorkflowStep' : 'step';
51
+ return `export const config = {
52
+ kind: 'workflow',
53
+ description: '${description}'
54
+ };
55
+
56
+ export default class ${className} {
57
+ async run(${reqParam}, ${stepParam}) {
58
+ await step.do('load settings', async () => {
59
+ await req.swell.settings();
60
+ });
61
+
62
+ return {
63
+ data: req.data,
64
+ };
65
+ }
66
+ }
67
+ `;
68
+ };
69
+ const toWorkflowClassName = (functionName) => {
70
+ const baseName = functionName
71
+ .split(/[/\\]/)
72
+ .pop()
73
+ ?.replace(/\.[cm]?[jt]sx?$/i, '');
74
+ const className = (baseName || 'workflow')
75
+ .split(/[^\dA-Za-z]+/g)
76
+ .filter(Boolean)
77
+ .map((part) => `${part[0]?.toUpperCase() || ''}${part.slice(1)}`)
78
+ .join('');
79
+ if (!className) {
80
+ return 'Workflow';
81
+ }
82
+ return /^[$A-Z_a-z]/.test(className) ? className : `Workflow${className}`;
83
+ };
40
84
  export { getFunctionTemplate, };
@@ -0,0 +1,16 @@
1
+ export type FunctionSourceKind = 'function' | 'workflow' | 'unknown';
2
+ export interface FunctionSourceAnalysis {
3
+ config?: Record<string, unknown>;
4
+ kind: FunctionSourceKind;
5
+ defaultExport?: {
6
+ name?: string;
7
+ reason?: string;
8
+ validWorkflowClass: boolean;
9
+ };
10
+ diagnostics: string[];
11
+ }
12
+ export declare function analyzeFunctionSource(filePath: string): Promise<FunctionSourceAnalysis>;
13
+ export declare function analyzeFunctionSourceText(sourceText: string, filePath?: string): FunctionSourceAnalysis;
14
+ export declare function getFunctionTriggers(config: Record<string, unknown>): string[];
15
+ export declare function hasKindDiagnostic(analysis: FunctionSourceAnalysis): boolean;
16
+ export declare function workflowStaticConfigError(): Error;