Skip to main content

Resonate TypeScript SDK

Welcome to the Resonate TypeScript SDK guide! This SDK makes it possible to write distributed async/await applications with TypeScript. This guide covers installation and features that the SDK offers.

โžก๏ธ Resonate TypeScript SDK API reference

tip

If you are new to Resonate, we recommend following the Resonate TypeScript SDK quickstart tutorial to get started learning how to use Resonate. ๐Ÿš€

Installationโ€‹

How to install and use the TypeScript SDK in your project.

To install the SDK in your project you can use npm or yarn.

npm

npm install @resonatehq/sdk

yarn

yarn add @resonatehq/sdk

Then, create a top-level Resonate object in your project file.

import { Resonate } from "@resonatehq/sdk";

const resonate = new Resonate();

After that, register your top-level function with the Resonate object using resonate.register(), provide a function unique identifier (UID) and a function pointer and then call resonate.start(). You can then invoke the registered function using resonate.run() by passing the function's UID, a UID for corresponding promise, and any required arguments.

import { Resonate, Context } from "@resonatehq/sdk";

const resonate = new Resonate();

resonate.register("your-function-name", yourFunctionPointer);

resonate.start();

resonate.run("your-function-name", "unique-promise-id", args);

With resonate.run(), your code execution will complete even in the presence of hardware or software failures. Any part of your application that calls resonate.run() is considered a Resonate Application Node, and maintans its own call graph.

Promise storage modesโ€‹

Resonate offers two promise storage modes: Local and Remote.

Local storageโ€‹

How to use local storage for Durable Promises.

When using Local storage (Local mode), which is the default, Resonate utilizes a volatile in-memory promise store.

import { Resonate } from "@resonatehq/sdk";

const resonate = new Resonate();

Promises are stored in memory and are not durable to hard crashes. This mode provides out-of-the-box features like automatic retries, tracing, and logging without requiring any additional infrastructure.

The following code is an example using local storage that has a main function (f1()) that sequentially awaits on two other functions (f2() and f3()).

import { Resonate, Context } from "@resonatehq/sdk";

// Function 1 is the top level function that awaits on function 2 and function 3.
export async function f1(ctx: Context) {
// Call function 2
await ctx.run(f2);
// Call function 3
await ctx.run(f3);
return;
}

async function f2() {
// ...
return;
}
async function f3() {
// ...
return;
}

// Initialize a Resonate application.
const resonate = new Resonate();

// Register a f1 as a Resonate function
resonate.register(
"f1", // function name
f1, // function pointer
resonate.options({ timeout: 1000 }) // set a total execution timeout of 1 seconds
);

// Start the Resonate application
resonate.start();

// Call the function1
await resonate.run("f1", `your-proimise-id`);

This sequence graph could be visualized like the following:

Local storage swim lane sequence functions only

The next level of detail could include the promises, which might look like this:

Local storage swim lane sequence

Notice that this sequence graphs above do not include the Resonate Server (supervisor). If all the functions are meant to execute locally, within the same process, an Application Node may chose to rely soley on a local promise store. Again, this is called Local mode. However, if you want f1(), f2(), or f3() to be resumable even if the process crashes, then use Remote mode.

Remote storageโ€‹

How to enable remote storage for Durable Promises.

Remote storage ensures promises get stored in the Resonate Server. To enable Remote storage, pass the Resonate Server's address when initializing Resonate:

import { Resonate } from "@resonatehq/sdk";

const resonate = new Resonate({
url: "http://localhost:8001",
});

Using the same code example as above, but with Remote storage, the sequence would look like the following:

Remote storage swim lane sequence

This sequence shows what happens "under the hood" when a Resonate Server (supervisor) is used.

Schedulesโ€‹

The Resonate TypeScript SDK enables you to schedule function executions. Instead of calling resonate.run(), use resonate.schedule() to schedule the function execution on a repeating basis.

You can create a Schedule that is stored in local memory, or you can create a Schedule that is stored in the Resonate Server.

If the application node connects to a Resonate Server, then the Schedule will automatically be stored there. Otherwise, the Schedule will persist in local memory and disappear when the process does.

Create a Scheduleโ€‹

How to schedule function executions with the Resonate TypeScript SDK.

You can execute your function periodically with a cron expression.

schedules/index.ts

import { Resonate, Context } from "@resonatehq/sdk";

