Documentation menu

Hono

Works the same on Node, Bun, Deno and edge runtimes — with one caveat for the connector, noted below.

npm install @saaspro/node

1. Client

// src/spm.ts
import { SaaSProMax } from "@saaspro/node";

export const spm = new SaaSProMax({
  secretKey: process.env.SPM_SECRET_KEY!,   // spm_sec_prod_xxxxxxxx
  release: process.env.GIT_SHA,
  env: process.env.NODE_ENV,
  // Short-lived runtimes should flush per request rather than on a timer.
  flushIntervalMs: 0,
});

2. Timing middleware

import { Hono } from "hono";
import { spm } from "./spm";

const app = new Hono();

app.use("*", async (c, next) => {
  const startedAt = Date.now();
  await next();
  spm.metrics.timing("http.request", Date.now() - startedAt, {
    method: c.req.method,
    route: c.req.routePath,     // the pattern, e.g. /orders/:id
    status: String(c.res.status),
  });
});

Use c.req.routePath, not c.req.path — the resolved URL would create a metric series per id.

3. Events

app.post("/api/orders", async (c) => {
  const order = await createOrder(await c.req.json());

  spm.track({
    distinctId: c.get("userId"),
    anonymousId: getCookie(c, "spm_aid"),
    event: "order placed",
    properties: { items: order.items.length },
    value: order.total,
    currency: order.currency,
    context: {
      ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim(),
      userAgent: c.req.header("user-agent"),
    },
  });

  await spm.flush();   // see "Flushing" below
  return c.json({ id: order.id });
});

4. Error handler

import { honoErrorHandler } from "@saaspro/node";

app.onError(honoErrorHandler(spm));

That reports the error with method, URL and headers (credential headers stripped) and returns 500 {"error":"Internal Server Error"}. To control the response:

app.onError(
  honoErrorHandler(spm, (error, c) => {
    if (error instanceof HTTPException) return error.getResponse();
    return c.json({ error: "Something went wrong" }, 500);
  }),
);

5. Flushing

Hono runs in places that freeze or tear down the process as soon as your handler resolves, so a background flush timer is unreliable. Two options:

Flush per request (simplest — set flushIntervalMs: 0):

app.use("*", async (c, next) => {
  await next();
  await spm.flush();
});

Cloudflare Workers — hand the flush to waitUntil so it does not block the response:

app.use("*", async (c, next) => {
  await next();
  c.executionCtx.waitUntil(spm.flush());
});

On a long-running Node or Bun server, keep the default flushIntervalMs: 5000 and call spm.shutdown() on SIGTERM instead.

6. Feature flags

app.get("/api/config", async (c) => {
  const flags = await spm.flags.evaluate({
    distinctId: c.get("userId"),
    properties: { plan: c.get("plan") },
  });
  return c.json({ flags });
});

Cached in memory for 30 seconds. On an edge runtime each isolate keeps its own cache, which is fine — the values are identical.

7. Connector

import { createConnectorHandler } from "@saaspro/node";
import { handlers } from "./spm-connector";

const connector = createConnectorHandler({
  publicKey: process.env.SPM_CONNECTOR_PUBLIC_KEY!,
  audience: process.env.SPM_APP_ID!,
  handlers,
});

app.all("/api/spm/connector/*", (c) => connector(c.req.raw));

Hono hands you a standard Request, which is exactly what the handler expects, and routes are matched on the path suffix so the mount point is up to you.

The connector needs node:crypto for Ed25519 verification. Run it on Node, Bun, Deno, or a Worker with nodejs_compat enabled — not on a plain V8 isolate.

Checklist

  • flushIntervalMs: 0 plus a per-request flush, or spm.shutdown() on SIGTERM
  • app.onError(honoErrorHandler(spm)) registered
  • Metric labels use c.req.routePath
  • Connector route runs where node:crypto exists
  • SPM_SECRET_KEY comes from the environment