Skip to main content

2 posts tagged with "sputnik"

View All Tags

HTTP Requests in TypeScript Serverless Functions

Β· 3 min read

One of the most requested features for TypeScript serverless functions has always been the ability to make HTTP requests to external APIs.

That's now supported πŸš€.


HTTP Requests​

Rust serverless functions have been able to reach out to the outside world via HTTPS for a while. That comes naturally since Rust functions leverage the ic_cdk, which is maintained by the DFINITY foundation for the Internet Computer and therefore, supports it out of the box.

On the other hand, TypeScript serverless functions are unique to Juno, so features are added incrementally.

Performing those kind of requests happens through a feature called HTTPS outcalls. Using it, you can extend your Satellite to fetch data, trigger webhooks, call third-party services, whatever your use case requires. In Juno's own codebase, it is used to send emails and fetch the public keys of the OpenID providers supported for authentication.


Making a Request​

Here's a simple example using the Dog CEO API to fetch a random dog image URL and return it directly from an update function:

import { defineUpdate } from "@junobuild/functions";
import { httpRequest, type HttpRequestArgs } from "@junobuild/functions/ic-cdk";
import { j } from "@junobuild/schema";

const DogSchema = j.strictObject({
message: j.url(),
status: j.string()
});

export const fetchRandomDog = defineUpdate({
result: DogSchema,
handler: async () => {
const args: HttpRequestArgs = {
url: "https://dog.ceo/api/breeds/image/random",
method: "GET",
isReplicated: false
};

const result = await httpRequest(args);

const decoder = new TextDecoder();
const body = decoder.decode(result.body);

return JSON.parse(body);
}
});

Define the function, call the API, use the result. That's it.

Magic GIF for fun


Transformer​

Some APIs return response headers that vary between nodes, timestamps, request IDs, and so on. In replicated mode, where the request is run multiple times and reconciled to ensure its validity, this can cause the call to fail if all responses do not match.

To handle this, you can define a transform function that sanitizes the response before the nodes compare it. A common pattern is to strip the headers entirely:

import { defineQuery, defineUpdate } from "@junobuild/functions";
import {
httpRequest,
HttpRequestResultSchema,
TransformArgsSchema,
type HttpRequestArgs
} from "@junobuild/functions/ic-cdk";
import { j } from "@junobuild/schema";

const DogSchema = j.strictObject({
message: j.url(),
status: j.string()
});

export const fetchRandomDog = defineUpdate({
result: DogSchema,
handler: async () => {
const args: HttpRequestArgs = {
url: "https://dog.ceo/api/breeds/image/random",
method: "GET",
isReplicated: true,
transform: "trimHeaders"
};

const result = await httpRequest(args);

const decoder = new TextDecoder();
const body = decoder.decode(result.body);

return JSON.parse(body);
}
});

export const trimHeaders = defineQuery({
hidden: true,
args: TransformArgsSchema,
result: HttpRequestResultSchema,
handler: ({ response: { status, body } }) => ({
status,
body,
headers: []
})
});

The transform field references the name of an exported query function. Marking it hidden: true keeps it out of the auto-generated client API, it's an implementation detail, not something you'd call from the frontend.


Guards​

This release also ships support for guards that let you protect your functions with access control logic, restricting who can call them before the handler even runs.

import { defineQuery } from "@junobuild/functions";
import { callerIsAdmin } from "@junobuild/functions/sdk";

export const ping = defineQuery({
guard: () => {
throw new Error("No pong today");
},
handler: () => {
console.log("Hello");
}
});

export const hello = defineQuery({
guard: callerIsAdmin,
handler: () => {
console.log("Hello, admin!");
}
});

References​

Following sections of the documentation have been updated:


HTTP requests were a feature often requested and led a few developers to choose Rust for that reason. Really happy to finally ship this one and hopefully see more devs embrace the simplicity of using TypeScript.

To infinity and beyond
David

Custom Functions in TypeScript

Β· 3 min read

The idea behind Juno's serverless functions is that you should be able to write them in either Rust or TypeScript following the same pattern and mental model. Parts of those, hooks, have been available in TypeScript for a while and are handy for reacting to events. But developers also often want to define their own functions, akin to adding custom endpoints to an HTTP API. That wasn't supported in TypeScript, until now πŸš€.


Custom Functions​

Custom functions let you define callable endpoints directly inside your Satellite, which can be explicitly invoked from your frontend or from other services.

There are two kinds.

A query is read-only. It returns data without touching any state, and it's fast. Use it when you just need to fetch something.

An update can read and write. Use it when your logic needs to persist data, trigger side effects, or when you need an absolute tamper-proof guarantee on the result.

import { defineQuery } from "@junobuild/functions";

export const ping = defineQuery({
handler: () => "pong"
});

The functions can be described with optional arguments and results. They are strongly typed both at runtime and at build time thanks to a new type system, and their handlers can be synchronous or asynchronous.

import { defineUpdate } from "@junobuild/functions";
import { j } from "@junobuild/schema";

const ArgsSchema = j.strictObject({
name: j.string()
});

const ResultSchema = j.strictObject({
message: j.string()
});

export const myUpdate = defineUpdate({
args: ArgsSchema,
result: ResultSchema,
handler: async ({ name }) => {
// your logic here
return { message: `Saved ${name}.` };
}
});

Auto-generated Bindings​

Another part that makes this genuinely fun to use: when you build your project, a type-safe client API is automatically generated based on your function definitions. No glue code, no manual wiring, no thinking about serialization. Your functions are simply available through the functions namespace.

import { functions } from "../declarations/satellite/satellite.api.ts";

await functions.ping();
await functions.myUpdate({ name: "David" });

Define the shape on the backend, call it from the frontend with full type safety. That's it.


Schema Types​

Arguments and return types are optional, but when you need them, Juno provides a type system built on top of Zod to let you define their shapes.

import { j } from "@junobuild/schema";

Those schemas are validated at runtime and used at build time to generate all the necessary bindings. You define the shape once, you get safety everywhere.

const Schema = j.strictObject({
name: j.string(),
age: j.number()
});

Since you will likely need some environment-specific types, j extends Zod with j.principal() and j.uint8array().

const Schema = j.strictObject({
owner: j.principal(),
data: j.uint8array()
});

And when your function needs to handle multiple distinct input shapes, reach for discriminatedUnion:

import { defineUpdate } from "@junobuild/functions";
import { j } from "@junobuild/schema";

const Schema = j.discriminatedUnion("type", [
j.strictObject({ type: j.literal("cat"), indoor: j.boolean() }),
j.strictObject({ type: j.literal("dog"), breed: j.string() })
]);

export const registerPet = defineUpdate({
args: Schema,
handler: ({ args }) => {
if (args.type === "cat") {
// handle cat
} else {
// handle dog
}
}
});

Long story short, j is Zod with a few extras and everything you need to strongly type your functions.


References​

Following sections of the documentation have been updated:


I'm genuinely excited about these improvements and can totally see myself not using Rust for writing serverless functions in most of my projects in a near future. Not entirely there yet, HTTPS outcalls support in TypeScript is still coming, but getting there.

To infinity and beyond
David