npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@the-minimal/rpc

v0.2.1

Published

Experience lightning-fast data transfers and bulletproof validation with this tiny TypeScript RPC library, harnessing ones-and-zeroes for streamlined and secure back-to-back development

Downloads

11

Readme

RPC image

Experience lightning-fast data transfers and bulletproof validation with this tiny TypeScript RPC library, harnessing ones-and-zeroes for streamlined and secure back-to-back development.

Highlights

  • Small bundle
    • Client ~ 600 bytes
    • Server ~ 600 bytes
    • Shared ~ 100 bytes
  • Low runtime overhead
  • Contract based
  • Static type inference
  • 100% test coverage
  • Protocol: @the-minimal/protocol
    • Binary protocol
    • Schema-based
    • Single pass encode/decode + assert
    • Produces very small payload
    • Small bundle (< 1 KB)
    • Low runtime overhead
  • Validation: @the-minimal/validator
    • Runtime validations
    • Assertion-only
    • Small bundle (< 1 KB)
    • Low runtime overhead
  • Errors: @the-minimal/error
    • Minimal errors
    • No stack traces
    • Small bundle (~ 120 bytes)
    • Low runtime overhead

Install

yarn add @the-minimal/rpc

Example

import { Type, Method } from "@the-minimal/rpc";
import { contract } from "@the-minimal/rpc/shared";
import { Name } from "@the-minimal/protocol";
import { and, email, rangeLength } from "@the-minimal/validator";

export const userRegisterContract = contract({
  method: Method.Post,
  path: "/user/register",
  input: {
    name: Name.Object,
    value: [
      {
        key: "email",
        name: Name.String,
        assert: and([rangeLength(5, 50), email]),
      },
      {
        key: "password",
        name: Name.String,
        assert: rangeLength(8, 16),
      },
    ],
  },
  output: {
    name: Name.Object,
    value: [
      {
        key: "id",
        name: Name.String,
      },
    ],
  },
});
import { serve } from "bun";
import { procedure, universalMapRouter } from "@the-minimal/rpc/server";
import { init } from "@the-minimal/protocol";
import { userRegisterContract } from "@contracts";

const userRegisterProcedure = procedure(
  userRegisterContract,
  async (value) => {
    // ..
    
    return {
      id: user.id,
    };
  },
);

const callProcedure = universalMapRouter([userRegisterProcedure]);

init();

serve({
  fetch(req) {
    return callProcedure(req);
  },
  port: 3000,
});
import { httpClient } from "@the-minimal/client";
import { init } from "@the-minimal/protocol";
import { userRegisterContract } from "@contracts";

init();

const userRegister = httpClient(
  import.meta.env.RPC_URL,
  userRegisterContract,
);
<script lang="ts">
  import { goto } from "svelte";
  import { userRegister } from "@api"; 
  import { Error } from "./Error"; 

  let email = "";
  let password = "";
  let error: string | null = null;
  
  const register = async () => {
    try {
      await userRegister({ 
        email, 
        password 
      });
      
      goto("/login");
    } catch (e) {
      error = e.message;
    } 
  };
</script>

