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 🙏

© 2026 – Pkg Stats / Ryan Hefner

@interludelayer-sdk/sdk

v0.1.3

Published

Open a session and send gasless, sub-millisecond transactions to an Interlude node.

Readme

@interludelayer-sdk/sdk

Open a session and send gasless, sub-millisecond transactions to an Interlude node.

One wallet signature buys a session key. Every call after that is signed by the key, costs no gas, and comes back in a single round trip with its return value already decoded.

The shortest thing that works

That snippet talks to the Paris Room. https://rpc.interludelayer.xyz serves that contract and no other. The live demo picks the nearest of five floors. After npx @interludelayer-sdk/cli ship, pass the printed app and node into createInterludeClient. A 502 on that URL for a few minutes is the node image building.

import { createPublicClient, http, type WalletClient } from "viem";
import { monadTestnet } from "viem/chains";
import { createInterludeClient } from "@interludelayer-sdk/sdk";
import { createInterludeHooks } from "@interludelayer-sdk/sdk/react";
import { roomAbi } from "./room-abi";

const { InterludeProvider, useSession, useSessionCall } = createInterludeHooks(
  createInterludeClient({
    app: "0xfd5876357346CDF23d73889b47919C8f4CC56dCA",
    abi: roomAbi,
    node: "https://rpc.interludelayer.xyz",
    base: createPublicClient({ chain: monadTestnet, transport: http() }),
  }),
);

function Floor() {
  const { session, open } = useSession();
  const join = useSessionCall("join");
  const move = useSessionCall("move");

  if (!session) return <button onClick={() => open()}>Enter</button>;

  return (
    <>
      <button onClick={() => join.send()}>Join</button>
      <button onClick={() => move.send([1])}>East</button>
    </>
  );
}

export function Game({ wallet }: { wallet: WalletClient }) {
  return (
    <InterludeProvider wallet={wallet} scope={["join", "move"]}>
      <Floor />
    </InterludeProvider>
  );
}

That is the whole client. For a contract you shipped, replace app and node with what the CLI printed. open() prompts the wallet once — the user signs a grant that says "this key may call join and move, for the next hour" — and the buttons never prompt again.

A process is the same three lines, with memoryStore() and a key it holds. examples/agent-room.mjs does that against the public Room.

Without React the core is the same three lines:

const interlude = createInterludeClient({ app, abi: roomAbi, node, base });

const session = await interlude.openSession({ wallet, scope: ["join", "move"] });
await session.send("join");
const { latencyMs } = await session.send("move", [1]);

Install

npm i @interludelayer-sdk/sdk viem

viem and react are peer dependencies. The main entry does not import React; only @interludelayer-sdk/sdk/react does.

What a session is

A SessionGrant is an EIP-712 message the user signs with their wallet, naming a freshly generated secp256k1 key, an expiry, the hub's current session epoch, and the selectors that key may call. The app verifies the signature on every call and resolves _actor() to the granter, so the app sees the user, not the key.

The SDK does the parts that are easy to get wrong:

  • The domain. chainId is the base chain's, not the node's, and verifyingContract is the app. A grant signed under the wrong one recovers to nobody and every call reverts. Read once from the base client, cached.
  • The epoch. A grant must name hub.sessionEpochOf(granter) as of signing or it is stale on arrival. Fetched for you — including the hub address, which comes off the app's own hub() getter, so there is nothing to configure.
  • The scope. scope: ["move"] is resolved against the ABI, so a typo is an error before the wallet opens rather than a revert later. Full signatures ("move(uint256)") and raw selectors work too, for overloads.
  • The wrapping. send encodes the call, wraps it in withSession(grant, sig, call), signs with the session key, and unwraps the bytes the wrapper returns back into the app's own return type.

You can check the SDK's digest against the app's before signing:

await interlude.openSession({ wallet, scope: ["move"], assertDigest: true });

That costs one read of sessionDigest(grant) and turns a silent EIP-712 mismatch into a throw naming both digests. Worth it once in development, not on every session.

Storage, and the honest trade-off

The session key and its signed grant are stored together, in sessionStorage, keyed by app

  • base chain + granter. That pairing is the point: a page refresh restores both, so the user keeps playing with no wallet prompt at all. Closing the tab ends the session.

