ðŠķ
Featherweight
Zero dependencies. Zero packages. Just copy the source code and get started.
router.ts
export const router = new Router<{ db: Database, cookies: Cookies }>()
someroute.ts
import { router } from "router"
import { ctx } from "simplycall"
const route = router
.on("route scope")
.with({
/**
* The login function
*
* Did you really need this documentation?
*
* @params username The username duh.
* @params password Are you sure you're sending this across https?
*/
async login(username: string, password: string) {
const session = this[ctx].db.sessions.authorize({ username, password });
if (session) {
this[ctx].cookies.set("session", session.token)
return true
}
return false
},
async logout() {
this[ctx].db.sessions.revoke(this[ctx].session.id)
},
async getData() {
return this[ctx].db.getAllTheData()
}
})
.conformArgumentsThrough({
login(username, password) {
if (typeof username !== "string") throw Error()
if (typeof password !== "string") throw Error()
return [username, password]
},
logout: "assume typesafe",
getData: "assume typesafe"
})
route-server.ts
import { makeTransportFromFetch } from "simplycall/transport/http/fromFetch"
import "someroute" // All routes must be imported to ensure route registration side effect ordering
import { router } from "router"
const handler = makeTransportFromFetch(router)
export function whateverFrameworkServerYoureUsing(request: Request) {
const result = handler.onRequestInfallibly(request)
if ("err" in result) {
console.error(result)
return new Response(null, { status: 500 })
}
return new Response(result.ok.body, { headers: result.ok.headers })
}
client.ts
import { makeTransportThroughFetch } from "simplycall/transport/http/throughFetch"
export const client = new Client<AbortController>(makeTransportThroughFetch({
url: "/your-mysterious-rpc-url-endpoint"
}))
somewhere-on-the-client.ts
import { typeof route as TheRoute } from "someroute"
import { client } from "client.ts"
const isLoggedIn = client<TheRoute>
.on("route scope") // Intellisense autocompletes.
.login("username", "password12345") // Get type hints and documentation
const controller = new AbortController()
setTimeout(() => controller.abort(), 250)
const theData = client<TheRoute>
.on("route scope", controller)
.getData()
Creating typed RPC routes is a fairly straightforward process. You just need three parts.
This is something you can write in one afternoon.
Just run
npm install...
Yet another library? Screw that.
Here's the code in its full glory. To install, copy and paste it into your project.
// The MIT License (MIT)
//
// Copyright (c) 2025 SyntheticGoop
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
export const ctx = Symbol();
/**
* A router that binds functions and validators to unique identifiers and
* provides an interface to call those functions.
*/
export class Router<Context = void> {
private routes: Record<
string,
{
parser: (...args: unknown[]) => unknown[];
handler: (
this: { [ctx]: Context },
...args: unknown[]
) => Promise<unknown>;
}
> = {};
/**
* Define the scope to which a route is bound to.
* This must combine with the route name to be globally unique.
*
* The scope name is used to disambiguate what would otherwise be
* duplicate route functions.
*
* You are allowed to reuse scopes in multiple places, just as long
* as you do not create functions with the same name on the same
* scope.
*
* Examples
* ```ts
* .on("authorization routes"); // Named style scope
* .on("/auth/public"); // Path based routing style scope
* .on("DSO240DF_lURJNW"); // Random ID based scope
* ```
*/
public on<Scope extends string>(/** Function scope */ scope: Scope) {
const boundThis = this;
return {
/**
* Define the functions bound on this scope.
*
* Functions will have their `this` keyword include a binding to
* the {@link Context} object. The context can be accessed using
* the {@link ctx} symbol.
*
* Despite how it is declared, this object is not shared between
* functions and across multiple function invocations.
*
* It is generally recommended that these functions never throw
* and instead return errors as values.
*
* Documenation on the functions and their arguments will be preserved.
*
* Be careful in the selection of function argument types. Depending on
* the serializer used, not all arguments types will be serializable.
*
* The requirement for defining functions as object keys is a side effect
* of the requirement to accurately reflect function docstrings.
*
* Examples
* ```ts
* .with({
* async route1(arg1: string, arg2: Blob) {
* return this[ctx].auth.isAdmin(); // Access context object
* },
* async route2(arg1: boolean) { // Declare multiple routes
* return null;
* },
* });
* ```
*/
with<
Rpc extends Record<
string,
// biome-ignore lint/suspicious/noExplicitAny: Generic inference
(this: { [ctx]: Context }, ...args: any) => Promise<any>
>,
>(
/** Function handlers */
handlers: Rpc,
) {
return {
/**
* As the router is intended to be mapped onto data without type
* guarantees, it is required to either define a parser for the
* function's arguments or explicitly opt out of any type assertion.
*
* These parser functions should **always** throw on invalid data.
* It is expected that functions calls are guaranteed to be typesafe
* through `typescript` type inference. Therefore parsing failures
* are expected to not come from regular usage.
*
* Try not to define types that are more restrictive than what typescript
* types can guarantee.
*
* Examples
* ```ts
* .conformArgumentsThrough({
* route1: zod.array([zod.string(), zod.boolean()]).parse, // Use an extrnal validation library like zod
* route2(...args: unknown) { // Define your own parser
* this[ctx].failOnUnauth() // If defined as an object function, the context can be accessed.
* if (typeof args[0] !== "string") throw Error();
* if (typeof args[1] !== "number") throw Error();
* return [args[0], args[1]] as [string, number];
* }
* });
* ```
*/
conformArgumentsThrough<
Parsers extends {
[Key in keyof Rpc]:
| ((
this: { [ctx]: Context },
...data: unknown[]
) => Rpc[Key] extends (
...args: infer Args
// biome-ignore lint/suspicious/noExplicitAny: Generic inference
) => any
? Args
: never)
| "assume typesafe";
},
>(parsers: Parsers) {
for (const name of Object.keys(parsers)) {
const id = `${scope} ${name}`;
if (id in boundThis.routes)
throw Error(
`Route [${id}] was declared twice and is not unique.`,
);
const parser =
parsers[name] === "assume typesafe"
? (...args: unknown[]) => args
: parsers[name];
const handler = handlers[name];
boundThis.routes[id] = {
parser,
handler,
};
}
return {
scope,
parsers,
handlers,
with(context: Context) {
return { [ctx]: context, ...handlers };
},
};
},
};
},
};
}
/**
* Route requests through the router, returning a result
* object containing the state of execution.
*
* This function will never throw.
*/
public async routeFromTransportInfallibly(rpc: {
/**
* The context object to provide a route handler.
*/
ctx: Context;
/**
* The unique `identity` of the route.
*/
id: string;
/**
* The arguments to be call the routed function with.
*/
args: unknown[];
}) {
const route = this.routes[rpc.id];
if (!route)
return {
err: {
"non existent route": `Route [${rpc.id}] does not exist!`,
},
};
let args: unknown[];
try {
args = route.parser(...rpc.args);
} catch (error) {
return {
err: {
"argument parsing failed": error,
},
};
}
const result = await route.handler
.call({ [ctx]: rpc.ctx }, ...args)
.then((ok) => ({ ok }))
.catch((err) => ({ err }));
if ("err" in result)
return {
err: {
"handler errored": result.err,
},
};
return result;
}
}
/**
* A client library that, from the inference of a router route,
* generates a corresponding object that allows for interfacing
* with that route.
*/
export class Client<Context = undefined> {
/**
* Create a new client.
*
* It is necessary to define a shared function that will
* be used to make remote calls to the router.
*/
constructor(
/**
* Object that maps over a remote procedure call.
*/
private readonly transport: {
/**
* Remote call function.
*
* This function must always return the expected response or error.
* This property of throwing error on unexpected response is a
* side effect of accurate reflection of function docstrings.
*
* @returns `unknown` The response from calling the remote function.
*/
sendThroughTransportFallibly(rpc: {
/**
* The context object injected as part of each call.
*/
ctx: Context;
/**
* The unique `identity` of the route.
*/
id: string;
/**
* The arguments to be call the routed function with.
*/
args: unknown[];
}): unknown;
},
) { }
/**
* Call a typed remote route with the bound client.
*
* Using the remote route's type as a generic, we can
* generate the correct corresponding types and arguments
* for the server to properly route requests.
*
* Examples
* ```ts
* const route = server.on(...).with(..).conformArgumentsThrough(...); // Route definition. See {@link Router}
* const result = client.on<typeof route>("scope").remoteFunctionName(...args); // Call the route
* const result = client.on<typeof route>("scope", ctx).remoteFunctionName(...args) // Call the route with context. Required if context is defined.
* ```
*/
public on<MaybeRPC>(
scope: MaybeRPC extends { scope: infer Scope } ? Scope : never,
...options: Context extends undefined ? [ctx?: Context] : [ctx: Context]
): MaybeRPC extends { handlers: infer Rpc } ? Rpc & { [ctx]: never } : never {
const transport = this.transport;
return new Proxy(
{},
{
get(_, prop) {
/* v8 ignore next 1 */
if (typeof prop !== "string") return;
return (...args: unknown[]) =>
transport.sendThroughTransportFallibly({
id: `${scope} ${prop}`,
args,
// @ts-expect-error Typescript does not understand that when context extends undefined, Context === Context | undefined
ctx: options[0],
});
},
},
// biome-ignore lint/suspicious/noExplicitAny: Construction of "any" object
) as any;
}
}
// The MIT License (MIT)
//
// Copyright (c) 2025 SyntheticGoop
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { decode } from "../../codec/formData/decode";
import { encode } from "../../codec/formData/encode";
/**
* Create a transport that receives data from
* a http {@link fetch} {@link Request}.
*
* This implementation constrains each argument
* to one of the following types for serialization:
* 1. {@link JSON} serializable object
* 2. {@link Blob} object
*/
export function makeTransportFromFetch<Context>(handler: {
routeFromTransportInfallibly(rpc: {
ctx: Context;
id: string;
args: unknown[];
}): Promise<{ err: Record<string, string> } | { ok: unknown }>;
}) {
return {
/**
* Processes a request through the route handler and returns
* a result type containing either errors or the headers and
* response body to be used for response construction.
*/
async onRequestInfallibly(request: Request, ctx: Context) {
const id = request.headers.get("x-simplycall-id");
if (typeof id !== "string")
return {
err: {
"id not provided": "",
},
};
const maybeFormData = await request
.formData()
.then((ok) => ({ ok }))
.catch((err) => ({ err }));
if ("err" in maybeFormData) return maybeFormData;
const maybeArgs = await decode(maybeFormData.ok);
if ("err" in maybeArgs) {
return {
err: {
"unable to parse request": maybeArgs.err,
},
};
}
const response = await handler.routeFromTransportInfallibly({
args: maybeArgs.ok as unknown[],
ctx,
id,
});
if ("err" in response) return response;
return {
ok: {
body: encode(response.ok),
},
};
},
};
}
// The MIT License (MIT)
//
// Copyright (c) 2025 SyntheticGoop
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { encode, decode } from "../../codec/formData";
/**
* Creates a transport that sends data over
* http {@link fetch}.
*/
export function makeTransportThroughFetch(config: {
url: URL;
}) {
return {
async sendThroughTransportFallibly(rpc: {
ctx: unknown;
id: string;
args: unknown[];
}) {
const maybeFormBody = encode(rpc.args);
if ("err" in maybeFormBody) {
throw Error(`Unable to serialize arguments for [${rpc.id}]`, {
cause: maybeFormBody.err,
});
}
await fetch(config.url, {
headers: {
"x-simplycall-id": rpc.id,
},
method: "POST",
body: maybeFormBody.ok,
}).then(async (result) => {
const argtype = result.headers.get("x-simplycall-argtype");
if (argtype !== "b" && argtype !== "j")
throw Error(
`Unable to parse response type [${argtype}] for [${rpc.id}].`,
);
const formData = await result.formData(); // Allow errors to propagate
const response = await decode(formData);
if ("err" in response) {
throw Error(`unable to decode response for [${rpc.id}]`, {
cause: response.err,
});
}
return response.ok;
});
},
};
}