Serverless Functions in TypeScript
Learn how to write and extend serverless functions for your Satellite in TypeScript.
Quickstart
Set up your environment to develop and extend a Satellite with custom serverless functions.
First, ensure you have the Juno CLI installed. If you haven't installed it yet, run:
- npm
- yarn
- pnpm
npm i -g @junobuild/cli
yarn global add @junobuild/cli
pnpm add -g @junobuild/cli
At your project root, eject the Satellite if you haven't already used a template.
juno functions eject
In a new terminal window, kick off the emulator:
juno emulator start --watch
Your local development environment is now up and running.
Hooks
Hooks respond to events occurring in your Satellite, such as a document being created or updated. They run automatically in the background and are not invoked directly.
The following example declares a hook that listens to changes in the demo collection and modifies the document's data before saving it back:
import { defineHook, type OnSetDoc } from "@junobuild/functions";
import {
decodeDocData,
encodeDocData,
setDocStore
} from "@junobuild/functions/sdk";
// The data shape stored in the Satellite's Datastore
interface Person {
hello: string;
}
// We declare a hook that listens to changes in the "demo" collection
export const onSetDoc = defineHook<OnSetDoc>({
collections: ["demo"],
run: async (context) => {
// Decode the document's data (stored as a blob)
const data = decodeDocData<Person>(context.data.data.after.data);
// Update the document's data by enhancing the "hello" field
const updated = {
hello: `${data.hello} checked`
};
// Encode the data back to blob format
const encoded = encodeDocData(updated);
// Save the updated document using the same caller, collection, and key
await setDocStore({
caller: context.caller,
collection: context.data.collection,
key: context.data.key,
doc: {
data: encoded,
description: context.data.data.after.description,
version: context.data.data.after.version
}
});
}
});
Hooks execute asynchronously, separate from the request-response cycle. Changes made by a hook will not be immediately visible to the caller.
Handling Multiple Collections
If your hook applies to many collections, a switch statement is one way to route logic:
import { defineHook, type OnSetDoc } from "@junobuild/functions";
export const onSetDoc = defineHook<OnSetDoc>({
collections: ["posts", "comments"],
run: async (context) => {
switch (context.data.collection) {
case "posts":
// Handle posts logic
break;
case "comments":
// Handle comments logic
break;
}
}
});
While this works, you might accidentally forget to handle one of the observed collections. To prevent that, you can use a typed map:
import {
defineHook,
type OnSetDoc,
type OnSetDocContext,
type RunFunction
} from "@junobuild/functions";
const collections = ["posts", "comments"] as const;
type OnSetDocCollection = (typeof collections)[number];
export const onSetDoc = defineHook<OnSetDoc>({
collections,
run: async (context) => {
const fn: Record<OnSetDocCollection, RunFunction<OnSetDocContext>> = {
posts: yourFunction,
comments: yourOtherFunction
};
await fn[context.data.collection as OnSetDocCollection]?.(context);
}
});
This ensures all collections are handled and you'll get a TypeScript error if one is missing.
Custom Functions
Custom Functions let you define callable endpoints directly inside your Satellite. Unlike hooks, which react to events, custom functions are explicitly invoked - from your frontend or from other modules.
You define them using defineQuery or defineUpdate, describe their input and output shapes with the j type system, and Juno takes care of generating all the necessary bindings under the hood.
Query vs. Update
A query is a read-only function. It returns data without modifying any state. Queries are fast and suitable for fetching or computing information.
An update is a function that can read and write state. Use it when your logic needs to persist data or trigger side effects. Updates can also be used for read operations when the response needs to be certified - making them suitable for security-sensitive use cases where data integrity must be guaranteed.
Defining a Function
Describe your function's input and output shapes using the j type system, then pass them to defineQuery or defineUpdate along with your handler:
import { defineUpdate } from "@junobuild/functions";
import { j } from "@junobuild/schema";
const Schema = j.strictObject({
name: j.string(),
id: j.principal()
});
export const helloWorld = defineUpdate({
args: Schema,
result: Schema,
handler: async ({ args }) => {
// Your logic here
return args;
}
});
Handlers can be synchronous or asynchronous. Both args and returns are optional.
Calling from the Frontend
When you build your project, a type-safe client API is automatically generated based on your function definitions. You can import and call your functions directly from your frontend without writing any glue code:
import { functions } from "../declarations/satellite/satellite.api.ts";
await functions.helloWorld({ name: "World", id: Principal.anonymous() });
Guards
Guards let you protect custom functions by running a check before the handler executes. If the guard throws, the function is not invoked.
import { defineQuery } from "@junobuild/functions";
export const ping = defineQuery({
guard: () => {
throw new Error("No pong today");
},
handler: () => {
console.log("Hello");
}
});
Juno also provides built-in guards you can use out of the box:
import { defineQuery } from "@junobuild/functions";
import { callerIsAdmin } from "@junobuild/functions/sdk";
export const ping = defineQuery({
guard: callerIsAdmin,
handler: () => {
console.log("Hello, admin!");
}
});
📦 See all available built-in guards in the SDK reference.
Assertions
Assertions allow you to validate or reject operations before they are executed. They're useful for enforcing data integrity, security policies, or business rules inside your Satellite, and they run synchronously during the request lifecycle.
import { decodeDocData } from "@junobuild/functions/sdk";
import { defineAssert, type AssertSetDoc } from "@junobuild/functions";
interface NoteData {
text: string;
}
export const assertSetDoc = defineAssert<AssertSetDoc>({
collections: ["notes"],
assert: (context) => {
const data = decodeDocData<NoteData>(context.data.data.proposed.data);
if (data.text.toLowerCase().includes("hello")) {
throw new Error("The text must not include the word 'hello'");
}
}
});
This example ensures that any document added to the notes collection does not contain the word "hello" (case-insensitive). If it does, the operation is rejected before the data is saved.
Calling Other Canisters
This is useful if you want to:
- Fetch or modify data in other modules
- Interact with standard canisters like ledger or governance
- Trigger behavior on other dapps
Here's an example that calls another canister’s method and logs the result:
import { call } from "@junobuild/functions/ic-cdk";
import { defineHook, type OnSetDoc } from "@junobuild/functions";
import { IDL } from "@icp-sdk/core/candid";
import { Principal } from "@icp-sdk/core/principal";
// Define Candid types
const SubAccount = IDL.Vec(IDL.Nat8);
const Account = IDL.Record({
owner: IDL.Principal,
subaccount: IDL.Opt(SubAccount)
});
const Icrc1Tokens = IDL.Nat;
// Define TypeScript interfaces
export type SubAccountType = Uint8Array | number[];
export interface AccountType {
owner: Principal;
subaccount: [] | [SubAccountType];
}
export type Icrc1TokensType = bigint;
// Define the onSetDoc hook
export const onSetDoc = defineHook<OnSetDoc>({
collections: ["notes"],
run: async (context) => {
const account: AccountType = {
owner: Principal.from(context.caller),
subaccount: []
};
const icpLedgerId = Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai");
const balance = await call<Icrc1TokensType>({
canisterId: icpLedgerId,
method: "icrc1_balance_of",
args: [[Account, account]],
result: Icrc1Tokens
});
console.log("Balance:", balance);
}
});
This example performs a call to the ICP Ledger canister using the icrc1_balance_of method to retrieve the token balance for the calling user. The result is printed to the log using console.log.
The args field contains a tuple with the Candid type definition and the corresponding JavaScript value.
The call function handles both encoding the request and decoding the response using the provided types.
To encode and decode these calls, you need JavaScript structures that match the Candid IDL types used by the target canister.
HTTPS Outcalls
HTTPS outcalls are a feature that enables your serverless functions to make HTTP requests to any external API.
This example is also available on GitHub.
For this example, we'll skip a few steps as the logic remains consistent:
- Your frontend makes a call to the Satellite.
- The Satellite performs some work, such as asserting and setting a document.
- If everything succeeds, the Satellite triggers a hook before returning the result of the call.
Here is an example of an hook which fetches an API to get the link to an image of a dog and saves that information within the Datastore. While this might not be a practical real-world use case, it is simple enough to demonstrate the feature.
Here is an example of an onSetDoc hook which fetches an API to get the link to an image of a dog and saves that information within the Datastore. While this might not be a practical real-world use case, it is simple enough to demonstrate the feature.
import { defineHook, type OnSetDoc, SetDoc } from "@junobuild/functions";
import { j } from "@junobuild/schema";
import { httpRequest, HttpRequestArgs } from "@junobuild/functions/ic-cdk";
import { encodeDocData, setDocStore } from "@junobuild/functions/sdk";
// The data of the document we are looking to update in the Satellite's Datastore.
const DogDataSchema = j.strictObject({
src: j.string().optional()
});
// We are using the Dog CEO API in this example.
// https://dog.ceo/dog-api/
//
// Its endpoint "random" returns such JSON data:
// {
// "message": "https://images.dog.ceo/breeds/mountain-swiss/n02107574_1118.jpg",
// "status": "success"
// }
//
// That's why we declare a struct that matches the structure of the answer.
const DogApiResponseSchema = j.strictObject({
message: j.url(),
status: j.string()
});
export const onSetDoc = defineHook<OnSetDoc>({
collections: ["dogs"],
run: async ({
caller,
data: {
collection,
key,
data: {
after: { description, version }
}
}
}) => {
// 1. Prepare the HTTP GET request
const url = "https://dog.ceo/api/breeds/image/random";
const args: HttpRequestArgs = {
url,
method: "GET",
// Use a single node as we do not require that a trust level for fetching a dog image for demo purposes. 😉
isReplicated: false
};
// 2. Execute the HTTP request.
const result = await httpRequest(args);
// 3. Transform the response to a structured data object.
const decoder = new TextDecoder();
const body = decoder.decode(result.body);
const dogResponse = DogApiResponseSchema.parse(JSON.parse(body));
// 4. Our goal is to update the document in the Datastore with an update that contains the link to the image fetched from the API we just called.
const dogData = DogDataSchema.parse({
src: dogResponse.message
});
// 5. We encode those data back to blob because the Datastore holds data as blob.
const encodedData = encodeDocData(dogData);
// 6. Then we construct the parameters required to call the function that save the data in the Datastore.
const doc: SetDoc = {
description,
version,
data: encodedData
};
// 7. We store the data in the Datastore for the same caller as the one who triggered the original on_set_doc, in the same collection with the same key as well.
setDocStore({
caller,
collection,
key,
doc
});
}
});
As with the previous example, the hook will asynchronously update the document. If you wait a bit before retrieving the document in your frontend, you might notice that the source of the image has been updated by your hook.
Replication
By default, all nodes that run your Satellite execute the same request and must agree on the response for the call to succeed. This ensures the result is verified but means the target API must return identical responses across repeated calls.
Setting switches to a single-node mode which by extension skips such assertion. It's also cheaper, but the response is not verified by others. Suitable when you trust the data source or consistency is not critical.
Costs
HTTPS outcalls consume cycles to execute. Refer to the ICP documentation for the current pricing model.
You can also use the HTTPS Outcalls Cost Calculator to estimate the cost of a specific request.
Technical Requirements
HTTPS outcalls support both IPv4 and IPv6.
One consideration when using replicated mode: since all nodes execute the same request, the API must return an identical response each time. Many APIs support this via an idempotency key. If that's not an option, non-replicated mode is usually the practical alternative.
Schema Types
The j type system is Juno's schema layer for custom functions. It is built on top of Zod and extends it with types specific to the Juno and Internet Computer environment, such as j.principal().
You use it to describe the shape of your function's arguments and return value. These schemas are both validated at runtime and used at build time to generate the necessary types and bindings.
import { j } from "@junobuild/schema";
const Schema = j.strictObject({
name: j.string(),
id: j.principal()
});
📦 Import from @junobuild/schema