Method
Method is an easy way to create Meteor methods
with Optimistic UI. It's built with Meteor 3.0 in mind. It's meant to be a drop in replacement for Validated Method and comes with additional features:
- Before and after hooks
- Global before and after hooks for all methods
- Pipe a series of functions
- Authed by default (can be overriden)
- Easily configure a rate limit
- Optionally run a method on the server only
- Attach the methods to Collections (optional)
- Validate with a jam:easy-schema schema or a custom validation function
- No need to use
.call
to invoke the method as withValidated Methods
Usage
Add the package to your app
meteor add jam:method
Create a method
name
is required and will be how Meteor's internals identifies it.
schema
will automatically validate a jam:easy-schema schema.
run
will be executed when the method is called.
1import { createMethod } from 'meteor/jam:method'; // can import { Methods } from 'meteor/jam:method' instead and use Methods.create if you prefer 2 3export const create = createMethod({ 4 name: 'todos.create', 5 schema: Todos.schema, // only jam:easy-schema schemas are supported at this time 6 run({ text }) { 7 const todo = { 8 text, 9 done: false, 10 createdAt: new Date(), 11 authorId: Meteor.userId(), // can also use this.userId instead of Meteor.userId() 12 } 13 const todoId = Todos.insert(todo); 14 return todoId; 15 } 16});
You can use a custom validation function instead if you'd like:
1// import your schema from somewhere 2// import your validator function from somewhere 3 4export const create = createMethod({ 5 name: 'todos.create', 6 validate(args) { 7 validator(args, schema) 8 }, 9 run({ text }) { 10 const todo = { 11 text, 12 done: false, 13 createdAt: new Date(), 14 authorId: Meteor.userId() // can also use this.userId instead of Meteor.userId() 15 } 16 const todoId = Todos.insert(todo); 17 return todoId; 18 } 19});
Async support
It also supports using *Async
Collection methods, e.g.:
1export const create = createMethod({ 2 name: 'todos.create', 3 schema: Todos.schema, // only jam:easy-schema schemas are supported at this time 4 async run({ text }) { 5 const todo = { 6 text, 7 done: false, 8 createdAt: new Date(), 9 authorId: Meteor.userId() // can also use this.userId instead of Meteor.userId() 10 } 11 const todoId = await Todos.insertAsync(todo); 12 return todoId; 13 } 14});
Import on the client and use
1import { create } from '/imports/api/todos/methods'; 2 3async function submit() { 4 try { 5 await create({text: 'book flight to Hawaii'}) 6 } catch(error) { 7 alert(error) 8 } 9}
Before and after hooks
You can execute functions before
and / or after
the method's run
function. before
and after
accept a single function or an array of functions.
When using before
, the original input passed into the method will be available. The original input will be returned automatically from a before
function so that run
receives what was originally passed into the method.
A great use case for using before
is to verify the user has permission. For example:
1async function checkOwnership({ _id }) { // the original input passed into the method is available here. destructuring for _id since that's all we need for this function 2 const todo = await Todos.findOneAsync(_id); 3 if (todo.authorId !== Meteor.userId()) { 4 throw new Meteor.Error('not-authorized') 5 } 6 7 return true; // any code executed as a before function will automatically return the original input passed into the method so that they are available in the run function 8} 9 10export const markDone = createMethod({ 11 name: 'todos.markDone', 12 schema: Todos.schema, 13 before: checkOwnership, 14 async run({ _id, done }) { 15 return await Todos.updateAsync(_id, {$set: {done}}); 16 } 17});
When using after
, the result of the run
function will be available as the first argument and the second argument will contain the original input that was passed into the method. The result of the run
function will be automatically returned from an after
function.
1function exampleAfter(result, context) { 2 const { originalInput } = context; // the name of the method is also available here 3 // do stuff 4 5 return 'success'; // any code executed as an after function will automatically return the result of the run function 6} 7 8export const markDone = createMethod({ 9 name: 'todos.markDone', 10 schema: Todos.schema, 11 before: checkOwnership, 12 async run({ _id, done }) { 13 return await Todos.updateAsync(_id, {$set: {done}}); 14 }, 15 after: exampleAfter 16});
Note
: If you use arrow functions for before
, run
, or after
, you'll lose access to this
– the methodInvocation. You may be willing to sacrifice that because this.userId
can be replaced by Meteor.userId()
and this.isSimulation
can be replaced by Meteor.isClient
but it's worth noting.
Pipe a series of functions
Instead of using run
, you can compose functions using .pipe
. Each function's output will be available as an input for the next function.
1// you'd define the functions in the pipe and then place them in the order you'd like them to execute within .pipe 2// be sure that each function in the pipe returns what the next one expects, otherwise you can add an arrow function to the pipe to massage the data, e.g. (input) => manipulate(input) 3 4export const create = createMethod({ 5 name: 'todos.create', 6 schema: Todos.schema 7}).pipe( 8 checkOwnership, 9 createTodo, 10 sendNotification 11)
Attach methods to its Collection (optional)
Instead of importing each method, you can attach them to the Collection. If you're using jam:easy-schema be sure to attach the schema before attaching the methods.
1// /imports/api/todos/collection 2import { Mongo } from 'meteor/mongo'; 3import { schema } from './schema'; 4 5export const Todos = new Mongo.Collection('todos'); 6 7Todos.attachSchema(schema); // if you're using jam:easy-schema 8(async() => { 9 const methods = await import('./methods.js') // dynamic import is recommended 10 Todos.attachMethods(methods); 11})();
With the methods attached you'll use them like this on the client:
1import { Todos } from '/imports/api/todos/collection'; 2// no need to import each method individually 3 4async function submit() { 5 try { 6 await Todos.create({text: 'book flight to Hawaii'}) 7 } catch(error) { 8 alert(error) 9 } 10}
Automatically dynamic import attached methods (optional)
You can also automatically dynamic import your attached methods to reduce the initial bundle size on the client.
You'll need to add a file to your project, e.g. /imports/register-dynamic-imports.js
and import this file on both the client and the server near the top of its mainModule
, e.g. /client/main.js
and /server/main.js
. Here's an example of the file:
1// In order for the dynamic import to work properly, Meteor needs to know these paths exist. 2// We do that by declaring them statically inside a if (false) block 3Meteor.startup(async () => { 4 if (false) { 5 await import('/imports/api/todos/schema'); // if using jam:easy-schema and want to dyanmically import it 6 await import('/imports/api/todos/methods'); 7 // add additional method paths for your other collections here 8 } 9});
Then instead of using Todos.attachMethods(methods)
, you'd just use Todos.attachMethods()
1// /imports/api/todos/collection 2// By not passing in the methods explicitly, it will automatically dynamically import the methods and then attach them 3 4import { Mongo } from 'meteor/mongo'; 5 6export const Todos = new Mongo.Collection('todos'); 7 8Todos.attachSchema(); // assuming you're using jam:easy-schema and dynamically importing it too 9Todos.attachMethods();
This assumes your directory structure is /imports/api/{collection}/methods
. If you have a different structure, e.g. /api/todos/methods
, you can configure the base path with:
1import { Methods } from 'meteor/jam:method'; 2 3Methods.configure({ 4 basePath: `/api` 5});
Executing code on the server only
By default, methods are optimistic meaning they will execute on the client and then on the server. If there's only part of the method that should execute on the server and not on the client, then simply wrap that piece of code in a if (Meteor.isServer)
block. This way you can still maintain the benefits of Optimistic UI. For example:
1export const create = createMethod({ 2 name: 'todos.create', 3 schema: Todos.schema, 4 async run(args) { 5 // rest of your function 6 // code running on both client and server 7 if (Meteor.isServer) { 8 // code running on the server only 9 import { secretCode } from '/server/secretCode'; // since it's in a /server folder the code will not be shipped to the client 10 // do something with secretCode 11 } 12 13 // code running on both client and server 14 return Todos.insertAsync(todo) 15 } 16});
If you prefer, you can force the entire method to execute on the server only by setting serverOnly: true
. It can be used with run
or .pipe
. Here's an example with run
:
1export const create = createMethod({ 2 name: 'todos.create', 3 schema: Todos.schema, 4 serverOnly: true, 5 async run(args) { 6 // all code here will execute only on the server 7 } 8});
You can also set all methods to be serverOnly
. See Configuring below.
Security note
Important
: Since Meteor does not currently support tree shaking, the contents of the code inside run
function or .pipe
could still be visible to the client even if you use if (Meteor.isServer)
or serverOnly: true
. To prevent this, you have these options:
-
Attach methods to its Collection with a dynamic import as shown above Attach methods to its Collection (optional)
-
Import function(s) from a file within a
/server
folder. Any code imported from a/server
folder will not be shipped to the client. The/server
folder can be located anywhere within your project's file structure and you can have multiple/server
folders. For example, you can co-locate with your collection folder, e.g./imports/api/todos/server/
, or it can be at the root of your project. See Secret server code for more info.
1export const create = createMethod({ 2 name: 'todos.create', 3 schema: Todos.schema, 4 serverOnly: true, 5 async run(args) { 6 import { serverFunction } from '/server/serverFunction'; 7 8 serverFunction(args); 9 } 10});
- Dynamically import function(s). These do not have to be inside a
/server
folder. This will also prevent the code from being shipped to the client and being inspectable via the browser console.
1export const create = createMethod({ 2 name: 'todos.create', 3 schema: Todos.schema, 4 serverOnly: true, 5 async run(args) { 6 const { serviceFunction } = await import('./services'); 7 8 serviceFunction(args); 9 } 10});
Changing authentication rules
By default, all methods will be protected by authentication, meaning they will throw an error if there is not a logged-in user. You can change this for an individual method by setting isPublic: true
. See Configuring below to change it for all methods.
1export const create = createMethod({ 2 name: 'todos.create', 3 schema: Todos.schema, 4 isPublic: true, 5 async run({ text }) { 6 // ... // 7 } 8});
Rate limiting
Easily rate limit a method by setting its max number of requests – the limit
– within a given time period (milliseconds) – the interval
.
1export const create = createMethod({ 2 name: 'todos.create', 3 schema: Todos.schema, 4 rateLimit: { // rate limit to a max of 5 requests every second 5 limit: 5, 6 interval: 1000 7 }, 8 async run({ text }) { 9 // ... // 10 } 11});
Configuring (optional)
If you like the defaults, then you won't need to configure anything. But there is some flexibility in how you use this package.
Here are the defaults:
1const config = { 2 before: [], // global before function(s) that will run before all methods 3 after: [], // global after function(s) that will run after all methods 4 serverOnly: false // globally make all methods serverOnly, aka disable Optimistic UI, by setting to true 5 options: { 6 returnStubValue: true, // make it possible to get the ID of an inserted item on the client before the server finishes 7 throwStubExceptions: true, // don't call the server method if the client stub throws an error, so that we don't end up doing validations twice 8 }, 9 arePublic: false, // by default all methods will be protected by authentication, override it for all methods by setting this to true 10 basePath: `/imports/api`, // used when dynamically importing methods 11};
To change the defaults, use:
1// can be configured on client-side and server-side (in a file imported on both client and server) 2// or just server-side depending on your use case 3import { Methods } from 'meteor/jam:method'; 4 5Methods.configure({ 6 // ... change the defaults here ... // 7});
Global before and after hooks
You can create before and/or after functions to run before / after all methods. Both before
and after
accept a single function or an array of functions.
1import { Methods } from 'meteor/jam:method'; 2 3const hello = () => { console.log('hello') } 4const there = () => { console.log('there') } 5const world = () => { console.log('world') } 6 7Methods.configure({ 8 before: [hello, there], 9 after: world 10});
Helpful utility functions
Here are some helpful utility functions you might consider adding. They aren't included in this package but you can copy and paste them into your codebase where you see fit.
Logger
1// log will simply console.log or console.error when the Method finishes 2function log(input, pipeline) { 3 pipeline.onResult((result) => { 4 console.log(`Method ${pipeline.name} finished`, input); 5 console.log('Result', result); 6 }); 7 8 pipeline.onError((err) => { 9 console.error(`Method ${pipeline.name} failed`); 10 console.error('Error', err); 11 }); 12};
Server-only function
1// this will ensure that the function passed in will only run on the server 2// could come in handy if have you're using .pipe and some of the functions you want to ensure only run on the server 3function server(fn) { 4 return function(...args) { 5 if (Meteor.isServer) { 6 return fn(...args) 7 } 8 } 9};
Then you could use them like this:
1Methods.configure({ 2 after: server(log) 3});
Coming from Validated Method
?
You may be familiar with mixins
and wondering where they are. With the features of this package - authenticated by default, before
/ after
hooks, .pipe
- your mixin code may no longer be needed or can be simplified. If you have another use case where your mixin doesn't translate, I'd love to hear it. Open a new discussion and let's chat.