Meteor-RPC
What is this package?
Inspired on zodern:relay and on tRPC
This package provides functions for building E2E type-safe RPCs focused on React front ends.
How to download it?
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.
How to use it?
There are a few concepts that are important while using this package:
- This package is built on top of
Meteor.methods
andMeteor.publish
but with types and runtime validation, their understanding is important to use this package. - Every method and publication uses
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
createModule
This 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
build
at the end of module creation to ensure that the module will be created.
Example:
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.addMethod
Type:
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.
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.addPublication
Type:
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.
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();
});
module.addSubmodule
This 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.buildSubmodule
when creating a submodule
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.addMiddlewares
Type:
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:
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.build
This 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.
// ✅ 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.buildSubmodule
This 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.
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();
Using in the client
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
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"
React focused API
Our package has a React-focused API that uses react-query
to handle the data fetching and mutations.
method.useMutation
It uses the useMutation
from react-query to create a mutation that will call the method
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.useQuery
It uses the useQuery
from react-query to create a query that will call the method, it uses suspense
to handle loading states
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.useSubscription
Subscriptions 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
// 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>
);
};
Examples
Currently, we have:
- chat-app that uses this package to create a chat-app
- askme that uses this package to create a Q&A app, you can check it live here
Advanced usage
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.
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;
Known issues
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