Skip to content
Docs · Build & deploy

Functions

One JavaScript module that answers HTTP requests at its own edge URL, https://hk-<project>--<function>.harakumo.app. For webhooks, small APIs, proxies and redirects.

View as Markdown
All topics
On this page

A function is one ES module with a default fetch handler, deployed to the edge. Each request runs at the location nearest the caller with no server to size or keep awake. Create one with code, or without code to get a starter handler you replace later.

A function can be a whole phone app's API: Use Harakumo from a phone app shows one that checks the user's token, reads the database and signs uploads.

worker.jsHTTPS responseone module, answering at its own URL on the Harakumo edge

In plain words

A small piece of code with its own web address. It does one job each time it is called, with no computer of your own to set up or keep running.

For example: When a customer pays, the payment service calls a function that marks the order as paid.

The address

Every function is served at https://hk-<project-slug>--<function-name>.harakumo.app — never under the project's own site address. The create response carries it as function.url.

Functions created before the move to harakumo.app have moved there, and url is the address to use. Their old harakumo.com address no longer runs the function: it redirects to the new one (301 for GET and HEAD, 308 for anything else, so a POST is sent again as a POST with its body). Webhook senders and pages on other sites usually do not follow redirects, so give them the new address.

Runtimes

runtimeWhat the code gets
node22 (default), node20Web APIs plus the Node.js compatibility layer: node:crypto, Buffer, process.env.
edgeWeb APIs only: fetch, Request and Response, crypto.subtle, streams.

Change the runtime with PUT /api/functions/:id { runtime }, with or without new code: the function is deployed again with it.

In front of your code

  • A function runs behind a small guard of Harakumo's: one deployed before 2026-10-11 gets it on its next deploy. Plain HTTP is redirected to HTTPS (except the two paths certificate checks use), security headers your answer does not set are added, and a WebSocket upgrade is passed through as it is.
  • Shield's rules apply before your code runs: a visitor from a blocked country, an address on the deny list and, when Shield blocks them, an AI crawler that names itself get 403 and your code does not run. In log-only mode they are counted and let through. What the rules caught shows on Dashboard → Shield.
  • The guard takes Shield's rules as they are at the deploy: a later change to them reaches the function on its next deploy, and a Shield save names the functions still running the rules from before.

Environment variables

  • The project's production variables are available as env.NAME in the handler, with HARAKUMO_PROJECT_ID, the project's id.
  • They are read when the function deploys. After changing one, redeploy the function: PUT /api/functions/:id with no code deploys its kept code again with the variables as they are now. The answer to a variable change names the functions still running the old values (functions_behind), and the project's Environment tab offers Redeploy.
  • The function receives them as secrets: the handler reads env.NAME as usual, and the values cannot be read back from where the function runs. In Harakumo they stay visible to anyone who can read the project's environment (developers and above), so they are configuration, not a vault.

The handler

The module's default export has a fetch(request, env, ctx) method, called once per request. request is a standard Request; env holds the project's variables (env.NAME); ctx.waitUntil keeps work going after the answer (below). Return a Response, or a promise of one.

The address is public: anyone who knows it can call it. Before doing anything private, check something only your callers have, such as a sign-in token or a shared secret in a header.

Read the path, the query and a JSON body
export default {
  async fetch(request, env, ctx) {
    // https://hk-shop--api.harakumo.app/items/42?expand=1
    const url = new URL(request.url);
    const parts = url.pathname.split('/').filter(Boolean);   // ['items', '42']

    if (request.headers.get('x-api-secret') !== env.API_SECRET) {
      return Response.json({ error: 'Not allowed' }, { status: 401 });
    }

    if (request.method === 'GET' && parts[0] === 'items' && parts.length === 2) {
      const id = Number(parts[1]);
      if (!Number.isInteger(id)) return Response.json({ error: 'The id must be a number' }, { status: 400 });
      return Response.json({ id, expand: url.searchParams.get('expand') === '1' });
    }

    if (request.method === 'POST' && url.pathname === '/items') {
      let item;
      try {
        item = await request.json();
      } catch {
        return Response.json({ error: 'Send JSON' }, { status: 400 });
      }
      if (typeof item.name !== 'string' || !item.name.trim()) {
        return Response.json({ error: 'name is required' }, { status: 400 });
      }
      return Response.json({ created: item.name.trim() }, { status: 201 });
    }

    return Response.json({ error: 'Not found' }, { status: 404 });
  },
};

