v3-docs/docs/community-packages/meteor-rpc.md
[[toc]]
Inspired on zodern:relay and on tRPC
This package provides functions for building E2E type-safe RPCs focused on React front ends.
::: warning
This package works only with Meteor 2.8 or higher.
If you are not sure about the version of Meteor you are using, you can check it by running the following command in your terminal within your project:
meteor --version
:::
meteor npm i meteor-rpc @tanstack/react-query zod
::: warning
Before continuing the installation, make sure you have react-query all set in your project; for more info, follow their quick start guide.
:::
There are a few concepts that are important while using this package:
Meteor.methods and Meteor.publish but with types and runtime validation, their understanding is important to use this package.Zod to validate the arguments, so you can be sure that the data you are receiving is what you expect.::: tip
If you are accepting any type of data, you can use z.any() as the schema or z.void when there is no argument
:::
createModuleThis function is used to create a module that will be used to call our methods and publications
subModule without a namespace: createModule() is used to create the main server module, the one that will be exported to be used in the client.`
subModule with a namespace: createModule("namespace") is used to create a submodule that will be added to the main module.
Remember to use
buildat the end of module creation to ensure that the module will be created.
Example:
::: code-group
import { createModule } from "meteor-rpc";
import { Chat } from "./chat";
const server = createModule() // server has no namespace
.addMethod("bar", z.string(), (arg) => "bar" as const)
.addSubmodule(Chat)
.build();
export type Server = typeof server;
import { createModule } from "meteor-rpc";
import { ChatCollection } from "/imports/api/chat";
import { z } from "zod";
export const Chat = createModule("chat")
.addMethod("createChat", z.void(), async () => {
return ChatCollection.insertAsync({ createdAt: new Date(), messages: [] });
})
.buildSubmodule();
import { createClient } from "meteor-rpc";
// you must import the type of the server
import type { Server } from "/imports/api/server";
const api = createClient<Server>();
const bar: "bar" = await api.bar("some string");
// ?^ 'bar'
const newChatId = await api.chat.createChat(); // with intellisense
:::
module.addMethodType:
addMethod(
name: string,
schema: ZodSchema,
handler: (args: ZodTypeInput<ZodSchema>) => T,
config?: Config<ZodTypeInput<ZodSchema>, T>
)
This is the equivalent of Meteor.methods but with types and runtime validation.
::: code-group
import { createModule } from "meteor-rpc";
import { z } from "zod";
const server = createModule()
.addMethod("foo", z.string(), (arg) => "foo" as const)
.build();
import { Meteor } from "meteor/meteor";
import { z } from "zod";
Meteor.methods({
foo(arg: string) {
z.string().parse(arg);
return "foo";
},
});
:::
module.addPublicationType:
addPublication(
name: string,
schema: ZodSchema,
handler: (args: ZodTypeInput<ZodSchema>) => Cursor<Result, Result>
)
This is the equivalent of Meteor.publish but with types and runtime validation.
::: code-group
import { createModule } from "meteor-rpc";
import { ChatCollection } from "/imports/api/chat";
import { z } from "zod";
const server = createModule()
.addPublication("chatRooms", z.void(), () => {
return ChatCollection.find();
})
.build();
import { Meteor } from "meteor/meteor";
import { ChatCollection } from "/imports/api/chat";
Meteor.publish("chatRooms", function () {
return ChatCollection.find();
});
:::
addSharedPublication(name: string, schema: ZodSchema, handler: (args: ZodTypeInput<ZodSchema>) => Array<Mongo.Cursor<any>> | Promise<Array<Mongo.Cursor<any>>> )
This is similar to addPublication, but it allows you to create an array of cursors, which can be useful for shared queries that need to return multiple collections or different queries.
::: code-group
// server/main.ts
import { createModule } from "meteor-rpc";
import { ChatCollection } from "/imports/api/chat";
import { UserCollection } from "/imports/api/user";
import { z } from "zod";
const server = createModule().addSharedPublication(
"chatRooms",
z.string(),
(userId) => {
return [ChatCollection.find({ userId }), UserCollection.find({ userId })];
}
);
server.build();
import { Meteor } from "meteor/meteor";
import { ChatCollection } from "/imports/api/chat";
import { UserCollection } from "/imports/api/user";
import { check } from "meteor/check";
Meteor.publish("chatRooms", function (userId) {
check(userId, String);
return [ChatCollection.find({ userId }), UserCollection.find({ userId })];
});
:::
module.addSubmoduleThis is used to add a submodule to the main module, adding namespaces for your methods and publications and making it easier to organize your code.
Remember to use
submodule.buildSubmodulewhen creating a submodule
::: code-group
import { ChatCollection } from "/imports/api/chat";
import { createModule } from "meteor-rpc";
export const chatModule = createModule("chat")
.addMethod("createChat", z.void(), async () => {
return ChatCollection.insertAsync({ createdAt: new Date(), messages: [] });
})
.buildSubmodule(); // <-- This is important so that this module can be added as a submodule
import { createModule } from "meteor-rpc";
import { chatModule } from "./server/chat";
const server = createModule()
.addMethod("bar", z.string(), (arg) => "bar" as const)
.addSubmodule(chatModule)
.build();
server.chat; // <-- this is the namespace for the chat module
server.chat.createChat(); // <-- this is the method from the chat module and it gets autocompleted
:::
module.addMiddlewaresType:
type Middleware = (raw: unknown, parsed: unknown) => void;
addMiddlewares(middlewares: Middleware[])
This is used to add middleware to the module; it should be used to add side effects logic to the methods and publications, which is ideal for logging or rate limiting.
The middleware ordering is last in, first out. Check the example below:
::: code-group
import { ChatCollection } from "/imports/api/chat";
import { createModule } from "meteor-rpc";
export const chatModule = createModule("chat")
.addMiddlewares([
(raw, parsed) => {
console.log("runs first");
},
])
.addMethod("createChat", z.void(), async () => {
return ChatCollection.insertAsync({ createdAt: new Date(), messages: [] });
})
.buildSubmodule();
import { createModule } from "meteor-rpc";
import { chatModule } from "./server/chat";
const server = createModule()
.addMiddlewares([
(raw, parsed) => {
console.log("runs second");
},
])
.addMethod("bar", z.string(), (arg) => "bar" as const)
.addSubmodule(chatModule)
.build();
import { createClient } from "meteor-rpc";
import type { Server } from "/imports/api/server"; // you must import the type
const api = createClient<Server>();
await api.chat.createChat(); // logs "runs first" then "runs second"
await api.bar("str"); // logs "runs second"
:::
module.buildThis is used to build the module, it should be used at the end of the module creation to ensure that the exported type is correct.
::: code-group
// ✅ it has the build method
import { createModule } from "meteor-rpc";
import { z } from "zod";
const server = createModule()
.addMethod("bar", z.string(), (arg) => "bar" as const)
.build();
export type Server = typeof server;
// ❌ it is missing the build method
import { createModule } from "meteor-rpc";
import { z } from "zod";
const server = createModule().addMethod(
"bar",
z.string(),
(arg) => "bar" as const
);
export type Server = typeof server;
:::
module.buildSubmoduleThis is used to build the submodule, it should be used at the end of the submodule creation and imported in the main module in the addSubmodule method.
::: code-group
import { createModule } from "meteor-rpc";
import { z } from "zod";
export const chatModule = createModule("chat")
.addMethod("createChat", z.void(), async () => {
return "chat" as const;
})
// ✅ it has the buildSubmodule method
.buildSubmodule();
import { createModule } from "meteor-rpc";
import { z } from "zod";
export const otherSubmodule = createModule("other")
.addMethod("otherMethod", z.void(), async () => {
return "other" as const;
})
// ❌ it is missing the buildSubmodule method
.build();
export const otherSubmodule = createModule("other").addMethod(
"otherMethod",
z.void(),
async () => {
return "other" as const;
}
); // ❌ it is missing the buildSubmodule method
import { createModule } from "meteor-rpc";
import { chatModule } from "./server/chat";
const server = createModule()
.addMethod("bar", z.string(), (arg) => "bar" as const)
.addSubmodule(chatModule)
.build();
:::
When using in the client, you have to use the createModule and build methods to create a module that will be used in the client
and be sure that you are exporting the type of the module
You should only create one client in your application
You can have something like api.ts that will export the client and the type of the client
::: code-group
import { createModule } from "meteor-rpc";
const server = createModule()
.addMethod("bar", z.string(), (arg) => "bar" as const)
.build();
export type Server = typeof server;
// you must import the type
import type { Server } from "/imports/api/server";
const app = createClient<Server>();
await app.bar("str"); // it will return "bar"
:::
Our package has a React-focused API that uses react-query to handle the data fetching and mutations.
method.useMutationIt uses the useMutation from react-query to create a mutation that will call the method
::: code-group
import { createModule } from "meteor-rpc";
const server = createModule()
.addMethod("bar", z.string(), (arg) => {
console.log("Server received", arg);
return "bar" as const;
})
.build();
export type Server = typeof server;
// you must import the type
import type { Server } from "/imports/api/server";
const app = createClient<Server>();
export const Component = () => {
const { mutate, isLoading, isError, error, data } = app.bar.useMutation();
return (
<button
onClick={() => {
mutation.mutate("str");
}}
>
Click me
</button>
);
};
:::
method.useQueryIt uses the useQuery from react-query to create a query that will call the method, it uses suspense to handle loading states
::: code-group
import { createModule } from "meteor-rpc";
const server = createModule()
.addMethod("bar", z.string(), (arg) => "bar" as const)
.build();
export type Server = typeof server;
// you must import the type of the server
import type { Server } from "/imports/api/server";
const app = createClient<Server>();
export const Component = () => {
const { data } = app.bar.useQuery("str"); // this will trigger suspense
return <div>{data}</div>;
};
:::
publication.useSubscriptionSubscriptions on the client have useSubscription method that can be used as a hook to subscribe to a publication. It uses suspense to handle loading states
::: code-group
// server/main.ts
import { createModule } from "meteor-rpc";
import { ChatCollection } from "/imports/api/chat";
import { z } from "zod";
const server = createModule()
.addPublication("chatRooms", z.void(), () => {
return ChatCollection.find();
})
.build();
export type Server = typeof server;
import type { Server } from "/imports/api/server"; // you must import the type
const app = createClient<Server>();
export const Component = () => {
// it will trigger suspense and `rooms` is reactive in this context.
// When there is a change in the collection it will rerender
const { data: rooms, collection: chatCollection } =
api.chatRooms.usePublication();
return (
<div>
{rooms.map((room) => (
<div key={room._id}>{room.name}</div>
))}
</div>
);
};
:::
Currently, we have:
You can take advantage of the hooks to add custom logic to your methods, checking the raw and parsed data and the result of the method, If the method fails, you can also check the error.
::: code-group
import { createModule } from "meteor-rpc";
import { z } from "zod";
const server = createModule()
.addMethod("bar", z.string(), (arg) => "bar" as const)
.build();
// you can add hooks after the method has been created
server.bar.addBeforeResolveHook((raw, parsed) => {
console.log("before resolve", raw, parsed);
});
server.bar.addAfterResolveHook((raw, parsed, result) => {
console.log("after resolve", raw, parsed, result);
});
server.bar.addErrorResolveHook((err, raw, parsed) => {
console.log("on error", err, raw, parsed);
});
export type Server = typeof server;
import { createModule } from "meteor-rpc";
import { z } from "zod";
const server = createModule()
// Or you can add hooks when creating the method
.addMethod("bar", z.any(), () => "str", {
hooks: {
onBeforeResolve: [
(raw, parsed) => {
console.log("before resolve", raw, parsed);
},
],
onAfterResolve: [
(raw, parsed, result) => {
console.log("after resolve", raw, parsed, result);
},
],
onErrorResolve: [
(err, raw, parsed) => {
console.log("on error", err, raw, parsed);
},
],
},
})
.build();
export type Server = typeof server;
:::
if you are getting a similar error like this one:
=> Started MongoDB.
Typescript processing requested for web.browser using Typescript 5.7.2
Creating new Typescript watcher for /app
Starting compilation in watch mode...
Compiling server/chat/model.ts
Compiling server/chat/module.ts
Compiling server/main.ts
Writing .meteor/local/plugin-cache/refapp_meteor-typescript/0.5.6/v2cache/buildfile.tsbuildinfo
Compilation finished in 0.3 seconds. 3 files were (re)compiled.
did not find /app/.meteor/local/plugin-cache/refapp_meteor-typescript/0.5.6/v2cache/out/client/main.js
did not find /app/.meteor/local/plugin-cache/refapp_meteor-typescript/0.5.6/v2cache/out/client/main.js
Nothing emitted for client/main.tsx
node:internal/crypto/hash:115
throw new ERR_INVALID_ARG_TYPE(
^
TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received null
at Hash.update (node:internal/crypto/hash:115:11)
at /Users/user/.meteor/packages/meteor-tool/.3.0.4.1tddsze.as7rh++os.osx.arm64+web.browser+web.browser.legacy+web.cordova/mt-os.osx.arm64/tools/fs/tools/fs/watch.ts:329:28
at Array.forEach (<anonymous>)
at /Users/user/.meteor/packages/meteor-tool/.3.0.4.1tddsze.as7rh++os.osx.arm64+web.browser+web.browser.legacy+web.cordova/mt-os.osx.arm64/tools/fs/tools/fs/watch.ts:329:8
at JsOutputResource._get (/tools/isobuild/compiler-plugin.js:1002:19) {
code: 'ERR_INVALID_ARG_TYPE'
}
Node.js v20.18.0
Please check if you are using refapp:meteor-typescript package, if so, you can remove it and use the typescript package instead.
The refapp:meteor-typescript package is currently incompatible with the meteor-rpc package.
If it is still not working, please open an issue in the repo