@pradeeparul2/unisights-node
v0.0.0-beta.3
Published
Server package for unisights. Creates a configurable endpoint that receives events from the unisights client SDK. Processing is optional. Always returns 200.
Maintainers
Readme
Unisights Node
Server package for the unisights ecosystem.
Creates a configurable endpoint that receives events captured by the unisights client SDK. Processing is optional. Always returns 200 — the client never blocks on the server response. Encrypted payloads are automatically decrypted before reaching your handler.
unisights client SDK
→ captures events (clicks, page views, errors, custom…)
→ POST /collect { ...payload } ← plain or encrypted
unisights-node
→ receives the payload
→ auto-decrypts if encrypted: true
→ calls your handler(UnisightsPayload) ← always decrypted
→ always responds 200 { ok: true }Install
npm install @pradeeparul2/unisights-nodeBasic usage
import { unisights } from "@pradeeparul2/unisights-node";
// No handler — just open the endpoint and return 200
const collector = unisights({ path: "/collect" });
// With optional processing
const collector = unisights({
path: "/collect",
handler: async (payload) => {
// payload is always UnisightsPayload — encrypted or not
await db.events.insert(payload.data);
},
});Express
import express from "express";
import { unisights } from "@pradeeparul2/unisights-node";
const app = express();
app.use(
unisights({
path: "/collect",
handler: async (payload) => {
console.log("event received:", payload.data);
},
}),
);
app.listen(3000);NestJS
// main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { unisights } from "@pradeeparul2/unisights-node";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(
unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
}),
);
await app.listen(3000);
}
bootstrap();Fastify
import Fastify from "fastify";
import { unisights } from "@pradeeparul2/unisights-node";
const fastify = Fastify();
const collector = unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
});
fastify.register(collector.fastify);
fastify.listen({ port: 3000 });Koa
import Koa from "koa";
import { unisights } from "@pradeeparul2/unisights-node";
const app = new Koa();
const collector = unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
});
app.use(collector.koa);
app.listen(3000);Raw Node.js http
import http from "node:http";
import { unisights } from "@pradeeparul2/unisights-node";
const collector = unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
});
http.createServer(collector).listen(3000);Hono
Works on Cloudflare Workers, Bun, Deno, Node — anywhere Hono runs.
import { Hono } from "hono";
import { unisights } from "@pradeeparul2/unisights-node";
const app = new Hono();
const collector = unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
});
app.use("*", collector.hono);
export default app;Cloudflare Workers
import { unisights } from "@pradeeparul2/unisights-node";
const collector = unisights({
path: "/collect",
handler: async (payload, request) => {
// payload is always the decrypted UnisightsPayload
// request is the raw Web Fetch API Request
await fetch("https://ingest.myservice.com", {
method: "POST",
body: JSON.stringify(payload),
});
},
});
export default {
fetch: collector.fetch,
};Cloudflare Pages Functions
// functions/collect.js
import { unisights } from "@pradeeparul2/unisights-node";
const collector = unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
});
export async function onRequestPost(context) {
return collector.fetch(context.request);
}Deno / Deno Deploy
import { unisights } from "@pradeeparul2/unisights-node";
const collector = unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
});
Deno.serve(collector.fetch);Bun (native HTTP)
import { unisights } from "@pradeeparul2/unisights-node";
const collector = unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
});
export default {
fetch: collector.fetch,
};Elysia (Bun)
import { Elysia } from "elysia";
import { unisights } from "@pradeeparul2/unisights-node";
const app = new Elysia();
const collector = unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
});
collector.elysia(app);
app.listen(3000);Vercel Edge Functions
// api/collect.js
import { unisights } from "@pradeeparul2/unisights-node";
const collector = unisights({
path: "/api/collect",
handler: async (payload) => {
console.log(payload);
},
});
export const config = { runtime: "edge" };
export default (request) => collector.fetch(request);Netlify Edge Functions
// netlify/edge-functions/collect.js
import { unisights } from "@pradeeparul2/unisights-node";
const collector = unisights({
path: "/collect",
handler: async (payload) => {
console.log(payload);
},
});
export default (request) => collector.fetch(request);
export const config = { path: "/collect" };Encryption
When the unisights client SDK is initialised with encrypt: true, payloads are encrypted before being sent. unisights-node detects and decrypts them automatically — your handler always receives a plain UnisightsPayload, regardless of whether encryption was enabled on the client.
// Nothing changes in your server code — decryption is transparent
const collector = unisights({
path: "/collect",
handler: async (payload) => {
// payload is UnisightsPayload whether the SDK sent it encrypted or not
await db.events.insert(payload.data);
},
});How it works
The SDK derives an encryption key entirely from public, reproducible inputs — no secret is stored in or transmitted from the browser:
bucket = floor(timestamp_ms / 30_000) // rotates every 30 seconds
client_key = SHA256(site_id + ":" + bucket + ":" + ua_hash)
ciphertext = plaintext XOR keystream(client_key)
tag = HMAC-SHA256(client_key, ciphertext)The server receives site_id, ua_hash, and bucket in the payload envelope and independently reproduces client_key to verify the HMAC tag and decrypt — no session state needed.
An encrypted payload looks like this when it arrives at the server:
{
"data": "<base64 ciphertext>",
"tag": "<base64 HMAC-SHA256 tag>",
"bucket": 56666667,
"site_id": "YOUR_INSIGHTS_ID",
"ua_hash": "f9a23b...",
"encrypted": true
}Optional server-side secret
For an additional layer of security, configure a server secret. When set, the key is wrapped with an extra HMAC step:
server_key = HMAC-SHA256(SERVER_SECRET, client_key)Pass the same secret to unisights() — decryption remains transparent:
const collector = unisights({
path: "/collect",
serverSecret: process.env.UNISIGHTS_SECRET,
handler: async (payload) => {
await db.events.insert(payload.data);
},
});The
serverSecretmust match what the SDK was configured with. If tag verification fails (wrong secret, tampered payload, or mismatched inputs), the error is swallowed and the client still receives200 { ok: true }.
Manual decrypt
If you need direct control, decrypt and isEncrypted are also exported:
import { unisights, decrypt, isEncrypted } from "@pradeeparul2/unisights-node";
const collector = unisights({
path: "/collect",
handler: async (raw) => {
const payload = isEncrypted(raw)
? await decrypt(raw, { serverSecret: process.env.UNISIGHTS_SECRET })
: raw;
await db.events.insert(payload.data);
},
});Payload shape
Every call to your handler receives a UnisightsPayload. All types are exported from @pradeeparul2/unisights-node.
interface UnisightsPayload {
data: {
asset_id: string; // your Unisights property ID
session_id: string; // UUID v4
page_url: string;
entry_page: string;
exit_page: string | null;
utm_params: UtmParams;
device_info: DeviceInfo; // browser, os, device_type
scroll_depth: number; // 0–100
time_on_page: number; // seconds
events: UnisightsEvent[]; // discriminated union
};
encrypted: boolean;
}events is a discriminated union — TypeScript narrows the data shape for each type automatically:
type UnisightsEvent =
| { type: "page_view"; data: PageViewEventData }
| { type: "click"; data: ClickEventData }
| { type: "web_vital"; data: WebVitalEventData }
| { type: "custom"; data: CustomEventData }
| { type: "error"; data: ErrorEventData };for (const event of payload.data.events) {
switch (event.type) {
case "page_view": // event.data → PageViewEventData ✓
case "click": // event.data → ClickEventData ✓
case "web_vital": // event.data → WebVitalEventData ✓
case "custom": // event.data → CustomEventData ✓
case "error": // event.data → ErrorEventData ✓
}
}TypeScript
The package is written in TypeScript and ships full type definitions:
import { unisights } from "@pradeeparul2/unisights-node";
import type {
UnisightsPayload,
UnisightsEvent,
EncryptedPayload,
DecryptOptions,
} from "@pradeeparul2/unisights-node";
const collector = unisights<UnisightsPayload>({
path: "/collect",
serverSecret: process.env.UNISIGHTS_SECRET,
handler: async (payload) => {
// payload fully typed ✓
const clicks = payload.data.events.filter((e) => e.type === "click");
},
});Options
| Option | Type | Default | Required | Description |
| -------------- | ---------- | ----------- | -------- | ------------------------------------------------------------ |
| path | string | '/events' | No | Endpoint path the server exposes |
| handler | function | null | No | async (payload, req) => void |
| serverSecret | string | undefined | No | Server-side HMAC wrapping secret — must match the SDK config |
Handler
handler: async (payload, req) => {
// payload — UnisightsPayload (always decrypted, even if the SDK sent it encrypted)
// req — raw request object (framework-specific, optional to use)
await db.events.insert(payload.data);
await queue.publish("events", payload);
await fetch("https://downstream.service/ingest", {
method: "POST",
body: JSON.stringify(payload),
});
// Return value is ignored.
// Errors thrown here are swallowed — client always receives 200.
};Always 200
The server always responds 200 { ok: true } regardless of whether:
- The handler is provided or not
- The handler throws an error
- The payload is malformed
- Decryption fails (tag mismatch, tampered payload, wrong secret)
This matches analytics/telemetry collector behaviour — the client SDK should never block user interactions waiting on the server.
Framework surface map
collector → Express, NestJS, Connect, raw http
collector.fastify → Fastify plugin
collector.koa → Koa middleware
collector.fetch → Cloudflare Workers, Deno, Bun, Vercel Edge, Netlify Edge
collector.hono → Hono (all platforms)
collector.elysia(app) → Elysia (Bun)License
MIT