The cost is that the key sits in sessionStorage, readable by any script running on the page. The blast radius is deliberately small — withSession is not payable, so a stolen key cannot move value, and it can only call the selectors the grant named, until the grant expires — but it is a real key doing real writes on the user's behalf, and an XSS hole means an attacker can call move as your user for the rest of the hour.

If that is not a trade you want, take memory only:

import { memoryStore } from "@interludelayer-sdk/sdk";

createInterludeClient({ app, abi, node, base, store: memoryStore() });

Then a refresh costs a new signature. There is no localStorage option: a key that outlives the tab is a key nobody remembers granting.

store takes anything with get/set/remove, if you want a cookie, an iframe, or a Worker.

Revocation

hub.bumpSessionEpoch() is the panic button. It invalidates every grant the user has ever signed, for every Interlude app at once, in one base-chain transaction:

const { revoke } = useSession();
await revoke(); // or interlude.revokeAll(wallet)

Individual sessions do not need revoking; they expire. Default expiry is one hour, overridable with expirySeconds.

Reading state

await interlude.read("squareOf", [user]); // the node's live state
await interlude.readSettled("squareOf", [user]); // the last committed value on the base chain

The two differ by whatever the node has not committed yet — that gap is the whole design. In React:

const { data, refetch } = useRead("squareOf", [user], { pollMs: 1000 });
const { data: live } = useWatch("squareOf", [user]);
const { status } = useNodeStatus();

useWatch / interlude.watchRead("squareOf", [user], setSquare) opens a WebSocket (interlude_subscribe("applied")) and re-reads your view each time any call lands on that node. The socket is not tied to a contract shape: Room or an app you have not written yet all hear the same event (app, input, output, logs). Decode logs against your ABI, or ignore them and just re-read. A node that does not serve the socket falls back to polling on its own.

const stop = interlude.watch((call) => {
  if (!call.succeeded) return;
  // any app: decode call.logs, or re-read whatever view you named
  void interlude.read("squareOf", [user]).then(setSquare);
});
// later
stop();

status is what interlude_session reports: the app, the ephemeral chain id, the validator, the pinned base block, the batches committed so far, and the diffs still pending. It is the quickest way to find out whether the node is serving the app your frontend thinks it is.

A receipt from send is the ephemeral execution. Monad has those diffs only after a commit. waitSettled() polls until pendingDiffs is empty (default 60s). Every send also returns settled as a promise you can await later. It is lazy: unused settled does not poll. send itself does not wait, so tap latency stays the round trip.

const { result, settled } = await session.send("move", [1]);
// `result` is already the live return. Monad catches up on its own.
await settled; // or `await interlude.waitSettled()`

interlude.commit() publishes the pending diffs now instead of waiting out the node's interval, which is mostly useful in tests. A hosted node that set INTERLUDE_COMMIT_TOKEN needs createInterludeClient({ …, commitToken }) or the call is refused.

Latency

send uses interlude_sendTransaction, which returns the receipt and the execution output in one round trip. viem's sendTransaction + waitForTransactionReceipt costs three, and then still cannot tell you what the call returned. If the node does not serve the custom method the SDK falls back to eth_call + eth_sendRawTransaction + eth_getTransactionReceipt on its own, and latencyMs will show it.

Measured against a local node on loopback, per move call: 1.3 ms median wall clock end to end (p95 2.6 ms, min 1.0 ms), of which ~0.26 ms is the local ECDSA signature and ~0.28 ms is the bare round trip. The default transport is a WebSocket kept open for the tab; HTTP is the fallback. The node's own execution is the sub-millisecond part; signing and JSON-RPC framing are most of what is left. A user in another continent pays the fibre, not the EVM — ship --region puts the node next to them.

Nonces are tracked client-side for the same reason — asking the node for one before each call would double the round trips. If the count drifts (another tab, a restarted node) the SDK resynchronises once and retries.

Errors

Every named revert in Delegatable and Session becomes a typed error carrying a sentence about what to do next. Reverts from the app's own ABI come back as AppRevertError with the error name and decoded arguments.

