@motiadev/core 0.1.0-beta.2 → 0.1.0-beta.21

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.
package/dist/index.d.ts CHANGED
@@ -9,3 +9,4 @@ export { isApiStep, isCronStep, isEventStep, isNoopStep } from './src/guards';
9
9
  export { LockedData } from './src/locked-data';
10
10
  export { getStepConfig } from './src/get-step-config';
11
11
  export { StateAdapter } from './src/state/state-adapter';
12
+ export { createMermaidGenerator } from './src/mermaid-generator';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.getStepConfig = exports.LockedData = exports.isNoopStep = exports.isEventStep = exports.isCronStep = exports.isApiStep = exports.setupCronHandlers = exports.createStateAdapter = exports.Logger = exports.globalLogger = exports.createEventManager = exports.createStepHandlers = exports.createServer = void 0;
17
+ exports.createMermaidGenerator = exports.getStepConfig = exports.LockedData = exports.isNoopStep = exports.isEventStep = exports.isCronStep = exports.isApiStep = exports.setupCronHandlers = exports.createStateAdapter = exports.Logger = exports.globalLogger = exports.createEventManager = exports.createStepHandlers = exports.createServer = void 0;
18
18
  __exportStar(require("./src/types"), exports);
19
19
  var server_1 = require("./src/server");
20
20
  Object.defineProperty(exports, "createServer", { enumerable: true, get: function () { return server_1.createServer; } });
@@ -38,3 +38,5 @@ var locked_data_1 = require("./src/locked-data");
38
38
  Object.defineProperty(exports, "LockedData", { enumerable: true, get: function () { return locked_data_1.LockedData; } });
39
39
  var get_step_config_1 = require("./src/get-step-config");
40
40
  Object.defineProperty(exports, "getStepConfig", { enumerable: true, get: function () { return get_step_config_1.getStepConfig; } });
