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: 0plus a per-request flush, orspm.shutdown()onSIGTERM -
app.onError(honoErrorHandler(spm))registered - Metric labels use
c.req.routePath - Connector route runs where
node:cryptoexists -
SPM_SECRET_KEYcomes from the environment