External Functions
We love to extol the virtues of deterministic functions but sometimes you just need to do something non-deterministic. The most common scenario where non-determinism is required is when interacting with an external third-party, such as processing a payment on Stripe.
Convex makes it easy to run general-purpose third-party serverless functions that can communicate with the Convex deployment. In this tutorial we'll extend our chat app to call a function running on a hosting provider such as Netlify or Vercel, fetch an animated GIF from GIPHY, and then write the URL into a chat message on Convex. Because our chat app requires authentication to send messages, we'll also forward the ID token to the hosted function so it can send the message as the user.
(Technically you could implement this GIF functionality client-side but the serverless function allows us to hide the GIPHY access key from the client and restrict what can be posted.)
This example builds on the hosted version of the chat app from the
Hosting tutorial. The steps below include information for both
Netlify and Vercel, but should be applicable to any hosting provider supporting
functions. If you need any help the completed code is in the
4a-external-functions-netlify
and 4b-external-functions-vercel
directories
in the Convex Demos repo.
Local environment
Both Netlify and Vercel support local development environments for testing out your local functions. As a first step, let's set up the local environment:
- Netlify
- Vercel
npm install -g netlify-cli
npm install -g vercel
We'll assume you already have an active Convex project via npx convex init
and
have pushed the Convex functions with npm convex push
. Ordinarily if you
wanted to run the web client locally you'd run npm run dev
but now you can run
the client within a simulated hosted environment instead by running:
- Netlify
- Vercel
netlify dev
vercel dev
Make sure you can run this command and load the chat app. It should behave just as before since we haven't added any external functions yet.
tip
If this is your first time using the CLI tool, it may require some initial setup to link it with the deployment created in the previous section. Either tool will guide you through the setup as needed.
Hosted functions that we add will get executed locally within the respective dev
environment. The Convex functions will run on the deployment specified in
convex.json
and any chat messages will be visible to clients on the production
chat deployment. Later tutorials will cover information on how to test Convex
functions on a non-production deployment.
Your first external function
- Netlify
- Vercel
Let's start with the Netlify Hello World TypeScript tutorial. Import the Netlify functions module:
npm install @netlify/functions
and create the following source file in the Netlify functions directory
netlify/functions/
:
import { Handler } from "@netlify/functions";
const handler: Handler = async (event, context) => {
return {
statusCode: 200,
body: JSON.stringify({ message: "Hello World" }),
};
};
export { handler };
Next, we need to call this function from the chat app. This can just be a simple
http fetch
. Add the following code somewhere in App.tsx
:
fetch("/.netlify/functions/hello", { headers: { Accept: "application/json" } })
.then(response => response.json())
.then(body => console.log(body))
.catch(error => console.log(error));
Reload your chat app and ensure that {message: 'Hello World'}
is showing up in
your browser JavaScript console.
We'll start with the Vercel Node.js Serverless Function tutorial. Import the Vercel TypeScript definitions:
npm install -D @vercel/node
Create a new /api
directory, and add the following hello.js
file inside:
import type { VercelRequest, VercelResponse } from "@vercel/node";
export default (request: VercelRequest, response: VercelResponse) => {
response.status(200).send(`Hello World!`);
};
Next, we need to call this function from the chat app. This can just be a simple
http fetch
. Add the following code somewhere in App.tsx
:
fetch("/api/hello")
.then(response => response.text())
.then(text => console.log(text))
.catch(error => console.log(error));
Reload your chat app and ensure that Hello World!
is showing up in your
browser JavaScript console.
Convex and the outside world
We're successfully calling a hosted function from our chat app but now it's time to level-up and talk to both Convex and a third-party service within a function.
Our desired end-user functionality is to be able to type a chat message like
/giphy wombat
and have an animated GIF of a wombat show up in the chat stream.
We're going to need some help from GIPHY for this. Create a
developer account and a free API app key on the
developer dashboard.
The desired handler function should look something like the following:
- Netlify
- Vercel
import { Handler } from "@netlify/functions";
import { ConvexHttpClient } from "convex/browser";
import fetch from "node-fetch";
import convexConfig from "../../convex.json";
import { Id } from "../../convex/_generated/dataModel";
const convex = new ConvexHttpClient(convexConfig.origin);
// Replace this with your own GIPHY key obtained at
// https://developers.giphy.com/ -> Create Account.
const GIPHY_KEY = "QrXTp0FioARhBHalPs2tpA4RNOTLhFYs";
function giphyUrl(query: string) {
return (
"https://api.giphy.com/v1/gifs/translate?api_key=" +
GIPHY_KEY +
"&s=" +
encodeURIComponent(query)
);
}
interface GiphyResponse {
data: { embed_url: string };
}
// Post a GIF chat message corresponding to the query string.
const handler: Handler = async (event, context) => {
const params = JSON.parse(event.body!);
const channelId = new Id("channels", params.channel);
const token = params.token;
convex.setAuth(token);
// Fetch GIF url from GIPHY.
const gif = await fetch(giphyUrl(params.query))
.then(response => response.json() as Promise<GiphyResponse>)
.then(json => json.data.embed_url);
// Write GIF url to Convex.
await convex.mutation("sendMessage")(channelId, "giphy", gif);
return {
statusCode: 200,
body: JSON.stringify(gif),
};
};
export { handler };
import { ConvexHttpClient } from "convex/browser";
import type { VercelRequest, VercelResponse } from "@vercel/node";
import fetch from "node-fetch";
import convexConfig from "../convex.json";
import { Id } from "../convex/_generated/dataModel";
const convex = new ConvexHttpClient(convexConfig.origin);
// Replace this with your own GIPHY key obtained at
// https://developers.giphy.com/ -> Create Account.
const GIPHY_KEY = "QrXTp0FioARhBHalPs2tpA4RNOTLhFYs";
function giphyUrl(query: string) {
return (
"https://api.giphy.com/v1/gifs/translate?api_key=" +
GIPHY_KEY +
"&s=" +
encodeURIComponent(query)
);
}
interface GiphyResponse {
data: { embed_url: string };
}
// Post a GIF chat message corresponding to the query string.
export default async function handler(
request: VercelRequest,
response: VercelResponse
) {
// The `VercelRequest` object automatically deserializes the request body
// according to the `Content-Type` headers, so we don't need to manually parse
// the JSON here.
const params = request.body;
const channelId = new Id("channels", params.channel);
const token = params.token;
convex.setAuth(token);
// Fetch GIF url from GIPHY.
const gif = await fetch(giphyUrl(params.query))
.then(response => response.json() as Promise<GiphyResponse>)
.then(json => json.data.embed_url);
// Write GIF url to Convex.
await convex.mutation("sendMessage")(channelId, "giphy", gif);
response.status(200).json(gif);
}
The first thing this code does is create a ConvexHttpClient
. This is a
non-reactive client that can run Convex queries and mutations. It's a simple
one-line call to write a chat message into Convex inside the handler.
The handler itself is pretty basic. It reads the chat channelId
and user's
token
passed in as parameters in the request body, along with a query
string
we'll use to look up a GIF. The token is passed to the Convex client so the
mutation will be authenticated, then it makes an external call to GIPHY to fetch
the URL of a relevant GIF. Finally, it writes that URL to Convex as a chat
message.
You may notice that we've added an extra parameter to the sendMessage
Convex
function to include the message type. More on that next.
Wiring it up
Time to wire everything together and start posting GIFs. First we'll update
convex/schema.ts
to add a format
field to the messages
table:
messages: defineTable({
body: s.string(),
channel: s.id("channels"),
format: s.string(), // "text" or "giphy"
user: s.id("users"),
}),
and update sendMessage.ts
to handle this new field:
import { Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";
// Send a message to the given chat channel.
export default mutation(
async (
{ db, auth },
channel: Id<"channels">,
format: string,
body: string
) => {
const identity = await auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthenticated call to sendMessage");
}
const user = await db
.table("users")
.filter(q => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))
.unique();
const message = {
channel,
format,
body,
user: user._id,
};
db.insert("messages", message);
}
);
We'll also need to update the MessageView
component to read the message type
and include the GIPHY iframe
embed:
function MessageView(props: { message: MessageWithAuthor }) {
const message = props.message;
if (message.format == "giphy") {
return (
<div>
<div>
<strong>{message.author}:</strong>
</div>
<iframe src={message.body} />
<div className="giphy-attribution">Powered By GIPHY</div>
</div>
);
}
return (
<div>
<strong>{message.author}:</strong> {message.body}
</div>
);
}
Finally we need to update handleSendMessage
to detect /giphy
strings and
send a POST request to our external function:
- Netlify
- Vercel
const [newMessageText, setNewMessageText] = useState("");
async function handleSendMessage(event: FormEvent) {
event.preventDefault();
setNewMessageText(""); // reset text entry box
// If a /giphy command is entered call into the Netlify function to post
// relevant GIF to channel.
if (newMessageText.startsWith("/giphy ")) {
await fetch("/.netlify/functions/post-gif", {
method: "POST",
body: JSON.stringify({
channel: props.channelId.id,
token: props.idToken,
query: newMessageText.slice(7),
}),
});
} else {
await sendMessage(props.channelId, "text", newMessageText);
}
}
const [newMessageText, setNewMessageText] = useState("");
async function handleSendMessage(event: FormEvent) {
event.preventDefault();
setNewMessageText(""); // reset text entry box
// If a /giphy command is entered call into the Vercel function to post
// relevant GIF to channel.
if (newMessageText.startsWith("/giphy ")) {
await fetch("/api/post-gif", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
channel: props.channelId.id,
token: props.idToken,
query: newMessageText.slice(7),
}),
});
} else {
await sendMessage(props.channelId, "text", newMessageText);
}
}
const [idToken, setIdToken] = useState<string | null>(null);
// Pass the ID token to the Convex client when logged in, and clear it when logged out.
// After setting the ID token, call the `storeUser` mutation function to store
// the current user in the `users` table and return the `Id` value.
useEffect(() => {
if (isLoading) {
return;
}
if (isAuthenticated) {
getIdTokenClaims().then(async claims => {
// Get the raw ID token from the claims.
const token = claims!.__raw;
setIdToken(token);
// Pass it to the Convex client.
convex.setAuth(token);
// Store the user in the database.
const id = await storeUser();
setUserId(id);
});
} else {
// Tell the Convex client to clear all authentication state.
convex.clearAuth();
setUserId(null);
}
}, [isAuthenticated, isLoading, getIdTokenClaims, convex, storeUser]);
We also need to thread it as a prop
into ChatBox
:
function ChatBox(props: { channelId: Id<"channels">; idToken: string | null }) {
channelId ? <ChatBox channelId={channelId} idToken={idToken} /> : null
That's all the code changes done! Before we can test anything you need to push
the new version of sendMessage
to Convex:
npx convex push
Now fire up netlify dev
or vercel dev
again and try posting a GIF to the
channel:
Deployment
If you followed the previous tutorial on hosting and deployment
then deployment is almost simple as committing your changes to your repository
and running a git push
! Netlify and Vercel automatically watch your repository
for changes and publish these to your production site.
tip
Don't forget to add convex.json
to your source code repository, otherwise the
hosted function won't know what Convex deployment to connect to.
Now that you can interface Convex with a general-purpose execution environment you all set to go build the next great web app. If you come up with something cool we'd love to know about it!