Answer pages on other sites (CORS)

A browser lets a page call a function on another address only when the function says it may, with CORS headers, and for most calls it first sends an OPTIONS request to ask. Name the sites you trust rather than * when the function does anything private. Calls from your own server, a phone app or curl need none of this.

CORS for one site
const ALLOWED = 'https://my-store.harakumo.app';

const cors = {
  'access-control-allow-origin': ALLOWED,
  'access-control-allow-methods': 'GET, POST, OPTIONS',
  'access-control-allow-headers': 'content-type, authorization',
  'access-control-max-age': '600',
  vary: 'origin',
};

export default {
  async fetch(request) {
    if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
    const res = Response.json({ ok: true });
    for (const [name, value] of Object.entries(cors)) res.headers.set(name, value);
    return res;
  },
};

Where a request comes from

request.cf says where the request came from, worked out from the caller's IP address: country (two letters, such as "DE"), and when known city, region, postalCode, latitude, longitude, timezone and continent, plus isEUCountry ("1" inside the EU) and colo, the edge location that answered. Any of them can be missing, so keep a default.

It is a guess from the address. Someone on a VPN or a company network shows where that network is, so use it to choose a language, a currency or a nearby store, never to prove where a person is. A test call from the dashboard or the API is made by Harakumo, so it shows where that request came from, not you.

Shield's country rules reach a function from its next deploy: the guard in front of its code turns a blocked visitor away before the code runs. For anything else that depends on the country, check request.cf.country yourself.

Prices in the caller's currency
const CURRENCY = { US: 'USD', GB: 'GBP', JP: 'JPY', IN: 'INR' };

export default {
  async fetch(request) {
    const where = request.cf || {};
    const country = where.country || 'US';
    return Response.json({
      country,
      currency: CURRENCY[country] || (where.isEUCountry === '1' ? 'EUR' : 'USD'),
      timezone: where.timezone || 'UTC',
      city: where.city || null,
    });
  },
};

Cache answers at the edge

A function can keep answers in the edge cache, so a repeat request is answered without running your code again. Functions share the edge with other Harakumo apps, so keep yours under a name only your project knows: put a long random value in an environment variable such as CACHE_NAME and open the cache with it. Do not keep anything private in a cache opened under a name someone could guess.

Only GET requests are cached here. An answer is kept for as long as its cache-control header says, in the edge location that saved it: a caller somewhere else may get a fresh answer, and an entry can go before its time. A redeploy does not clear it; change CACHE_NAME to start empty. Never cache an answer that depends on who is asking, or one that sets a cookie.

Keep a GET answer for five minutes
async function answer(request, env) {
  const res = await fetch('https://example.com/rates.json');   // slow, or paid per call
  return Response.json(await res.json());
}

export default {
  async fetch(request, env, ctx) {
    if (request.method !== 'GET' || !env.CACHE_NAME) return answer(request, env);
    const cache = await caches.open(env.CACHE_NAME);
    const hit = await cache.match(request);
    if (hit) return hit;

    const fresh = await answer(request, env);
    if (!fresh.ok) return fresh;
    const res = new Response(fresh.body, fresh);
    res.headers.set('cache-control', 'public, max-age=300');
    ctx.waitUntil(cache.put(request, res.clone()));   // saved after the answer goes
    return res;
  },
};

Work after the answer

ctx.waitUntil(promise) lets work finish after the response has been sent, so the caller does not wait for it: a log line, a webhook to another service, a cache write. It is for a short while (seconds, not minutes), and nobody sees it fail, so catch its errors. Anything that must happen belongs before the response.

Send a log line without slowing the answer
export default {
  async fetch(request, env, ctx) {
    const started = Date.now();
    const res = Response.json({ ok: true });
    ctx.waitUntil(
      fetch(env.LOG_URL, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ path: new URL(request.url).pathname, ms: Date.now() - started }),
      }).catch(() => {}),
    );
    return res;
  },
};

Use the project's other services

A function calls the Harakumo API with plain fetch and a key kept in an environment variable: a key limited to the function's project is the narrowest that works. setup_app_backend from an AI assistant creates one and stores it, with the database and bucket ids, as HARAKUMO_API_KEY, HARAKUMO_DATABASE_ID and HARAKUMO_BUCKET_ID. Never send the key back in an answer.

