@soft-where/meter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # @soft-where/meter
2
+
3
+ Production-safe, edge-compatible usage metering for Node and Next.js. Tracks API requests, bandwidth, blob storage, custom metrics, and DB snapshots by posting to the SOFT-WHERE ERP. Stateless, non-blocking, and only active when `NODE_ENV === "production"`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @soft-where/meter
9
+ ```
10
+
11
+ ## Required variables
12
+
13
+ The meter needs three values. **Pass them in when calling `createMeter()`**; in apps you typically read from environment variables so secrets stay out of code.
14
+
15
+ | Variable | Purpose | Example |
16
+ |----------|---------|--------|
17
+ | **projectId** | Your SOFT-WHERE project identifier | `"proj_abc123"` |
18
+ | **apiKey** | Secret key for the ERP (do not expose to the client) | From SOFT-WHERE dashboard |
19
+ | **erpUrl** | Full URL of the SOFT-WHERE ERP ingest endpoint | `"https://erp.soft-where.com/ingest"` |
20
+ | **environment** | Optional label (e.g. staging vs production) | `"production"` |
21
+
22
+ **Important:** Do not use `NEXT_PUBLIC_*` for `apiKey` or other secrets. Use server/edge-only env vars (e.g. `SOFT_WHERE_API_KEY`) so they are not bundled for the browser.
23
+
24
+ ## Usage
25
+
26
+ ### 1. Create a meter
27
+
28
+ Call `createMeter()` once (e.g. at module load or in a shared helper) and reuse the returned object. Use env vars for config so you can change them per environment without code changes.
29
+
30
+ ```ts
31
+ import { createMeter } from "@soft-where/meter";
32
+
33
+ const meter = createMeter({
34
+ projectId: process.env.SOFT_WHERE_PROJECT_ID!,
35
+ apiKey: process.env.SOFT_WHERE_API_KEY!,
36
+ erpUrl: process.env.SOFT_WHERE_ERP_URL!,
37
+ environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV,
38
+ });
39
+ ```
40
+
41
+ ### 2. Use the meter
42
+
43
+ - **trackRequest(req, res, durationMs)** — Call with the same `Request` and `Response` (or Next.js `NextRequest` / `NextResponse`) and the request duration in milliseconds. The library sends one request event (and reads `bandwidthBytes` from `Response#headers` `Content-Length` when present).
44
+ - **trackBlobUpload(bytes)** / **trackBlobDelete(bytes)** — Call from upload/delete handlers with the byte deltas.
45
+ - **trackCustomMetric(name, value)** — Send arbitrary numeric metrics.
46
+ - **attemptDbSnapshot(getDbSize)** — Call with an async function that returns DB size in bytes (e.g. Neon HTTP). The library will call it, then post the snapshot to the ERP without blocking on the network.
47
+
48
+ All methods are no-ops when `NODE_ENV !== "production"`. They never throw and never block request completion (ERP calls are fire-and-forget with `keepalive: true`).
49
+
50
+ ---
51
+
52
+ ## Next.js: use in `middleware.ts`
53
+
54
+ Middleware runs on the Edge runtime. You can use `@soft-where/meter` there to send request counts and duration (and bandwidth when `Content-Length` is present on the response) to the SOFT-WHERE ERP.
55
+
56
+ ### Cursor agent instructions (Next.js middleware integration)
57
+
58
+ Use the following as both documentation and a prompt for a Cursor agent that should wire this package into a Next.js app’s middleware.
59
+
60
+ **Goal:** Integrate `@soft-where/meter` into the existing Next.js middleware so that every request is metered and reported to the SOFT-WHERE ERP. Do not break existing middleware logic (auth, redirects, etc.); add metering alongside it.
61
+
62
+ **Steps:**
63
+
64
+ 1. **Install the package**
65
+ - Add dependency: `@soft-where/meter`.
66
+
67
+ 2. **Environment variables**
68
+ - Ensure the Next.js project has these **server/edge-only** env vars (e.g. in `.env.local` or in the hosting provider’s dashboard). Do **not** prefix them with `NEXT_PUBLIC_`:
69
+ - `SOFT_WHERE_PROJECT_ID` — SOFT-WHERE project ID.
70
+ - `SOFT_WHERE_API_KEY` — Secret API key for the ERP.
71
+ - `SOFT_WHERE_ERP_URL` — Full ERP ingest URL (e.g. `https://erp.soft-where.com/ingest`).
72
+ - Document these in the project’s env example (e.g. `.env.example`) with short comments.
73
+
74
+ 3. **Create a shared meter instance**
75
+ - In the same file as the middleware (or in a small module it imports), create the meter once using the env vars above:
76
+ - `createMeter({ projectId, apiKey, erpUrl, environment? })`.
77
+ - Read `projectId`, `apiKey`, and `erpUrl` from `process.env.SOFT_WHERE_PROJECT_ID`, `process.env.SOFT_WHERE_API_KEY`, and `process.env.SOFT_WHERE_ERP_URL`.
78
+ - Only create/call the meter when these env vars are defined so the app does not crash in dev or when the integration is disabled.
79
+
80
+ 4. **Wire metering into `middleware.ts`**
81
+ - In the middleware function:
82
+ - Record the start time at the very beginning (e.g. `const start = Date.now()`).
83
+ - Run the existing middleware logic and obtain the `NextResponse` (or other response) you intend to return.
84
+ - Before returning, call `meter.trackRequest(request, response, durationMs)` where:
85
+ - `request` is the middleware’s `NextRequest` (it is a standard `Request`).
86
+ - `response` is the `NextResponse` (or response) you are about to return (it is a standard `Response`).
87
+ - `durationMs` is `Date.now() - start`.
88
+ - Return the same response as before so behavior is unchanged.
89
+ - Do not `await` the meter; `trackRequest` is fire-and-forget and must not block the response.
90
+
91
+ 5. **Edge compatibility**
92
+ - Middleware runs on the Edge runtime. `@soft-where/meter` is edge-compatible and uses the global `fetch` with `keepalive: true`. No extra config is required.
93
+
94
+ 6. **Production-only behavior**
95
+ - The library only sends data when `NODE_ENV === "production"`. In development, all meter calls are no-ops; no need to guard calls in code.
96
+
97
+ **Summary for the agent:** Install `@soft-where/meter`, add env vars `SOFT_WHERE_PROJECT_ID`, `SOFT_WHERE_API_KEY`, and `SOFT_WHERE_ERP_URL` (no `NEXT_PUBLIC_`), create one meter with `createMeter()` from those env vars, then in `middleware.ts` measure duration and call `meter.trackRequest(request, response, durationMs)` before returning the response. Leave all other middleware logic intact.
98
+
99
+ ---
100
+
101
+ ### Minimal middleware example
102
+
103
+ ```ts
104
+ // middleware.ts (Next.js)
105
+ import { NextResponse } from "next/server";
106
+ import type { NextRequest } from "next/server";
107
+ import { createMeter } from "@soft-where/meter";
108
+
109
+ const projectId = process.env.SOFT_WHERE_PROJECT_ID;
110
+ const apiKey = process.env.SOFT_WHERE_API_KEY;
111
+ const erpUrl = process.env.SOFT_WHERE_ERP_URL;
112
+
113
+ const meter =
114
+ projectId && apiKey && erpUrl
115
+ ? createMeter({ projectId, apiKey, erpUrl })
116
+ : null;
117
+
118
+ export function middleware(request: NextRequest) {
119
+ const start = Date.now();
120
+ const response = NextResponse.next();
121
+ const durationMs = Date.now() - start;
122
+
123
+ if (meter) {
124
+ meter.trackRequest(request, response, durationMs);
125
+ }
126
+
127
+ return response;
128
+ }
129
+ ```
130
+
131
+ ## API reference
132
+
133
+ - **createMeter(options)**
134
+ `options.projectId` (string), `options.apiKey` (string), `options.erpUrl` (string), `options.environment?` (string).
135
+ Returns a **Meter** object.
136
+
137
+ - **meter.trackRequest(req, res, durationMs)**
138
+ `req` and `res` are the Web API `Request` and `Response` (or Next.js equivalents). `durationMs` is the request duration in milliseconds. Sends one request event; bandwidth is taken from `res.headers` `Content-Length` when present.
139
+
140
+ - **meter.trackBlobUpload(bytes)** / **meter.trackBlobDelete(bytes)**
141
+ `bytes` is the size delta.
142
+
143
+ - **meter.trackCustomMetric(name, value)**
144
+ `name` (string), `value` (number).
145
+
146
+ - **meter.attemptDbSnapshot(getDbSize)**
147
+ `getDbSize` is a `() => Promise<number>` (e.g. Neon HTTP size). The library calls it and posts the result to the ERP without awaiting the network. Returns a Promise that resolves when the size has been obtained and the send has been triggered (or on error); never throws.
148
+
149
+ ## License
150
+
151
+ MIT
@@ -0,0 +1,23 @@
1
+ /**
2
+ * SOFT-WHERE usage metering library.
3
+ * Production-safe, edge-compatible, stateless. Only sends when NODE_ENV === "production".
4
+ * @packageDocumentation
5
+ */
6
+ declare const VERSION = "1.0.0";
7
+
8
+ type CreateMeterOptions = {
9
+ projectId: string;
10
+ apiKey: string;
11
+ erpUrl: string;
12
+ environment?: string;
13
+ };
14
+ type Meter = {
15
+ trackRequest: (req: Request, res: Response, durationMs: number) => void;
16
+ trackBlobUpload: (bytes: number) => void;
17
+ trackBlobDelete: (bytes: number) => void;
18
+ trackCustomMetric: (name: string, value: number) => void;
19
+ attemptDbSnapshot: (getDbSize: () => Promise<number>) => Promise<void>;
20
+ };
21
+ declare function createMeter(options: CreateMeterOptions): Meter;
22
+
23
+ export { type CreateMeterOptions, type Meter, createMeter, VERSION as version };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * SOFT-WHERE usage metering library.
3
+ * Production-safe, edge-compatible, stateless. Only sends when NODE_ENV === "production".
4
+ * @packageDocumentation
5
+ */
6
+ declare const VERSION = "1.0.0";
7
+
8
+ type CreateMeterOptions = {
9
+ projectId: string;
10
+ apiKey: string;
11
+ erpUrl: string;
12
+ environment?: string;
13
+ };
14
+ type Meter = {
15
+ trackRequest: (req: Request, res: Response, durationMs: number) => void;
16
+ trackBlobUpload: (bytes: number) => void;
17
+ trackBlobDelete: (bytes: number) => void;
18
+ trackCustomMetric: (name: string, value: number) => void;
19
+ attemptDbSnapshot: (getDbSize: () => Promise<number>) => Promise<void>;
20
+ };
21
+ declare function createMeter(options: CreateMeterOptions): Meter;
22
+
23
+ export { type CreateMeterOptions, type Meter, createMeter, VERSION as version };
package/dist/index.js ADDED
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createMeter: () => createMeter,
24
+ version: () => VERSION
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+ var VERSION = "1.0.0";
28
+ function getBandwidthBytes(res) {
29
+ const cl = res.headers.get("content-length");
30
+ if (cl == null) return 0;
31
+ const n = Number(cl);
32
+ return Number.isFinite(n) && n >= 0 ? n : 0;
33
+ }
34
+ function createMeter(options) {
35
+ const { projectId, apiKey, erpUrl } = options;
36
+ const isProduction = typeof process !== "undefined" && process.env?.NODE_ENV === "production";
37
+ function postToErp(payload) {
38
+ if (!isProduction) return;
39
+ try {
40
+ fetch(erpUrl, {
41
+ method: "POST",
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ "x-softwhere-key": apiKey
45
+ },
46
+ body: JSON.stringify(payload),
47
+ keepalive: true
48
+ }).catch(() => {
49
+ });
50
+ } catch {
51
+ }
52
+ }
53
+ return {
54
+ trackRequest(req, res, durationMs) {
55
+ if (!isProduction) return;
56
+ const bandwidthBytes = getBandwidthBytes(res);
57
+ postToErp({
58
+ type: "request",
59
+ projectId,
60
+ apiRequests: 1,
61
+ bandwidthBytes,
62
+ durationMs,
63
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
64
+ });
65
+ },
66
+ trackBlobUpload(bytes) {
67
+ if (!isProduction) return;
68
+ postToErp({ type: "blob_upload", projectId, bytes });
69
+ },
70
+ trackBlobDelete(bytes) {
71
+ if (!isProduction) return;
72
+ postToErp({ type: "blob_delete", projectId, bytes });
73
+ },
74
+ trackCustomMetric(name, value) {
75
+ if (!isProduction) return;
76
+ postToErp({ type: "custom", projectId, metric: name, value });
77
+ },
78
+ async attemptDbSnapshot(getDbSize) {
79
+ if (!isProduction) return;
80
+ try {
81
+ const dbBytes = await getDbSize();
82
+ postToErp({
83
+ type: "db_snapshot",
84
+ projectId,
85
+ dbBytes,
86
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
87
+ });
88
+ } catch {
89
+ }
90
+ }
91
+ };
92
+ }
93
+ // Annotate the CommonJS export names for ESM import in node:
94
+ 0 && (module.exports = {
95
+ createMeter,
96
+ version
97
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // index.ts
2
+ var VERSION = "1.0.0";
3
+ function getBandwidthBytes(res) {
4
+ const cl = res.headers.get("content-length");
5
+ if (cl == null) return 0;
6
+ const n = Number(cl);
7
+ return Number.isFinite(n) && n >= 0 ? n : 0;
8
+ }
9
+ function createMeter(options) {
10
+ const { projectId, apiKey, erpUrl } = options;
11
+ const isProduction = typeof process !== "undefined" && process.env?.NODE_ENV === "production";
12
+ function postToErp(payload) {
13
+ if (!isProduction) return;
14
+ try {
15
+ fetch(erpUrl, {
16
+ method: "POST",
17
+ headers: {
18
+ "Content-Type": "application/json",
19
+ "x-softwhere-key": apiKey
20
+ },
21
+ body: JSON.stringify(payload),
22
+ keepalive: true
23
+ }).catch(() => {
24
+ });
25
+ } catch {
26
+ }
27
+ }
28
+ return {
29
+ trackRequest(req, res, durationMs) {
30
+ if (!isProduction) return;
31
+ const bandwidthBytes = getBandwidthBytes(res);
32
+ postToErp({
33
+ type: "request",
34
+ projectId,
35
+ apiRequests: 1,
36
+ bandwidthBytes,
37
+ durationMs,
38
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
39
+ });
40
+ },
41
+ trackBlobUpload(bytes) {
42
+ if (!isProduction) return;
43
+ postToErp({ type: "blob_upload", projectId, bytes });
44
+ },
45
+ trackBlobDelete(bytes) {
46
+ if (!isProduction) return;
47
+ postToErp({ type: "blob_delete", projectId, bytes });
48
+ },
49
+ trackCustomMetric(name, value) {
50
+ if (!isProduction) return;
51
+ postToErp({ type: "custom", projectId, metric: name, value });
52
+ },
53
+ async attemptDbSnapshot(getDbSize) {
54
+ if (!isProduction) return;
55
+ try {
56
+ const dbBytes = await getDbSize();
57
+ postToErp({
58
+ type: "db_snapshot",
59
+ projectId,
60
+ dbBytes,
61
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
62
+ });
63
+ } catch {
64
+ }
65
+ }
66
+ };
67
+ }
68
+ export {
69
+ createMeter,
70
+ VERSION as version
71
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@soft-where/meter",
3
+ "version": "1.0.0",
4
+ "description": "SOFT-WHERE usage metering library — production-safe, edge-compatible, stateless",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist"],
16
+ "scripts": {
17
+ "build": "npx tsup index.ts --format esm,cjs --dts",
18
+ "dev": "npx tsup index.ts --watch",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": ["metering", "usage", "analytics", "edge", "soft-where"],
22
+ "author": "",
23
+ "license": "MIT",
24
+ "devDependencies": {
25
+ "@types/node": "^25.3.0",
26
+ "tsup": "^8.5.1",
27
+ "typescript": "^5.9.3"
28
+ }
29
+ }