| Error | What happened | | --- | --- | | SessionExpiredError | The grant's hour ran out. Open a new session. | | SelectorOutOfSessionScopeError | The call is not in the grant's scope. See below. | | SessionEpochStaleError | The user revoked with bumpSessionEpoch(), or the node's pinned block predates the bump. The message says which, because it reads the hub to find out. | | WrongSessionKeyError | The grant was presented by a key it does not name. | | PrivilegedSelectorError | No grant may reach the delegation controls or hub callbacks, whatever its scope says. | | SessionNotSignedByGranterError | The signature does not recover to the granter — usually a grant signed for another app or another chain id. | | EmptySessionScopeError | A grant with no selectors and no anyFunction authorises nothing. | | SessionAlreadyOpenError | withSession refuses to nest. | | DelegatedWritesDisabledError | The write went to the base chain instead of the node. | | AppRevertError | The app's own rule, with its name and arguments. | | UnrecognisedRevertError | Revert data no error in the ABI matches. | | NodeUnreachableError | The node did not answer. | | InvalidScopeError | The scope names a function the ABI does not have. |

Also SessionGranterIsZeroError, SessionKeyIsZeroError, MalformedSessionCallError, BadSessionSignatureError, MalleableSessionSignatureError, NoActorError, NotRegisteredError, DelegatableError for the rest, and SessionUnusableError for the SDK's own preconditions. All extend InterludeError.

decodeRevert(data, abi) is exported if you have raw revert data of your own to make sense of.

The this.other() limitation

A scoped session records the selector its grant was presented for, and trusts the actor for that selector only. An app that dispatches work through an external self-call — this.other() — hands the inner frame a different msg.sig, so _actor() in that frame reverts with SelectorOutOfSessionScope even though the grant covers the function you called.

Adding the inner selector to the scope does not help: the recorded selector is the outer one. The SDK detects this case (it already checked the scope locally, so a scope revert from the node can only be a self-call) and says so instead of showing you four bytes. The fixes are to call each function under its own grant, or to open the session with anyFunction: true — the only grant that carries an actor through a self-call. Or, better, to have the app call its internal function internally.

Opening the delegation

Not the hot path — this is the app owner's job, once, on the base chain — but the helpers are here so you do not need a second ABI:

await interlude.delegateAll(ownerWallet);
await interlude.delegateKey(ownerWallet, keyOf(userAddress));

API

@interludelayer-sdk/sdk

| Export | | | --- | --- | | createInterludeClient(config) | The core client. | | client.openSession(options) | Restore, or prompt once and sign. | | client.restoreSession(granter) | Restore, or null. Never prompts. | | session.send(fn, args) | A gasless call. Returns { result, receipt, hash, latencyMs, settled }. | | session.covers(entry), session.isExpired(), session.discard() | | | client.read, client.readSettled | View calls against the node and the base chain. | | client.status(), client.commit() | interlude_session, interlude_commit. | | client.revokeAll(wallet) | hub.bumpSessionEpoch(). | | client.epochOf(user), client.hubAddress(), client.baseChainId() | | | client.sessionDigest(grant), client.sessionDigestOnChain(grant) | For comparing the two. | | sessionGrantTypedData, sessionGrantDigest, signSessionGrant, resolveScope, grantCovers | The grant primitives, usable on their own. | | memoryStore(), webStorageStore(storage), defaultStore() | | | delegatableAbi, hubAbi, keyOf, GLOBAL_PARTITION | |

@interludelayer-sdk/sdk/react

createInterludeHooks(client) returns InterludeProvider, useSession, useSessionCall, useRead, useNodeStatus, useInterlude.

It is a factory rather than free hooks because React context cannot be generic: erased to Abi, send("move", [3n]) would take a string and an unknown[] and check neither. Bind the client once where you configure it and every call site is typed off your ABI.

useSessionCall(...).send resolves rather than rejects on failure, putting the error in error, so a bare onClick={() => move.send([3n])} cannot produce an unhandled rejection. Use session.send where you want the throw.

Tests

pnpm test:sdk:e2e   # from the repo root; or scripts/sdk-e2e.sh

There is one entry point because there is nothing worth testing against a mock: the digest tests compare against a deployed app's own sessionDigest, and the rest talks to a node. The script starts anvil, deploys the hub and Players, delegates the player's partition, starts the node against anvil, then runs the suite — a session opened and used, _actor() resolving to the granter, a commit settling on chain, a session restored from a real sessionStorage across a remount, and every failure mode: out of scope, expired, and revoked by bumpSessionEpoch().

--keep leaves the chain and the node running afterwards, which is the quickest way to point a frontend at them. With a stack already up, pnpm --filter @interludelayer-sdk/sdk test reruns the suite on its own, given INTERLUDE_BASE_RPC, INTERLUDE_NODE_RPC, INTERLUDE_APP and INTERLUDE_PLAYER_PK.