Read from the database
async function query(env, sql, params = []) {
  const res = await fetch('https://harakumo.com/api/databases/' + env.HARAKUMO_DATABASE_ID, {
    method: 'POST',
    headers: { authorization: 'Bearer ' + env.HARAKUMO_API_KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ sql, params }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.error || 'Harakumo answered ' + res.status);
  return data.results;
}

export default {
  async fetch(request, env) {
    const id = Number(new URL(request.url).searchParams.get('id'));
    if (!Number.isInteger(id)) return Response.json({ error: 'Send ?id=' }, { status: 400 });
    try {
      const [product] = await query(env, 'SELECT id, name, price_cents FROM products WHERE id = ?', [id]);
      return product ? Response.json(product) : Response.json({ error: 'No such product' }, { status: 404 });
    } catch (err) {
      return Response.json({ error: err.message }, { status: 502 });
    }
  },
};

Status and failed deploys

  • status is creating while the first deploy runs, active once the function answers at url, and error when its last deploy (or a delete) did not go through. last_error says why; url is null unless the function is active.
  • Code the edge refuses (a syntax error, an import that does not exist, too much work while the module loads) is answered with 400 and the edge's own reason. On create nothing is kept; on redeploy the running version keeps serving.
  • Problems on our side answer 502 or 503 with what went wrong; a 503 carries Retry-After. A failed deploy is never reported as a working function.
  • If the code uploaded but its address could not be connected, the function is kept with status error: redeploy it (PUT /api/functions/:id) to try again, or delete it.

Versions and rollback

Every deploy that changes a function's code is a version, numbered from 1: the create, a redeploy with new code, and a rollback. Deploying the same code again is not a new version. The newest 20 are kept, and the running one too if it is older; current is the version the function runs now. Its page in the dashboard lists them.

A version whose status is failed was refused when it was deployed: it never ran, and the version before it kept serving. last_error says why.

A rollback deploys a kept version's code again, with the runtime it ran with, as a new version whose rolled_back_from names the one it came from. It runs with the project's production variables as they are at that moment: a version records the names of the variables it was deployed with (env_keys), never their values.

A rollback fails the way a redeploy does. When the code is refused, the version that was live keeps serving, and the answer says why and which version that is. It takes the same permission as a redeploy; anyone in the workspace can list versions, and a version's code is for developers and above, like the function's own code.

List versions
const { versions, current } = await hk.functions.versions(fn.id);
// versions, newest first: [{ version: 3, status: 'deployed', current: true, rolled_back_from: 1, env_keys: ['API_KEY'], … }, …]
Roll back
const { version, url } = await hk.functions.rollback(fn.id, 2);
// version 2's code is live again, as a new version

Limits

  • One module, at most 200 KB, no npm install and no bundler — bundle dependencies yourself if you need them.
  • CPU time per request: 50 ms on Hobby, 1 second on Pro, 2 seconds on Team and 10 seconds on Scale. Waiting on fetch or a database does not count; a request past it fails. A plan change applies on the function's next deploy.
  • One deploy, rollback or delete of a function at a time: another sent while one runs answers 409 deploy_in_progress.
  • Code that does not parse, or imports something that does not exist, is refused with 400 and the edge's reason; nothing is created.
  • Invoking from the API or dashboard is a test call: it forwards method, path, headers and body, waits up to 10 seconds and returns the status, headers and the first 16 KB of the body. Real traffic goes to the URL and is not counted or capped yet.
  • Test calls are counted as test calls, never as traffic: test_call_count on the function, and "Function test runs" under Usage. invocation_count (requests to the URL) is null until traffic is measured.
  • No logs and no scheduled (cron) runs yet: a function runs when its URL is called. Work that must happen on a timer is an agent's job: an agent can run on a schedule (see Agents).
  • Developers and above can read a function's stored source; viewers cannot.
  • Keys: a key limited to the project can create, deploy and delete its functions; a read-only key can read and test-invoke them.

Reference

SDK
const code = `export default {
  async fetch(request, env) {
    return Response.json({ hello: 'world', region: env.REGION ?? 'edge' });
  },
};`;
const { function: fn } = await hk.functions.create(project.id, { name: 'hello', runtime: 'node22', code });
console.log(fn.url);                            // https://hk-<project>--hello.harakumo.app
await hk.functions.update(fn.id, { code });     // redeploy (also picks up env changes)
const { versions } = await hk.functions.versions(fn.id);
await hk.functions.rollback(fn.id, 1);          // version 1's code, live again as a new version