41
+ var mermaid_generator_1 = require("./src/mermaid-generator");
42
+ Object.defineProperty(exports, "createMermaidGenerator", { enumerable: true, get: function () { return mermaid_generator_1.createMermaidGenerator; } });
@@ -8,13 +8,16 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const flowsConfigEndpoint = (app, baseDir) => {
10
10
  const configPath = path_1.default.join(baseDir, 'motia-workbench.json');
11
+ const getConfig = () => {
12
+ if (fs_1.default.existsSync(configPath)) {
13
+ return JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
14
+ }
15
+ return {};
16
+ };
11
17
  app.post('/flows/:id/config', (req, res) => {
12
18
  const newFlowConfig = req.body;
13
19
  try {
14
- if (!fs_1.default.existsSync(configPath)) {
15
- fs_1.default.writeFileSync(configPath, JSON.stringify({}, null, 2));
16
- }
17
- const existingConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
20
+ const existingConfig = getConfig();
18
21
  const updatedConfig = {
19
22
  ...existingConfig,
20
23
  };
@@ -28,21 +31,20 @@ const flowsConfigEndpoint = (app, baseDir) => {
28
31
  res.status(200).send({ message: 'Flow config saved successfully' });
29
32
  }
30
33
  catch (error) {
31
- console.error('Error saving flow config:', error);
34
+ console.error('Error saving flow config:', error.message);
32
35
  res.status(500).json({ error: 'Failed to save flow config' });
33
36
  }
34
37
  });
35
38
  app.get('/flows/:id/config', (req, res) => {
36
39
  const { id } = req.params;
37
40
  try {
38
- const file = fs_1.default.readFileSync(configPath, 'utf8');
39
- const allFlowsConfig = JSON.parse(file);
41
+ const allFlowsConfig = getConfig();
40
42
  const flowConfig = allFlowsConfig[id] || {};
41
43
  res.status(200).send(flowConfig);
42
44
  }
43
45
  catch (error) {
44
- console.error('Error reading flow config:', error);
45
- res.status(400).send({});
46
+ console.error('Error reading flow config:', error.message);
47
+ res.status(400).send({ error: 'Failed to read flow config' });
46
48
  }
47
49
  });
48
50
  };
@@ -0,0 +1,4 @@
1
+ import { LockedData } from './locked-data';
2
+ export declare const createMermaidGenerator: (baseDir: string) => {
3
+ initialize: (lockedData: LockedData) => void;
4
+ };
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createMermaidGenerator = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const guards_1 = require("./guards");
10
+ // Pure function to ensure diagrams directory exists
11
+ const ensureDiagramsDirectory = (diagramsDir) => {
12
+ if (!fs_1.default.existsSync(diagramsDir)) {
13
+ fs_1.default.mkdirSync(diagramsDir, { recursive: true });
14
+ }
15
+ };
16
+ // Pure function to get node ID
17
+ const getNodeId = (step, baseDir) => {
18
+ // Get relative path from the base directory
19
+ const relativePath = path_1.default.relative(baseDir, step.filePath);
20
+ // Remove common file extensions
21
+ const pathWithoutExtension = relativePath.replace(/\.(ts|js|tsx|jsx)$/, '');
22
+ // Replace slashes with underscores and dots with underscores
23
+ // Only keep alphanumeric characters and underscores
24
+ return pathWithoutExtension.replace(/[^a-zA-Z0-9]/g, '_');
25
+ };
26
+ // Pure function to get node label
27
+ const getNodeLabel = (step) => {
28
+ // Get display name for node
29
+ const displayName = step.config.name || path_1.default.basename(step.filePath, path_1.default.extname(step.filePath));
30
+ // Add node type prefix to help distinguish types
31
+ let prefix = '';
32
+ if ((0, guards_1.isApiStep)(step))
33
+ prefix = '🌐 ';
34
+ else if ((0, guards_1.isEventStep)(step))
35
+ prefix = '⚡ ';
36
+ else if ((0, guards_1.isCronStep)(step))
37
+ prefix = '⏰ ';
38
+ else if ((0, guards_1.isNoopStep)(step))
39
+ prefix = '⚙️ ';
40
+ // Create a node label with the step name
41
+ return `["${prefix}${displayName}"]`;
42
+ };
43
+ // Pure function to get node style
44
+ const getNodeStyle = (step) => {
45
+ // Apply style class based on step type
46
+ if ((0, guards_1.isApiStep)(step))
47
+ return ':::apiStyle';
48
+ if ((0, guards_1.isEventStep)(step))
49
+ return ':::eventStyle';
50
+ if ((0, guards_1.isCronStep)(step))
51
+ return ':::cronStyle';
52
+ if ((0, guards_1.isNoopStep)(step))
53
+ return ':::noopStyle';
54
+ return '';
55
+ };
56
+ // Pure function to generate connections
57
+ const generateConnections = (emits, sourceStep, steps, sourceId, baseDir) => {
58
+ const connections = [];
59
+ if (!emits || !Array.isArray(emits) || emits.length === 0) {
60
+ return '';
61
+ }
62
+ // Helper function to check if a step subscribes to a topic
63
+ const stepSubscribesToTopic = (step, topic) => {
64
+ // Event steps use regular subscribes
65
+ if ((0, guards_1.isEventStep)(step) &&
66
+ step.config.subscribes &&
67
+ Array.isArray(step.config.subscribes) &&
68
+ step.config.subscribes.includes(topic)) {
69
+ return true;
70
+ }
71
+ // Noop and API steps use virtualSubscribes
72
+ if (((0, guards_1.isNoopStep)(step) || (0, guards_1.isApiStep)(step)) &&
73
+ step.config.virtualSubscribes &&
74
+ Array.isArray(step.config.virtualSubscribes) &&
75
+ step.config.virtualSubscribes.includes(topic)) {
76
+ return true;
77
+ }
78
+ return false;
79
+ };
80
+ emits.forEach((emit) => {
81
+ const topic = typeof emit === 'string' ? emit : emit.topic;
82
+ const label = typeof emit === 'string' ? topic : emit.label || topic;
83
+ steps.forEach((targetStep) => {
84
+ if (stepSubscribesToTopic(targetStep, topic)) {
85
+ const targetId = getNodeId(targetStep, baseDir);
86
+ connections.push(` ${sourceId} -->|${label}| ${targetId}`);
87
+ }
88
+ });
89
+ });
90
+ return connections.join('\n');
91
+ };
92
+ // Pure function to generate flow diagram
93
+ const generateFlowDiagram = (flowName, steps, baseDir) => {
94
+ // Start mermaid flowchart with top-down direction
95
+ let diagram = `flowchart TD\n`;
96
+ // Add class definitions for styling with explicit text color
97
+ const classDefinitions = [
98
+ ` classDef apiStyle fill:#f96,stroke:#333,stroke-width:2px,color:#fff`,
99
+ ` classDef eventStyle fill:#69f,stroke:#333,stroke-width:2px,color:#fff`,
100
+ ` classDef cronStyle fill:#9c6,stroke:#333,stroke-width:2px,color:#fff`,
101
+ ` classDef noopStyle fill:#3f3a50,stroke:#333,stroke-width:2px,color:#fff`,
102
+ ];
103
+ diagram += classDefinitions.join('\n') + '\n';
104
+ // Check if we have any steps
105
+ if (!steps || steps.length === 0) {
106
+ return diagram + ' empty[No steps in this flow]';
107
+ }
108
+ // Create node definitions with proper format
109
+ steps.forEach((step) => {
110
+ const nodeId = getNodeId(step, baseDir);
111
+ const nodeLabel = getNodeLabel(step);
112
+ const nodeStyle = getNodeStyle(step);
113
+ diagram += ` ${nodeId}${nodeLabel}${nodeStyle}\n`;
114
+ });
115
+ // Create connections between nodes
116
+ let connectionsStr = '';
117
+ steps.forEach((sourceStep) => {
118
+ const sourceId = getNodeId(sourceStep, baseDir);
119
+ // Helper function to process emissions if they exist
120
+ function processEmissions(emissionsArray, stepSource, stepsCollection, sourceIdentifier) {
121
+ if (emissionsArray && Array.isArray(emissionsArray)) {
122
+ return generateConnections(emissionsArray, stepSource, stepsCollection, sourceIdentifier, baseDir);
123
+ }
124
+ return '';
125
+ }
126
+ // Semantic variables to clarify which step types support which emission types
127
+ const supportsEmits = (0, guards_1.isApiStep)(sourceStep) || (0, guards_1.isEventStep)(sourceStep) || (0, guards_1.isCronStep)(sourceStep);
128
+ const supportsVirtualEmits = supportsEmits || (0, guards_1.isNoopStep)(sourceStep);
129
+ // Process regular emissions if supported
130
+ if (supportsEmits) {
131
+ const emitConnections = processEmissions(sourceStep.config.emits, sourceStep, steps, sourceId);
132
+ if (emitConnections) {
133
+ connectionsStr += emitConnections + '\n';
134
+ }
135
+ }
136
+ // Process virtual emissions if supported
137
+ if (supportsVirtualEmits) {
138
+ const virtualEmitConnections = processEmissions(sourceStep.config.virtualEmits, sourceStep, steps, sourceId);
139
+ if (virtualEmitConnections) {
140
+ connectionsStr += virtualEmitConnections + '\n';
141
+ }
142
+ }
143
+ });
144
+ // Add connections to the diagram
145
+ diagram += connectionsStr;
146
+ return diagram;
147
+ };
148
+ // Function to save a diagram to a file
149
+ const saveDiagram = (diagramsDir, flowName, diagram) => {
150
+ const filePath = path_1.default.join(diagramsDir, `${flowName}.mmd`);
151
+ fs_1.default.writeFileSync(filePath, diagram);
152
+ };
153
+ // Function to remove a diagram file
154
+ const removeDiagram = (diagramsDir, flowName) => {
155
+ const filePath = path_1.default.join(diagramsDir, `${flowName}.mmd`);
156
+ if (fs_1.default.existsSync(filePath)) {
157
+ fs_1.default.unlinkSync(filePath);
158
+ }
159
+ };
160
+ // Function to generate and save a diagram
161
+ const generateAndSaveDiagram = (diagramsDir, flowName, flow, baseDir) => {
162
+ const diagram = generateFlowDiagram(flowName, flow.steps, baseDir);
163
+ saveDiagram(diagramsDir, flowName, diagram);
164
+ };
165
+ // Main exported function that creates the mermaid generator
166
+ const createMermaidGenerator = (baseDir) => {
167
+ const diagramsDir = path_1.default.join(baseDir, '.mermaid');
168
+ ensureDiagramsDirectory(diagramsDir);
169
+ // Event handlers
170
+ const handleFlowCreated = (flowName, flow) => {
171
+ generateAndSaveDiagram(diagramsDir, flowName, flow, baseDir);
172
+ };
173
+ const handleFlowUpdated = (flowName, flow) => {
174
+ generateAndSaveDiagram(diagramsDir, flowName, flow, baseDir);
175
+ };
176
+ const handleFlowRemoved = (flowName) => {
177
+ removeDiagram(diagramsDir, flowName);
178
+ };
179
+ // Initialize function to hook into LockedData events
180
+ const initialize = (lockedData) => {
181
+ // Hook into flow events
182
+ lockedData.on('flow-created', (flowName) => {
183
+ handleFlowCreated(flowName, lockedData.flows[flowName]);
184
+ });
185
+ lockedData.on('flow-updated', (flowName) => {
186
+ handleFlowUpdated(flowName, lockedData.flows[flowName]);
187
+ });
188
+ lockedData.on('flow-removed', (flowName) => {
189
+ handleFlowRemoved(flowName);
190
+ });
191
+ // Generate diagrams for all existing flows
192
+ if (lockedData.flows && typeof lockedData.flows === 'object') {
193
+ Object.entries(lockedData.flows).forEach(([flowName, flow]) => {
194
+ generateAndSaveDiagram(diagramsDir, flowName, flow, baseDir);
195
+ });
196
+ }
197
+ };
198
+ // Return the public API
199
+ return {
200
+ initialize,
201
+ };
202
+ };
203
+ exports.createMermaidGenerator = createMermaidGenerator;
@@ -0,0 +1 @@
1
+ export declare const composeMiddleware: (...middlewares: any[]) => (req: any, ctx: any, handler: () => Promise<any>) => Promise<any>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.composeMiddleware = void 0;
4
+ /* eslint-disable @typescript-eslint/no-explicit-any */
5
+ const composeMiddleware = (...middlewares) => {
6
+ return async (req, ctx, handler) => {
7
+ const composedHandler = middlewares.reduceRight((nextHandler, middleware) => () => middleware(req, ctx, nextHandler), handler);
8
+ return composedHandler();
9
+ };
10
+ };
11
+ exports.composeMiddleware = composeMiddleware;
12
+ /* eslint-enable @typescript-eslint/no-explicit-any */
@@ -7,6 +7,7 @@ const path_1 = __importDefault(require("path"));
7
7
  const logger_1 = require("./logger");
8
8
  const rpc_state_manager_1 = require("./rpc-state-manager");
9
9
  const rpc_1 = require("./rpc");
10
+ const middleware_compose_1 = require("./middleware-compose");
10
11
  // eslint-disable-next-line @typescript-eslint/no-require-imports
11
12
  require('dotenv').config();
12
13
  // Add ts-node registration before dynamic imports
@@ -38,8 +39,12 @@ async function runTypescriptModule(filePath, event) {
38
39
  const emit = async (data) => sender.send('emit', data);
39
40
  const context = { traceId, flows, logger, state, emit };
40
41
  sender.init();
41
- // Call the function with provided arguments
42
- const result = contextInFirstArg ? await module.handler(context) : await module.handler(event.data, context);
42
+ const middlewares = Array.isArray(module.config.middleware) ? module.config.middleware : [];
43
+ const composedMiddleware = (0, middleware_compose_1.composeMiddleware)(...middlewares);
44
+ const handlerFn = () => {
45
+ return contextInFirstArg ? module.handler(context) : module.handler(event.data, context);
46
+ };
47
+ const result = await composedMiddleware(event.data, context, handlerFn);
43
48
  await sender.send('result', result);
44
49
  await sender.close();
45
50
  process.exit(0);
@@ -1,6 +1,6 @@
1
1
  import time
2
2
  from typing import Any, Dict, Optional
3
- from rpc import RpcSender
3
+ from rpc import RpcSender, serialize_for_json
4
4
 
5
5
  class Logger:
6
6
  def __init__(self, trace_id: str, flows: list[str], rpc: RpcSender):
@@ -18,7 +18,7 @@ class Logger:
18
18
  }
19
19
 
20
20
  if args:
21
- # Convert SimpleNamespace to dict if needed
21
+ # Use our serializer to ensure args are JSON-serializable
22
22
  if hasattr(args, '__dict__'):
23
23
  args = vars(args)
24
24
  elif not isinstance(args, dict):
@@ -5,9 +5,10 @@ import os
5
5
  import asyncio
6
6
  import traceback
7
7
  from logger import Logger
8
- from rpc import RpcSender
8
+ from rpc import RpcSender, serialize_for_json
9
9
  from rpc_state_manager import RpcStateManager
10
- from typing import Any
10
+ from typing import Any, Optional
11
+ import functools
11
12
 
12
13
  def parse_args(arg: str) -> Any:
13
14
  from types import SimpleNamespace
@@ -19,16 +20,43 @@ def parse_args(arg: str) -> Any:
19
20
  return arg
20
21
 
21
22
  class Context:
22
- def __init__(self, args: Any, rpc: RpcSender):
23
+ def __init__(self, args: Any, rpc: RpcSender, is_api_handler: bool = False):
23
24
  self.trace_id = args.traceId
24
25
  self.traceId = args.traceId
25
26
  self.flows = args.flows
26
27
  self.rpc = rpc
27
28
  self.state = RpcStateManager(rpc)
28
29
  self.logger = Logger(self.trace_id, self.flows, rpc)
30
+ self._loop = asyncio.get_event_loop()
31
+ self.is_api_handler = is_api_handler
32
+ self._pending_tasks = []
29
33
 
30
34
  async def emit(self, event: Any):
31
- return await self.rpc.send('emit', event)
35
+ if self.is_api_handler:
36
+ self.rpc.send_no_wait('emit', event)
37
+ return None
38
+ else:
39
+ return await self.rpc.send('emit', event)
40
+
41
+ # Add wrapper to handle non-awaited emit coroutine
42
+ def __getattribute__(self, name):
43
+ attr = super().__getattribute__(name)
44
+ if name == 'emit' and asyncio.iscoroutinefunction(attr):
45
+ @functools.wraps(attr)
46
+ def wrapper(*args, **kwargs):
47
+ coro = attr(*args, **kwargs)
48
+ # Check if this is being awaited
49
+ frame = sys._getframe(1)
50
+ if frame.f_code.co_name != '__await__':
51
+ task = asyncio.create_task(coro)
52
+ def handle_exception(t):
53
+ if t.done() and not t.cancelled() and t.exception():
54
+ print(f"Unhandled exception in background task: {t.exception()}", file=sys.stderr)
55
+ task.add_done_callback(handle_exception)
56
+ return task
57
+ return coro
58
+ return wrapper
59
+ return attr
32
60
 
33
61
  async def run_python_module(file_path: str, rpc: RpcSender, args: Any) -> None:
34
62
  try:
@@ -62,14 +90,40 @@ async def run_python_module(file_path: str, rpc: RpcSender, args: Any) -> None:
62
90
 
63
91
  context = Context(args, rpc)
64
92
 
93
+ # Check if this is an API handler
94
+ is_api_handler = False
95
+ if hasattr(module, 'config'):
96
+ if isinstance(module.config, dict) and module.config.get('type') == 'api':
97
+ is_api_handler = True
98
+ elif hasattr(module.config, 'type') and module.config.type == 'api':
99
+ is_api_handler = True
100
+
101
+ # Create context with is_api_handler flag
102
+ context = Context(args, rpc, is_api_handler)
103
+
65
104
  if contextInFirstArg:
66
105
  result = await module.handler(context)
67
106
  else:
107
+ if hasattr(args.data, 'body'):
108
+ args.data.body = serialize_for_json(args.data.body)
68
109
  result = await module.handler(args.data, context)
69
110
 
111
+ # For API handlers, we want to return immediately without waiting for background tasks
112
+ # This prevents the API from getting stuck
113
+ if not is_api_handler:
114
+ pending = asyncio.all_tasks() - {asyncio.current_task()}
115
+ if pending:
116
+ await asyncio.gather(*pending)
117
+
70
118
  if result:
71
119
  await rpc.send('result', result)
72
120
 
121
+ # For non-API handlers, ensure all pending tasks are completed before closing
122
+ if not is_api_handler:
123
+ pending = asyncio.all_tasks() - {asyncio.current_task()}
124
+ if pending:
125
+ await asyncio.gather(*pending)
126
+
73
127
  rpc.close()
74
128
  rpc.send_no_wait('close', None)
75
129
 
@@ -89,4 +143,4 @@ if __name__ == "__main__":
89
143
  rpc = RpcSender()
90
144
  loop = asyncio.get_event_loop()
91
145
  tasks = asyncio.gather(rpc.init(), run_python_module(file_path, rpc, parse_args(arg)))
92
- loop.run_until_complete(tasks)
146
+ loop.run_until_complete(tasks)
@@ -8,6 +8,20 @@ from typing import Any, Dict, Tuple
8
8
  # get the FD from ENV
9
9
  NODEIPCFD = int(os.environ["NODE_CHANNEL_FD"])
10
10
 
11
+ def serialize_for_json(obj: Any) -> Any:
12
+ """Convert Python objects to JSON-serializable types"""
13
+ if hasattr(obj, '__dict__'):
14
+ return obj.__dict__
15
+ elif hasattr(obj, '_asdict'): # For namedtuples
16
+ return obj._asdict()
17
+ elif isinstance(obj, (list, tuple)):
18
+ return [serialize_for_json(item) for item in obj]
19
+ elif isinstance(obj, dict):
20
+ return {k: serialize_for_json(v) for k, v in obj.items()}
21
+ else:
22
+ # Try to return the object as is, letting json handle basic types
23
+ return obj
24
+
11
25
  class RpcSender:
12
26
  def __init__(self):
13
27
  self.executing = True
@@ -1,19 +1,56 @@
1
1
  from typing import Any
2
2
  import asyncio
3
3
  from rpc import RpcSender
4
+ import functools
5
+ import sys
4
6
 
5
7
  class RpcStateManager:
6
8
  def __init__(self, rpc: RpcSender):
7
9
  self.rpc = rpc
10
+ self._loop = asyncio.get_event_loop()
8
11
 
9
12
  async def get(self, trace_id: str, key: str) -> asyncio.Future[Any]:
10
- return await self.rpc.send('state.get', {'traceId': trace_id, 'key': key})
13
+ result = await self.rpc.send('state.get', {'traceId': trace_id, 'key': key})
14
+
15
+ if result is None:
16
+ return {'data': None}
17
+ elif isinstance(result, dict):
18
+ if 'data' not in result:
19
+ return {'data': result}
20
+
21
+ return result
11
22
 
12
23
  async def set(self, trace_id: str, key: str, value: Any) -> asyncio.Future[None]:
13
- return await self.rpc.send('state.set', {'traceId': trace_id, 'key': key, 'value': value})
24
+ future = await self.rpc.send('state.set', {'traceId': trace_id, 'key': key, 'value': value})
25
+ return future
14
26
 
15
27
  async def delete(self, trace_id: str, key: str) -> asyncio.Future[None]:
16
28
  return await self.rpc.send('state.delete', {'traceId': trace_id, 'key': key})
17
29
 
18
30
  async def clear(self, trace_id: str) -> asyncio.Future[None]:
19
31
  return await self.rpc.send('state.clear', {'traceId': trace_id})
32
+
33
+ # Add wrappers to handle non-awaited coroutines
34
+ def __getattribute__(self, name):
35
+ attr = super().__getattribute__(name)
36
+ if name in ('get', 'set', 'delete', 'clear') and asyncio.iscoroutinefunction(attr):
37
+ @functools.wraps(attr)
38
+ def wrapper(*args, **kwargs):
39
+ coro = attr(*args, **kwargs)
40
+ # Check if this is being awaited
41
+ frame = sys._getframe(1)
42
+ if frame.f_code.co_name != '__await__':
43
+ # Not being awaited, schedule in background
44
+ # But we need to make sure this task completes before the handler returns
45
+ # So we'll return the task for the caller to await if needed
46
+ task = asyncio.create_task(coro)
47
+ # Add error handling for the background task
48
+ def handle_exception(t):
49
+ if t.done() and not t.cancelled() and t.exception():
50
+ print(f"Unhandled exception in background task: {t.exception()}", file=sys.stderr)
51
+ task.add_done_callback(handle_exception)
52
+ return task
53
+ # Being awaited, return coroutine as normal
54
+ return coro
55
+ return wrapper
56
+ return attr
@@ -3,6 +3,22 @@ class RpcStateManager
3
3
  @sender = sender
4
4
  end
5
5
 
6
+ # Support hash-like access with [:method_name] syntax
7
+ def [](method_name)
8
+ case method_name.to_sym
9
+ when :get
10
+ ->(trace_id, key) { get(trace_id, key) }
11
+ when :set
12
+ ->(trace_id, key, value) { set(trace_id, key, value) }
13
+ when :delete
14
+ ->(trace_id, key) { delete(trace_id, key) }
15
+ when :clear
16
+ ->(trace_id) { clear(trace_id) }
17
+ else
18
+ raise NoMethodError, "undefined method `#{method_name}' for #{self.class}"
19
+ end
20
+ end
21
+
6
22
  def get(trace_id, key)
7
23
  # Return promise to match Python/Node behavior
8
24
  @sender.send('state.get', { traceId: trace_id, key: key })
@@ -22,4 +38,4 @@ class RpcStateManager
22
38
  # Return promise to match Python/Node behavior
23
39
  @sender.send('state.clear', { traceId: trace_id })
24
40
  end
25
- end
41
+ end
@@ -20,7 +20,7 @@ class Context
20
20
 
21
21
  def emit(event)
22
22
  # Add type field if not present to match Node.js/Python behavior
23
- event = { type: event[:type] || event['type'], data: event[:data] || event['data'] } unless event.is_a?(String)
23
+ event = { topic: event[:topic] || event['topic'], data: event[:data] || event['data'] } unless event.is_a?(String)
24
24
  promise = @rpc.send('emit', event)
25
25
  promise # Return promise to maintain async pattern
26
26
  end
@@ -8,7 +8,9 @@ const cron_handler_1 = require("./cron-handler");
8
8
  const body_parser_1 = __importDefault(require("body-parser"));
9
9
  const express_1 = __importDefault(require("express"));
10
10
  const http_1 = __importDefault(require("http"));
11
+ const multer_1 = __importDefault(require("multer"));
11
12
  const socket_io_1 = require("socket.io");
13
+ const cors_1 = __importDefault(require("cors"));
12
14
  const flows_endpoint_1 = require("./flows-endpoint");
13
15
  const guards_1 = require("./guards");
14
16
  const logger_1 = require("./logger");
@@ -23,6 +25,7 @@ const createServer = async (lockedData, eventManager, state, config) => {
23
25
  const server = http_1.default.createServer(app);
24
26
  const io = new socket_io_1.Server(server);
25
27
  const loggerFactory = new LoggerFactory_1.LoggerFactory(config.isVerbose, io);
28
+ const upload = (0, multer_1.default)();
26
29
  const allSteps = [...steps_1.systemSteps, ...lockedData.activeSteps];
27
30
  const cronManager = (0, cron_handler_1.setupCronHandlers)(lockedData, eventManager, state, loggerFactory);
28
31
  const asyncHandler = (step) => {
@@ -36,6 +39,7 @@ const createServer = async (lockedData, eventManager, state, config) => {
36
39
  headers: req.headers,
37
40
  pathParams: req.params,
38
41
  queryParams: req.query,
42
+ files: req.files,
39
43
  };
40
44
  try {
41
45
  const data = request;
@@ -75,10 +79,10 @@ const createServer = async (lockedData, eventManager, state, config) => {
75
79
  const handler = asyncHandler(step);
76
80
  const methods = {
77
81
  GET: () => router.get(path, handler),
78
- POST: () => router.post(path, handler),
79
- PUT: () => router.put(path, handler),
82
+ POST: () => router.post(path, upload.any(), handler),
83
+ PUT: () => router.put(path, upload.any(), handler),
80
84
  DELETE: () => router.delete(path, handler),
81
- PATCH: () => router.patch(path, handler),
85
+ PATCH: () => router.patch(path, upload.any(), handler),
82
86
  OPTIONS: () => router.options(path, handler),
83
87
  HEAD: () => router.head(path, handler),
84
88
  };
@@ -102,6 +106,7 @@ const createServer = async (lockedData, eventManager, state, config) => {
102
106
  router.stack = filteredStack;
103
107
  };
104
108
  allSteps.filter(guards_1.isApiStep).forEach(addRoute);
109
+ app.use((0, cors_1.default)());
105
110
  app.use(router);
106
111
  (0, flows_endpoint_1.flowsEndpoint)(lockedData, app);
107
112
  (0, flows_config_endpoint_1.flowsConfigEndpoint)(app, process.cwd());
@@ -63,8 +63,9 @@ const eventSchema = zod_1.z
63
63
  subscribes: zod_1.z.array(zod_1.z.string()),
64
64
  emits: emits,
65
65
  virtualEmits: emits.optional(),
66
- input: zod_1.z.union([jsonSchema, zod_1.z.null()]).optional(),
66
+ input: zod_1.z.union([jsonSchema, zod_1.z.object({}), zod_1.z.null()]).optional(),
67
67
  flows: zod_1.z.array(zod_1.z.string()).optional(),
68
+ includeFiles: zod_1.z.array(zod_1.z.string()).optional(),
68
69
  })
69
70
  .strict();
70
71
  const apiSchema = zod_1.z
@@ -78,7 +79,9 @@ const apiSchema = zod_1.z
78
79
  virtualEmits: emits.optional(),
79
80
  virtualSubscribes: zod_1.z.array(zod_1.z.string()).optional(),
80
81
  flows: zod_1.z.array(zod_1.z.string()).optional(),
81
- bodySchema: zod_1.z.union([jsonSchema, zod_1.z.null()]).optional(),
82
+ bodySchema: zod_1.z.union([jsonSchema, zod_1.z.object({}), zod_1.z.null()]).optional(),
83
+ includeFiles: zod_1.z.array(zod_1.z.string()).optional(),
84
+ middleware: zod_1.z.array(zod_1.z.any()).optional(),
82
85
  })
83
86
  .strict();
84
87
  const cronSchema = zod_1.z
@@ -90,6 +93,7 @@ const cronSchema = zod_1.z
90
93
  virtualEmits: emits.optional(),
91
94
  emits: emits,
92
95
  flows: zod_1.z.array(zod_1.z.string()).optional(),
96
+ includeFiles: zod_1.z.array(zod_1.z.string()).optional(),
93
97
  })