<div>
  {#if error}
    <Error message={error} />
  {/if}
  
  <input type="email" bind:value={email} />
  <input type="password" bind:value={password} />
  
  <button on:click={register}>Register</button>
</div>

FAQ

JSON is a text-based and human-readable protocol which is good for things like config files but suboptimal for transferring data between potentially low-end device on a potentially slow connection.

Most binary protocols that support TypeScript come with their own DSL which then compiles into, often, quite big JavaScript files.

Compared to statically typed compiled languages JavaScript and by extension TypeScript has a unique challenge of checking types at runtime.

Usually this results in a combination of JSON from which we cannot (easily) generate TypeScript types and assert basic types and a runtime data validation library that asserts the basic types together with also asserting some of their properties such as length of a string or comparing number ranges.

This means that the runtime has to encode and decode unknown data and after that assert it by looping through it again.

All of this is quite inefficient from the point of view of cpu, ram and payload size.

Instead, we use a binary schema-full protocol which packs the data into binary and asserts the data while doing so and it does that in a very efficient way.

This of course assumes that the device makes at least a couple of requests to make up for the initial penalty of downloading and parsing the schema (contract) itself.

In other words if, on average, you make only one request per one endpoint per one session, then it's probably better to use JSON and a simple and laser-focused, most likely handwritten, assertions.

Technically speaking sending body in any kind of request is not an issue.

However, in GET requests it's very rarely used and the HTTP specification says that in most cases we should not use it.

If we agreed to not use body in GET requests we would have to use query parameters and/or URL pathname instead which means that we would have to change how we either define contracts or how we parse them increasing complexity and maybe introducing potential parsing bugs that would happen only in some contracts but not others.

The main reason users might want to use GET requests as opposed to let's say POST requests is caching.

It's possible to cache POST requests but in most cases it's not the default behavior and would require some additional work.

Also in some not-so-rare cases (imagine getting a list of products based on a complex filter) we might hit the URL length limit and in that case we would have to use POST requests or somehow either split the URL into multiple parts or make the query parameters smaller.

Because of these reasons we use body in GET requests in tandem with SHA-1 hash, created from the body ArrayBuffer, inserted into the URL of the request (format: ${root}${path}#${hash}).

By default, hashing is disabled, but you can enable it by setting hash: true in the contract.

Also, if creating SHA-1 hash is too slow or unnecessary in your use-case you can simply pass your custom hash when calling the procedure as the second argument.

If you use GET method it uses the default caching behavior of the browser.

If you need a custom behavior you can simply set cache headers in the contract.

Essentially middlewares are just functions that are called before and/or after the procedure.

So instead of doing something like this:

const router = new Router();

router.before(middleOne); 
router.before(middleTwo); 
router.after(middleThree); 

// ..

router.add("/your/endpoint", async (value) => { /* .. */ }); 

Do something like this instead:

const yourEndpointProcedure = procedure(contract, async (value) => {
  await middleOne(value); 
  await middleThree(value); 
  
  // ..
  
  await middleThree(result); 
  
  return result;
});

Obviously if you repeat the same middleware multiple times you should probably wrap it into a function which accept a handler function and use that as the procedure handler instead.

In the spirit of the previous question you can either return context from your middlewares or create AsyncLocalStorage outside of the procedure and then use it in whichever procedure and however deep you want.

API

Contract

Contract is a declaration of how client and server communicate with each other.

  • path = Request path
  • method = Request method
  • headers = Request headers
  • input/output = @the-minimal/protocol schemas

Contracts are passed into procedure and client.

export const userRegisterContract = contract({
  method: Method.Post,
  headers: {},
  path: "/user/register",
  input: {
    name: Name.Object,
    value: [
      {
        key: "email",
        name: Name.String,
        assert: and([rangeLength(5, 50), email]),
      },
      {
        key: "password",
        name: Name.String,
        assert: rangeLength(8, 16),
      },
    ],
  },
  output: {
    name: Name.Object,
    value: [
      {
        key: "id",
        name: Name.String,
      },
    ],
  },
});

Procedure

Procedure defines how to handle a contract on the server side.

It accepts decoded value and returns an encoded value which is then sent back to the client.

Procedures are called composed into an array of procedures which is used by routers.

const userRegisterProcedure = procedure(
  userRegisterContract,
  async (value) => {
    // ..
    
    return {
      id: user.id,
    };
  },
);

Client

Client is a wrapper around fetch that handles encoding and decoding of the Request and Response.

It accepts a base url and a contract and returns a function that accepts a value and optionally a custom hash and returns a promise that resolves with Result.

const userRegister = httpClient(
  import.meta.env.RPC_URL,
  userRegisterContract,
);

// ..

const user = await userRegister({
  email: "[email protected]",
  password: "Test123456"
});

Router

There are multiple routers for different runtimes and frameworks.

UniversalMapRouter

Supports any runtime and framework that uses the standard Request and Response API.

This router is useful for long-running runtimes (e.g. Node, Bun, Deno) since it caches procedures into a Map for a fast lookup.

It returns a function which accepts a Request and returns a Promise<Response>.

const callProcedure = universalMapRouter([userRegisterProcedure]);

serve({
  fetch(req) {
    return callProcedure(req);
  },
  port: 3000,
});

UniversalArrayRouter

Supports any runtime and framework that uses the standard Request and Response API.

This router is useful for short-running runtimes (e.g. CloudFlare Workers) since it doesn't cache procedures and instead directly filters the input AnyProcedure[].

It returns a function which accepts a Request and returns a Promise<Response>.

const callProcedure = universalArrayRouter([userRegisterProcedure]);

serve({
  fetch(req) {
    return callProcedure(req);
  },
  port: 3000,
});