// flow is the top-level function that awaits on step1 and step 2
export async function flow(ctx: Context) {
console.log("Starting the flow");
// Call function 2
await ctx.run(step1);
// Call function 3
await ctx.run(step2);
return;
}
// step1 function
async function step1() {
console.log("Executing step 1");
// ...
return;
}
// step2 function
async function step2() {
console.log("Executing step 2");
// ...
return;
}

// Initialize a Resonate application.
const resonate = new Resonate({
url: "http://localhost:8001",
});

// Schedule the execution of "flow" for every minute
const sched = await resonate.schedule("flow-schedule", "* * * * *", flow);
console.log(sched);

// Start the Resonate application
resonate.start();
console.log("Running");

If the above process crashes, you can restart it and the interval of the function executions will pick back up.

Another acceptable syntax is to register the function first and then refer to the function by its name:

resonate.register("flow", flow);
// ...
const sched = await resonate.schedule("flow-schedule", "* * * * *", "flow");

Consider this pattern for creating schedules on one Application Node, and having the functions execute on another.

Unique Schedule IDs

In the example above, an idempotency key is generated from the schedule ID and the function name. This enables you to run this Application Node in multiple places without creating a new Schedule each time. It also enables you to restart a process that crashes.

However, if you try to create a Schedule with an ID that already exists with a different function you will get an error.

message: 'A schedule with this identifier already exists'

Get a Scheduleโ€‹

How to get Schedule details using the TypeScript SDK.

You can get the details for a Schedule with the Schedule ID used to create the schedule.

const sched = await resonate.schedules.get("flow-schedule");

You can also use the CLI to get a Schedule. See the CLI reference for getting a Schedule for details.

Search for Schedulesโ€‹

How to search for Schedules.

You can search for Schedules using a combination of tags and ID wildcards.

Using the CLI:

resonate schedules search "flow-*"

See the CLI reference for searching for Schedules for details.

Delete a Scheduleโ€‹

How to delete a Schedule stored in the Resonate Server.

Currently there isn't an API exposed in the TypeScript SDK to delete a Schedule.

Instead you can use the Server CLI.

resonate schedules delete <schedule-id>

See the CLI reference for deleting a Schedule for details.

Resolving promisesโ€‹

How to manually resolve a Durable Promise from another process.

Promises and executions always come in pairs. However an execution could be any "step". Often the "step" is a function execution but it could be a human completing a task for example.

Let's say you have a multi step flow defined in a function like this:

import { Resonate, Context } from "@resonatehq/sdk";

// flow blocks on the resolution of "step2"
export async function flow(ctx: Context) {
// Await on step 1
// step 1 resolves when the function completes
await ctx.run(step1);
// Await on step2
// step2 gets resolved from another process
console.log("awaiting on step 2 completion");
await ctx.run("step2");
// Await on step 3
// step 3 resolves when the function completes
await ctx.run(step3);
return;
}

async function step1() {
console.log("executing step 1");
// ...
return;
}
async function step3() {
console.log("executing step 3");
// ...
return;
}

// Initialize a Resonate application.
// Connect to a Resonate Server for coordination
const resonate = new Resonate({ url: "http://localhost:8001" });

// Register a function as a Resonate function
resonate.register(
"flow", // function name
flow, // function pointer
resonate.options({ timeout: 1200000 }) // Timeout of 60 seconds
);

// Start the Resonate application
resonate.start();

// Call the function1
await resonate.run("flow", `flow-promise-2`);
console.log("Complete");

In the previous code example, the line await ctx.run("step2"); blocks the rest of the function execution on promise id "step2". This is because we are passing the string "step2" which creates a promise with an Id of "step2" that is not directly attached to a function execution.

In the other await steps a function is called instead, i.e. await ctx.run(step1); and await ctx.run(step2);, and promises are created that are directly paired with those function exeuctions. In the case of await ctx.run("step2"); a promise is created that is not directly associated with an execution.

Now we must resolve that promise "manually" and we can do this from any process that is connected to the Resonate Server. For example:

import { Resonate, Context, ResonatePromises } from "@resonatehq/sdk";

// Initialize a Resonate application.
const resonate = new Resonate({ url: "http://localhost:8001" });

// ...
// Unblock flow by resolving step 2
console.log("executing step 2");
resonate.promises.resolve("step2", "description of resolution");
// ...

What's happening under the hood?

Here is a sequence diagram that attempts to show how the code above plays out. The diagram assumes that the flow function starts first and blocks on "step2", then while the flow function awaits on the "step2" promise the unblocking process runs and "completes" whatever happened in step2 using resonate.promises.resolve().