94
98
  .strict();
95
99
  const validateStep = (step) => {
@@ -118,7 +122,7 @@ const validateStep = (step) => {
118
122
  if (error instanceof zod_1.z.ZodError) {
119
123
  return {
120
124
  success: false,
121
- error: 'Invalid step configuration',
125
+ error: error.errors.map((err) => err.message).join(', '),
122
126
  errors: error.errors.map((err) => ({ path: err.path.join('.'), message: err.message })),
123
127
  };
124
128
  }
@@ -32,6 +32,11 @@ export type EventConfig<TInput extends ZodObject<any> = any> = {
32
32
  virtualEmits?: Emit[];
33
33
  input: TInput;
34
34
  flows?: string[];
35
+ /**
36
+ * Files to include in the step bundle.
37
+ * Needs to be relative to the step file.
38
+ */
39
+ includeFiles?: string[];
35
40
  };
36
41
  export type NoopConfig = {
37
42
  type: 'noop';
@@ -42,6 +47,7 @@ export type NoopConfig = {
42
47
  flows?: string[];
43
48
  };
44
49
  export type ApiRouteMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
50
+ export type ApiMiddleware = (req: ApiRequest, ctx: FlowContext, next: () => Promise<ApiResponse>) => Promise<ApiResponse>;
45
51
  export type ApiRouteConfig = {
46
52
  type: 'api';
47
53
  name: string;
@@ -52,13 +58,22 @@ export type ApiRouteConfig = {
52
58
  virtualEmits?: Emit[];
53
59
  virtualSubscribes?: string[];
54
60
  flows?: string[];
61
+ middleware?: ApiMiddleware[];
55
62
  bodySchema?: ZodObject<any>;
63
+ /**
64
+ * Files to include in the step bundle.
65
+ * Needs to be relative to the step file.
66
+ */
67
+ includeFiles?: string[];
56
68
  };
57
69
  export type ApiRequest = {
58
70
  pathParams: Record<string, string>;
59
71
  queryParams: Record<string, string | string[]>;
60
72
  body: Record<string, any>;
61
73
  headers: Record<string, string | string[]>;
74
+ files?: Express.Multer.File[] | {
75
+ [fieldname: string]: Express.Multer.File[];
76
+ };
62
77
  };
63
78
  export type ApiResponse = {
64
79
  status: number;
@@ -74,6 +89,11 @@ export type CronConfig = {
74
89
  virtualEmits?: Emit[];
75
90
  emits: Emit[];
76
91
  flows?: string[];
92
+ /**
93
+ * Files to include in the step bundle.
94
+ * Needs to be relative to the step file.
95
+ */
96
+ includeFiles?: string[];
77
97
  };
78
98
  export type CronHandler = (ctx: FlowContext) => Promise<void>;
79
99
  export type StepHandler<T> = T extends EventConfig<any> ? EventHandler<T['input']> : T extends ApiRouteConfig ? ApiRouteHandler : T extends CronConfig ? CronHandler : never;
package/package.json CHANGED
@@ -2,12 +2,15 @@
2
2
  "name": "@motiadev/core",
3
3
  "description": "Core functionality for the Motia framework, providing the foundation for building event-driven workflows.",
4
4
  "main": "dist/index.js",
5
- "version": "0.1.0-beta.2",
5
+ "version": "0.1.0-beta.21",
6
6
  "dependencies": {
7
7
  "body-parser": "^1.20.3",
8
8
  "colors": "^1.4.0",
9
+ "cors": "^2.8.5",
10
+ "dotenv": "^16.4.7",
9
11
  "express": "^4.21.2",
10
12
  "ioredis": "^5.4.2",
13
+ "multer": "1.4.5-lts.1",
11
14
  "node-cron": "^3.0.3",
12
15
  "socket.io": "^4.8.1",
13
16
  "ts-node": "^10.9.2",
@@ -17,8 +20,10 @@
17
20
  },
18
21
  "devDependencies": {
19
22
  "@types/body-parser": "^1.19.5",
23
+ "@types/cors": "^2.8.17",
20
24
  "@types/express": "^5.0.0",
21
25
  "@types/jest": "^29.5.14",
26
+ "@types/multer": "^1.4.12",
22
27
  "@types/node-cron": "^3.0.11",
23
28
  "@types/supertest": "^6.0.2",
24
29
  "jest": "^29.7.0",