Functions
Volcano Functions run serverless backend logic that can't happen in the browser, like complex database queries or third-party API calls.
Volcano Functions are serverless functions that run your custom backend logic. They're perfect for operations that can't be done from the browser, like complex database queries, third-party API integrations, or secure operations.
Overview
Functions provide:
- Secure Execution - Code runs server-side, away from client inspection
- Full SQL Access - Write complex queries with JOINs, CTEs, and more
- Third-Party APIs - Call external services with secrets kept secure
- Background Jobs - Process data, send emails, generate reports
- User Context - Functions receive the authenticated user's identity
Invoking Functions
Basic Invocation
const { data, status, version, error } = await volcano.functions.invoke('send-welcome-email', {
template: 'welcome',
recipientId: user.id,
});
if (error) {
console.error('Function failed:', error.message);
return;
}
console.log('Status:', status);
console.log('Version:', version);
console.log('Result:', data);version maps to the X-Volcano-Version response header (<version> in production, <env>-<version> in non-production).
Function resolution and invocation recover from a platform HTTP 401 before dispatch: the SDK refreshes the captured session and retries the rejected request once. Concurrent calls share successful recovery. Replacing or signing out that session prevents replay under another identity. The call preserves its original payload values. A function's own response, HTTP 403, or a network failure never triggers this retry. Anonymous and service keys do not refresh.
Starting sign-out prevents a pending invocation from dispatching. An invocation already sent to the function cannot be cancelled by changing the local session.
With Typed Response
interface DashboardStats {
totalUsers: number;
activeToday: number;
revenue: number;
}
const { data, status, headers, version, error } = await volcano.functions.invoke<
{ timeframe: string },
DashboardStats
>('get-dashboard-stats', {
timeframe: 'last-30-days',
});
if (data) {
console.log('HTTP status:', status);
console.log('X-Volcano-Version:', version);
console.log('Total users:', data.totalUsers);
console.log('Active today:', data.activeToday);
}No Payload
const { data, status, headers, version, error } = await volcano.functions.invoke('health-check');Where the request goes
Functions answer on their own domain, not on your API URL. invoke looks the
name up once, then sends the invocation to the endpoint the platform returned:
https://<function-id>.functions.volcano.run/If you restrict outbound requests, allow that domain alongside your API host.
In a browser, it needs a connect-src entry in your Content Security Policy;
on a server, it needs an egress rule. A request blocked here comes back as a
VolcanoSystemError with status: null, which is how you tell it apart from a
function that ran and returned an error of its own.
The lookup is cached for as long as the platform says it is valid, so repeated
calls to the same function do not repeat it. Nothing to configure: the SDK never
builds the host from apiUrl, because the two differ per deployment.
Deployments without a public function domain — local development, for one — invoke through the API host instead. Same call, same result.
Authentication
The SDK uses the user's access token when a user is signed in. The function receives the user's context and can:
- Verify the user's identity
- Query the database with Row-Level Security
- Access user-specific data
// Client-side
await volcano.auth.signIn({ email: 'alice@example.com', password: '...' });
// Function is called with Alice's identity
const { data } = await volcano.functions.invoke('get-my-profile');
// Returns Alice's profile dataWhen no user is signed in, the SDK uses the project's anon key. This works only for functions configured as public. Public invocations do not receive user context.
const volcano = new VolcanoAuth({ anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY });
const { data } = await volcano.functions.invoke('contact-form', {
email: 'lead@example.com',
});Writing Functions
Functions are deployed through the Volcano dashboard or CLI. Here's what they look like:
Basic Function
// functions/hello.js
exports.handler = async (event) => {
const name = event.name || 'World';
return {
statusCode: 200,
body: JSON.stringify({
message: `Hello, ${name}!`,
}),
};
};With Authentication
// functions/get-my-posts.js
const { Client } = require('pg');
exports.handler = async (event) => {
// User context is injected by Volcano
const auth = event.__volcano_auth;
if (!auth) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Unauthorized' }),
};
}
// Connect to database with user context. DATABASE_URL already carries the
// unique username the proxy routes by; databaseConnectionString only sets
// application_name to impersonate the auth user so Row-Level Security applies.
const { databaseConnectionString } = require('@volcano.dev/sdk');
const connStr = databaseConnectionString(process.env.DATABASE_URL, { userId: auth.user_id });
const client = new Client({ connectionString: connStr });
await client.connect();
try {
// RLS automatically filters to this user's posts
const { rows } = await client.query('SELECT * FROM posts ORDER BY created_at DESC');
return {
statusCode: 200,
body: JSON.stringify({ posts: rows }),
};
} finally {
await client.end();
}
};Complex Database Query
// functions/dashboard-stats.js
const { Client } = require('pg');
exports.handler = async (event) => {
const auth = event.__volcano_auth;
if (!auth) {
return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) };
}
const { timeframe } = event;
const days = timeframe === 'last-7-days' ? 7 : 30;
const { databaseConnectionString } = require('@volcano.dev/sdk');
const connStr = databaseConnectionString(process.env.DATABASE_URL, { userId: auth.user_id });
const client = new Client({ connectionString: connStr });
await client.connect();
try {
// Complex query with CTE and aggregations
const result = await client.query(`
WITH recent_posts AS (
SELECT * FROM posts
WHERE created_at > NOW() - INTERVAL '${days} days'
),
stats AS (
SELECT
COUNT(*) as total_posts,
COUNT(DISTINCT DATE(created_at)) as active_days,
SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) as published
FROM recent_posts
)
SELECT * FROM stats
`);
return {
statusCode: 200,
body: JSON.stringify(result.rows[0]),
};
} finally {
await client.end();
}
};Calling External APIs
// functions/send-slack-notification.js
exports.handler = async (event) => {
const auth = event.__volcano_auth;
if (!auth) {
return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) };
}
const { channel, message } = event;
// Webhook URL stored as environment variable
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel,
text: message,
username: 'Volcano Bot',
}),
});
if (!response.ok) {
return {
statusCode: 500,
body: JSON.stringify({ error: 'Failed to send notification' }),
};
}
return {
statusCode: 200,
body: JSON.stringify({ success: true }),
};
};Work That Runs for Hours
A function invocation is bounded by its timeout, so work that has to survive
longer — a multi-step pipeline, an approval that arrives tomorrow, a batch job
over a flaky API — belongs in a
durable function, which checkpoints its progress and
resumes where it left off. Those are started with volcano.durable.start
instead of functions.invoke, and answer with an execution to follow rather
than a result.
Use Cases
When to Use Functions
Functions are ideal for:
| Use Case | Example |
|---|---|
| Complex Queries | JOINs, CTEs, window functions |
| Aggregations | Dashboard statistics, reports |
| Stored Procedures | Business logic in PostgreSQL |
| External APIs | Stripe payments, SendGrid emails |
| File Processing | Image resizing, PDF generation |
| Scheduled Tasks | Daily reports, cleanup jobs |
| Admin Operations | Bulk updates, data migrations |
| Secure Operations | Secret key usage, privileged actions |
When to Use Query Builder
The browser-based query builder is better for:
- Simple CRUD operations
- Single-table queries with filters
- Real-time updates (less latency)
- Reducing backend code
Error Handling
Client-Side
Resolution and pre-dispatch HTTP errors retain error.status, plus error.code
and error.retryAfter when the server supplies them. retryAfter is a delay in
seconds. These fields remain available after narrowing an invocation error with
VolcanoSystemError.is(error). Transport failures have a null status and no HTTP
metadata. A function's own HTTP response remains in data and status, with
error set to null.
const { data, error } = await volcano.functions.invoke('process-payment', {
amount: 1999,
currency: 'usd',
});
if (error) {
// Platform-layer failure — the invocation never reached your function
// (deploy down/failed, gateway error, or a network failure). These are a
// `VolcanoSystemError`; detect with `error.isSystemError === true`.
console.error('Function error:', error.message);
showErrorToast('Payment failed. Please try again.');
return;
}
// Check for business logic errors in the response
if (data.error) {
console.error('Payment error:', data.error);
showErrorToast(data.error);
return;
}
console.log('Payment successful:', data.paymentId);Function-Side
exports.handler = async (event) => {
try {
// Function logic
const result = await processPayment(event);
return {
statusCode: 200,
body: JSON.stringify({ success: true, paymentId: result.id }),
};
} catch (error) {
console.error('Payment error:', error);
// Return structured error
return {
statusCode: 400,
body: JSON.stringify({
error: error.message,
code: error.code || 'PAYMENT_FAILED',
}),
};
}
};Environment Variables
Functions can access environment variables configured in the Volcano dashboard:
exports.handler = async (event) => {
// Built-in variables. DATABASE_URL already carries the unique username the
// proxy routes by; pass it to databaseConnectionString rather than building
// application_name yourself.
const dbUrl = process.env.DATABASE_URL;
// Custom variables (set in dashboard)
const stripeKey = process.env.STRIPE_SECRET_KEY;
const sendgridKey = process.env.SENDGRID_API_KEY;
// Use them
const stripe = require('stripe')(stripeKey);
// ...
};Store sensitive data like API keys as environment variables rather than in code.
Database Access Patterns
User Context (RLS Enforced)
For queries that should respect Row-Level Security:
const { Client } = require('pg');
const { databaseConnectionString } = require('@volcano.dev/sdk');
// Inside exports.handler, where `event` is available:
const auth = event.__volcano_auth;
// Impersonates the auth user: application_name=volcano_user_access:{userId}
const connStr = databaseConnectionString(process.env.DATABASE_URL, { userId: auth.user_id });
const client = new Client({ connectionString: connStr });
// Queries filtered by RLSAdmin Access (Bypass RLS)
For administrative operations:
const { Client } = require('pg');
const { databaseConnectionString } = require('@volcano.dev/sdk');
// No userId: application_name=volcano_full_access
const connStr = databaseConnectionString(process.env.DATABASE_URL);
const client = new Client({ connectionString: connStr });
// Full access to all dataThe proxy routes by the globally-unique username (volcano_client_{id}) that is
already in DATABASE_URL; application_name only selects the access mode. Prefer
databaseConnectionString over hand-building application_name.
The helper preserves libpq connection syntax, including hostless and multi-host
targets, and leaves unrelated query values unchanged.
Connection Pooling
The access mode and RLS identity are selected by application_name at
connection startup, so they cannot be changed on a pooled connection after it is
established. Pool connections that all share one access mode (e.g. a per-user
function whose pool connection string already targets that user, or admin work),
and open a fresh connection when the identity differs.
const { Pool } = require('pg');
const { databaseConnectionString } = require('@volcano.dev/sdk');
// Admin pool created outside the handler (reused across invocations). Its
// application_name is fixed to full_access at startup.
const adminPool = new Pool({
connectionString: databaseConnectionString(process.env.DATABASE_URL),
max: 20,
});
exports.handler = async (event) => {
const auth = event.__volcano_auth;
// Per-user RLS query: the connection must start up as this user, so use a
// short-lived client with the user-access connection string.
const { Client } = require('pg');
const client = new Client({
connectionString: databaseConnectionString(process.env.DATABASE_URL, { userId: auth.user_id }),
});
await client.connect();
try {
const { rows } = await client.query('SELECT * FROM posts'); // filtered by RLS
return {
statusCode: 200,
body: JSON.stringify({ posts: rows }),
};
} finally {
await client.end();
}
};Best Practices
Validate Input
Always validate function input:
exports.handler = async (event) => {
const { email, amount } = event;
if (!email || typeof email !== 'string') {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Invalid email' }),
};
}
if (typeof amount !== 'number' || amount <= 0) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Invalid amount' }),
};
}
// Proceed with validated input
};Check Authentication
Most functions should require authentication:
exports.handler = async (event) => {
const auth = event.__volcano_auth;
if (!auth) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Authentication required' }),
};
}
// auth.user_id - The authenticated user's ID
// auth.email - The user's email
// auth.role - The user's role (if set)
};Handle Timeouts
Functions have execution time limits. Handle long operations gracefully:
exports.handler = async (event) => {
// Set a timeout shorter than the function limit
const timeout = setTimeout(() => {
console.error('Operation taking too long');
}, 25000);
try {
const result = await longRunningOperation();
return { statusCode: 200, body: JSON.stringify(result) };
} finally {
clearTimeout(timeout);
}
};Log for Debugging
Use console.log for debugging - logs appear in the Volcano dashboard:
exports.handler = async (event) => {
console.log('Function invoked with:', JSON.stringify(event));
console.log('User:', event.__volcano_auth?.user_id);
try {
const result = await processData(event);
console.log('Result:', result);
return { statusCode: 200, body: JSON.stringify(result) };
} catch (error) {
console.error('Error:', error);
throw error;
}
};Next Steps
- Database - Use the query builder for simple operations
- Authentication - Understand user context in functions
- Storage - Process uploaded files in functions