Unblocking sequence diagram

Resonate Contextโ€‹

Interactions with the runtime occur through the Resonate Context, which provides methods like ctx.run() and ctx.sleep(). These methods offer automatic retries, recoverability, task distribution, and more. All top-level functions (invoked by resonate.run()) and intermediary functions (invoked by ctx.run()) must accept a Resonate context as their first argument.

async function purchase(ctx: Context, user: User, song: Song): Promise<Status> {
const charged = await ctx.run(charge, user, song);
const granted = await ctx.run(access, user, song);
return { charged, granted };
}

async function charge(ctx: Context, user: User, song: Song): Promise<boolean> {
console.log(`Charged user:${user.id} $${song.price}.`);
return true;
}

async function access(ctx: Context, user: User, song: Song): Promise<boolean> {
console.log(`Granted user:${user.id} access to song:${song.id}.`);
return true;
}

In-Processโ€‹

In-process execution enables durable execution of functions within the same process by passing a function pointer to a local function followed by its arguments.

const result = ctx.run(download, arg1, arg2, ...);

Error handlingโ€‹

If you want to retry a function in an attempt to recover from an application-level issue, you need to either throw an error, or return a rejected promise. You cannot return a custom object that contains error information.

โœ… Throw an error:

try {
return "success";
} catch (e) {
let errorMessage = "An unknown error occurred";
if (e instanceof Error) {
errorMessage = e.message;
}
throw errorMessage;
}

โœ… Reject a promise:

return new Promise((resolve, reject) => {
try {
resolve("success");
} catch (e) {
let errorMessage = "An unknown error occurred";
if (e instanceof Error) {
errorMessage = e.message;
}
reject(errorMessage);
}
});

โŒ Don't return a custom object:

try {
return { success: true };
} catch (e) {
let errorMessage = "An unknown error occurred";
if (e instanceof Error) {
errorMessage = e.message;
}
return { success: false, error: errorMessage };
}
}

Sleepingโ€‹

Resonate keeps track of timers and executes them, even across failures and restarts. To sleep in a Resonate application for a whole day, do the following:

await ctx.sleep(86_400_000);

Configurationsโ€‹

Resonate offers various configuration options to customize its behavior. If options are not provided, sensible defaults are used.

Global Configurationโ€‹

Configure the SDK globally via the top-level Resonate object:

import { Resonate, Retry } from "@resonatehq/sdk";

const resonate = new Resonate({
url: "https://my-remote-store.com", // The remote promise store URL. If not provided, an in-memory promise store will be used.
retry: Retry.exponential(
100, // initial delay (in ms)
2, // backoff factor
Infinity, // max attempts
60000 // max delay (in ms, 1 minute)
),
timeout: 5000, // The default promise timeout in ms, used for every function executed by calling run. Defaults to 1000.
tags: { foo: "bar" }, // Tags to add to all durable promises.
});

Function-specific Configurationโ€‹

When registering functions with resonate.register(), provide function-specific options:

resonate.register(
"downloadAndSummarize",
downloadAndSummarize,
resonate.options({
timeout: Number.MAX_SAFE_INTEGER, // Overrides the default timeout.
retry: Retry.linear(), // Overrides the default retry policy.
tags: { bar: "baz" }, // Additional tags to add to the durable promise.
})
);

Additionally, override functions in ctx.run():

note

The options, such as timeout, cannot exceed the parent function. If it does, the minimum will take precedence.

ctx.run(download, arg1, arg2, resonate.options({ ... }));

Versioningโ€‹

You can register multiple versions of a function with resonate.register():

// Register `downloadAndSummarize` function with a version number of 2,
// a function pointer to a local function,
// and optionals configurations.
resonate.register(
"downloadAndSummarize",
downloadAndSummarize,
resonate.options({
version: 2,
})
);

You can specify which version to run as an option on run. By default the function registered with the greatest (latest) version will be chosen.

resonate.run("downloadAndSummarize", "uid", resonate.options({ version: 2 }));

Additionally, your function has access to context.version, telling it the version this execution was started with.

async function charge(ctx: Context, user: User, song: Song): Promise<boolean> {
if (ctx.version == 1) {
console.log(`Charged user:${user.id} $${song.price} with version 1`);
} else {
console.log(`Charged user:${user.id} $${song.price} with version 2`);
}
return true;
}