RPC Docs.
Prim+RPC does not have a stable release and should not yet be used in production applications. Please report any security issues that you find according to the security policy.
Prim+RPC's goal is simple: write a function on the server and call it from the client. Once Prim+RPC is set up, it is incredibly easy to add new functions to the backend. However caution should be taken since input given to the server cannot be trusted. Tasks such as validation, sanitation, authentication, and more are outside of Prim+RPC's responsibilities and are left to the developer (and the libraries that you choose).
When you pass a function to the .module
option of Prim+RPC, it is intended to become RPC. By default however you will
be denied access to execute the function remotely on the client until you explicitly mark it as an RPC. This is done in
one of two ways: by setting the .rpc
property on the function to true
or by adding your function to the .allowList
option of the Prim+RPC server.
import { createPrimServer } from "@doseofted/prim-rpc"// This can be called from the client because we set the `.rpc` property to `true`function myPublicFunction() { return "I'm allowed to be called from the client"}myPublicFunction.rpc = true// This cannot be called from the client even though we passed it to the client// (note: type definitions are still shared because is is given to server below)function myPrivateFunction() { return "I'm only allowed to be called on the server"}// While we cannot add an `.rpc` property directly, we can add it to the allow list below// We could alternatively create a wrapper function with an `.rpc` property that calls `myFrozenFunction()`function myFrozenFunction() { return "I'm allowed to be called from the client"}Object.freeze(myFrozenFunction)createPrimServer({ module: { myPublicFunction, myPrivateFunction, myFrozenFunction }, allowList: { myFrozenFunction: true },})
Prim+RPC does not validate or sanitize arguments passed to the server. It is up to the developer to ensure that the arguments provided are of the expected type and shape. You may choose a validation library of your choice to accomplish this. Without a validation library, it should be expected that types given from the client could not match your type definitions. Consider the following:
In this example a user is subscribed without their consent because the string no
is a truthy value. This can be
avoided with validation of given arguments. You may choose any library that you'd like to validate arguments. We'll use
Zod in this example but you could also use libraries like TypeBox, ArkType, or many others. Below is a safer example:
import { subscribeToNewsLetter } from "./my-newsletter-service"import { z } from "zod"const formInputsSchema = z.object({ email: z.string().trim().email(), subscribe: z.boolean().default(false),})// this is only a simple demonstrationexport function submitForm(givenForm: z.infer<typeof formInputsSchema>) { const form = formInputsSchema.parse(givenForm) return form.subscribe ? subscribeToNewsLetter(form.email) : false}submitForm.rpc = truecreatePrimServer({ module: { submitForm } })
Now when we submit this form, we will be presented an error because the client did not provide the boolean value that is expected. If you're coming from tRPC, you may consider using Zod's syntax for defining a function (which bears some resemblance to defining a tRPC router):
import { subscribeToNewsLetter } from "./my-newsletter-service"import { z } from "zod"// this is only a simple demonstrationconst submitForm = z .function() .args( z.object({ email: z.string().trim().email(), subscribe: z.boolean().default(false), }) ) .returns(z.boolean()) .implements(form => { return form.subscribe ? subscribeToNewsLetter(form.email) : false })submitForm.rpc = truecreatePrimServer({ module: { submitForm } })
Prim+RPC will not allow functions to be called unless they have a property .rpc
set to true
or the function names
are given in an .allowList
. This means that if you accidentally pass a function to the server that wasn't intended, it
still can't be called. However you should be cautious with the type definitions of the given module, especially if your
type exports use the typeof
operator on your module. Consider the following:
In this example, the server is given the module
object which includes the settings
that were exported. While this is
definitely a problem (settings and environment variables should not be exported like this, regardless of whether you
are using Prim+RPC), at least the values are not shared directly with the Prim+RPC client.
The email will send in this example but the mailingApiKey
will fail as it's not a function and not marked as RPC.
However, we've also exported the types of the module including client.settings.mailingApiKey
. While we can't see the
value of the secret, we now know where the secret is located. Security through obscurity is not security but we
certainly don't need to tell anyone where our secrets are located.
While exporting secrets is not recommended, it's more likely that you may need to export an internal function for usage in your server and it could be collocated with a function intended as RPC. Extra caution should be taken with exports when using that module with Prim+RPC so that TypeScript types of those functions aren't shared (even if the functions themselves are not shared).
There are a few ways to tackle this. The easiest is not to export secret information or internal logic but this may not
always be possible. Another possible solution: you could use a file naming scheme to make clear what is internal and
what is external (marked RPC). For example, you may export functions as usual and use another file public.ts
that only
re-exports functions marked as RPC.
As long as we use the server/public.ts
module/types with the Prim+RPC server, we can avoid exposing the
sendEmailPrivately
types to the client.
By default, Prim+RPC will use the environment's default JSON handler for serialization and unjs/destr for deserialization (which provides the benefit of protection from prototype pollution while behaving predictably).
You may override the JSON handler with your own as you'd like. This may provide support for additional types or may even be used to serialize to a format other than JSON. However be aware that a new handler could introduce new security issues and this should be considered, especially if RPC is intended to be shared over public channels.
import { createPrimClient, createPrimServer } from "@doseofted/prim-rpc"import jsonHandler from "superjson"// superjson could be used, for example: it also guards against attacks such as prototype pollution.createPrimClient({ jsonHandler })createPrimServer({ jsonHandler })
Prim+RPC keeps a narrow scope: it handles RPC but it utilizes separate plugins for transport. This means that you can choose any transport that you'd like by using an available plugin or creating your own but it also means that the security of the transport falls outside of Prim+RPC's scope. Securing the transport may mean something different depending on the environment. For instance, on a web client, you may consider the possibility of XSS. If you're using Prim+RPC on a web server then you may consider using TLS, CORS headers, CSRF tokens, rate limiting, authentication, and other means. These are good practices in general for an application and are not specific to Prim+RPC.