wayleave 0.1.6 → 0.2.1
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 +61 -6
- package/index.d.ts +103 -4
- package/index.js +151 -34
- package/meter.d.ts +74 -0
- package/meter.js +230 -0
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -52,19 +52,21 @@ app.use(gate.express());
|
|
|
52
52
|
- Signature parameters are parsed as an RFC 9421 dictionary: order-independent, `alg` enforced as Ed25519, any signature label, and the signature base is built from whatever components the signer declared. Requests signed in Cloudflare's documented format verify.
|
|
53
53
|
- Sub-millisecond per request. Your latency budget won't notice.
|
|
54
54
|
- The metering hook can throw, crash, or hang your billing backend — serving continues. Your uptime never depends on ours.
|
|
55
|
-
- Key directories
|
|
56
|
-
- **A bot that sends a browser `user-agent` and an `accept-language` header is classified `human` and crosses priced routes free.** Those two headers are the entire bypass. The `human` lane is a fall-through — it is reached by tripping none of the automation tells, which is absence of evidence, not evidence of a person. There is no TLS fingerprinting
|
|
55
|
+
- Key rotation has a seam but no implementation. `directories` accepts a resolver function you can point at a cache, but there is no JWKS fetcher in the box and the resolver must not block. Wiring that cache is still your job.
|
|
56
|
+
- **A bot that sends a browser `user-agent` and an `accept-language` header is classified `human` and crosses priced routes free.** Those two headers are the entire bypass. The `human` lane is a fall-through — it is reached by tripping none of the automation tells, which is absence of evidence, not evidence of a person. There is no TLS fingerprinting and no challenge here; that work belongs at your edge or CDN, and pretending otherwise would be the dishonest version of this list. (`verifyAgentIP` checks a *declared* operator against its published ranges — it does nothing about a request claiming to be a browser, which is this bypass.) `strictPricedPaths: true` inverts the burden on priced routes so that only a verified signature or your own `confirmHuman(req)` crosses. Recommended wherever there is a price.
|
|
57
57
|
- Wayleave prices **disclosure**; it does not detect **concealment**. It is a tollbooth, not a wall — it works on operators who want to be identifiable, which today is most of the ones worth billing.
|
|
58
58
|
- This is a **screening and pricing layer, not a guarantee**. Every decision returns its evidence and is loggable.
|
|
59
59
|
|
|
60
60
|
## Status
|
|
61
61
|
|
|
62
|
-
v0.1
|
|
62
|
+
v0.2.1 — verification, lanes, policy, pricing, and the metering hook, all under test (87 scenarios, including forgery, tampering, replay, guessed payment proofs, spoofed rate-limit identities, spoofed browser headers, spoofed operator identity, and cross-implementation wire formats). Payment settlement network integration is in progress; the 402 challenge is built in and settlement confirmation is a function you supply.
|
|
63
63
|
|
|
64
64
|
0.1.5 closes a hole worth naming: before it, a priced route accepted a payment proof that any agent could derive from the 402 challenge it had just been sent — free passage, recorded as revenue. Settlement confirmation is now yours to supply and denies by default. If you shipped 0.1.4 or earlier on a priced route, upgrade.
|
|
65
65
|
|
|
66
66
|
0.1.6 addresses the other half of the same question. Payment could be faked; so could being human. `strictPricedPaths` lets a priced route demand positive evidence instead of accepting the absence of a bot signal as one.
|
|
67
67
|
|
|
68
|
+
0.2.1 ships `wayleave/meter`, a buffering and retrying sink for the hosted meter. 0.2.0 turns three hardcoded strategies into seams — state, metering, and key lookup — so shared storage, durable billing, and key rotation become configuration rather than a rewrite. Defaults are unchanged; see [Extending it](#extending-it).
|
|
69
|
+
|
|
68
70
|
## How payment actually works
|
|
69
71
|
|
|
70
72
|
Pay-at-the-door, not IOUs. On a route you price:
|
|
@@ -130,9 +132,62 @@ nothing but a confirmed settlement is ever written to the ledger as billed.
|
|
|
130
132
|
agent hitting two paths in the same second produces byte-identical
|
|
131
133
|
signatures. Rejecting those would break real traffic. Without a signer
|
|
132
134
|
nonce, replay is bounded by the expiry window and nothing tighter.
|
|
133
|
-
- **State is per-process.** Rate limits and replay memory do not
|
|
134
|
-
restart and do not span instances
|
|
135
|
-
|
|
135
|
+
- **State is per-process by default.** Rate limits and replay memory do not
|
|
136
|
+
survive a restart and do not span instances. Pass a `store` implementing
|
|
137
|
+
`hit` / `hasNonce` / `rememberNonce` to share them. Keep it synchronous —
|
|
138
|
+
it runs on every request — so back a distributed store with a local view
|
|
139
|
+
that replicates asynchronously. Note the two uses differ in how much they
|
|
140
|
+
mind lag: fixed-window rate limiting tolerates approximate counts, replay
|
|
141
|
+
does not, because a nonce that has not replicated yet can be spent twice.
|
|
142
|
+
- **Key rotation is possible but not built in.** `directories` accepts a
|
|
143
|
+
resolver function, so point it at a cache you refresh from a JWKS endpoint
|
|
144
|
+
on a timer. There is no fetcher in the box, and the resolver must not block.
|
|
145
|
+
|
|
146
|
+
## Extending it
|
|
147
|
+
|
|
148
|
+
Three seams, each defaulting to the behaviour above. Nothing here is required.
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
new Wayleave({
|
|
152
|
+
store: myRedisBackedStore, // shared rate limits + replay across instances
|
|
153
|
+
sink: myBufferedSink, // emit() with batching, retry, dedup
|
|
154
|
+
directories: url => keyCache[url],// rotatable keys, refreshed out of band
|
|
155
|
+
verifyAgentIP: (ip, ua) => isPublishedRange(ip, ua),
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Every metering event carries `v` (schema version) and `idempotencyKey`, so a
|
|
160
|
+
sink that retries can be deduplicated at the far end rather than double-billing.
|
|
161
|
+
|
|
162
|
+
A buffering, retrying sink ships in the box:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
import Wayleave from 'wayleave';
|
|
166
|
+
import { MeterSink } from 'wayleave/meter';
|
|
167
|
+
|
|
168
|
+
const sink = new MeterSink({
|
|
169
|
+
endpoint: 'https://meter.wayleave.dev',
|
|
170
|
+
apiKey: process.env.WAYLEAVE_METER_KEY,
|
|
171
|
+
onError: (err, info) => log.warn({ err, info }, 'meter'),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const gate = new Wayleave({ pricedPaths: { '/api/premium': 0.05 }, sink });
|
|
175
|
+
process.on('SIGTERM', () => sink.close()); // drain, or lose what is buffered
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`emit` is synchronous, non-throwing, and does no I/O — a billing backend
|
|
179
|
+
having a bad day must not become your customers having one. Retries are safe
|
|
180
|
+
because of the idempotency key: sending a batch twice costs a round trip, not
|
|
181
|
+
a double-billed customer. The buffer is bounded, and anything shed is counted
|
|
182
|
+
and reported rather than dropped quietly.
|
|
183
|
+
|
|
184
|
+
`verifyAgentIP` is the one that changes a lane. A `user-agent` naming a known
|
|
185
|
+
operator is a claim; when that operator publishes its ranges the claim is
|
|
186
|
+
checkable. Returning `false` moves the request to `suspected_bot` — a false
|
|
187
|
+
claim of identity is a stronger signal than no claim, which is the same
|
|
188
|
+
reasoning that puts an invalid signature there. Return `null` for operators you
|
|
189
|
+
cannot check; that leaves the request `declared_agent`, because unknown is not
|
|
190
|
+
guilty. Throwing is treated as `null` for the same reason.
|
|
136
191
|
|
|
137
192
|
## License
|
|
138
193
|
|
package/index.d.ts
CHANGED
|
@@ -144,6 +144,17 @@ export interface Decision {
|
|
|
144
144
|
|
|
145
145
|
/** What the metering hook receives. It may throw; serving continues. */
|
|
146
146
|
export interface MeterEvent {
|
|
147
|
+
/**
|
|
148
|
+
* Event schema version. Present so a meter that outlives one release can
|
|
149
|
+
* tell which shape it is reading. Currently always 1.
|
|
150
|
+
*/
|
|
151
|
+
v: number;
|
|
152
|
+
/**
|
|
153
|
+
* Unique per crossing and stable across retries of the same event, so a
|
|
154
|
+
* sink that resends after a failure can be deduplicated at the far end.
|
|
155
|
+
* Namespaced per process — two instances never collide.
|
|
156
|
+
*/
|
|
157
|
+
idempotencyKey: string;
|
|
147
158
|
/** Unix seconds. */
|
|
148
159
|
t: number;
|
|
149
160
|
path: string;
|
|
@@ -161,8 +172,90 @@ export interface MeterEvent {
|
|
|
161
172
|
/** `[pathPrefix, allow]` — first matching prefix wins. */
|
|
162
173
|
export type PolicyRule = [prefix: string, allow: boolean];
|
|
163
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Rate-limit counters and consumed nonces. Implement this against Redis or
|
|
177
|
+
* Postgres and pass it as `store` to share state across instances.
|
|
178
|
+
*
|
|
179
|
+
* Must be synchronous — it is called on every request, and an awaited round
|
|
180
|
+
* trip per crossing costs more than the crossing earns. Put a distributed
|
|
181
|
+
* backend behind a local view that replicates asynchronously.
|
|
182
|
+
*
|
|
183
|
+
* Note the two uses differ in how much they mind approximation: fixed-window
|
|
184
|
+
* rate limiting tolerates it, replay does not. A nonce that has not yet
|
|
185
|
+
* replicated is a nonce that can be spent twice.
|
|
186
|
+
*/
|
|
187
|
+
export interface Store {
|
|
188
|
+
/** Requests by this identity in the current window. Returns the new count. */
|
|
189
|
+
hit(identity: string, windowSeconds: number, now: number): number;
|
|
190
|
+
hasNonce(nonce: string): boolean;
|
|
191
|
+
rememberNonce(nonce: string, expiresAt: number): void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The default `Store`: bounded, in this process's memory, lost on restart. */
|
|
195
|
+
export class MemoryStore implements Store {
|
|
196
|
+
constructor(opts?: { maxTracked?: number });
|
|
197
|
+
maxTracked: number;
|
|
198
|
+
hit(identity: string, windowSeconds: number, now: number): number;
|
|
199
|
+
hasNonce(nonce: string): boolean;
|
|
200
|
+
rememberNonce(nonce: string, expiresAt: number): void;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Where metering events go. Implement `emit` to buffer, batch, retry, and
|
|
205
|
+
* dedup on `idempotencyKey`. It must never throw into the request path.
|
|
206
|
+
*/
|
|
207
|
+
export interface Sink {
|
|
208
|
+
emit(event: MeterEvent): void;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The default `Sink`: calls `onEvent` and swallows whatever it throws. */
|
|
212
|
+
export class DirectSink implements Sink {
|
|
213
|
+
constructor(onEvent?: (event: MeterEvent) => void);
|
|
214
|
+
emit(event: MeterEvent): void;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Key lookup. The function form is what makes rotation possible: point it at
|
|
219
|
+
* a cache your app refreshes from a JWKS endpoint on a timer. Synchronous by
|
|
220
|
+
* design — do not fetch here.
|
|
221
|
+
*/
|
|
222
|
+
export type KeyResolver = (directoryUrl: string) =>
|
|
223
|
+
Record<string, PublicKeyLike> | undefined;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Checks whether a request claiming to be a known operator actually came from
|
|
227
|
+
* that operator's published addresses.
|
|
228
|
+
*
|
|
229
|
+
* Return `true` to confirm, `false` to accuse — a false claim is a stronger
|
|
230
|
+
* fraud signal than silence, so it lands in `suspected_bot` alongside an
|
|
231
|
+
* invalid signature. Return `null`/`undefined` for "cannot check", which
|
|
232
|
+
* leaves the request merely `declared_agent`: unknown is not guilty. A throw
|
|
233
|
+
* is treated as "cannot check" too, so a DNS blip never demotes real agents.
|
|
234
|
+
*/
|
|
235
|
+
export type AgentIPVerifier = (
|
|
236
|
+
ip: string | undefined,
|
|
237
|
+
userAgent: string
|
|
238
|
+
) => boolean | null | undefined;
|
|
239
|
+
|
|
164
240
|
export interface WayleaveOptions {
|
|
165
|
-
|
|
241
|
+
/** Config object, or a resolver function for rotatable keys. */
|
|
242
|
+
directories?: KeyDirectories | KeyResolver;
|
|
243
|
+
/**
|
|
244
|
+
* Verify that a self-declared operator is on its published addresses.
|
|
245
|
+
* Without it, `declared_agent` is a claim taken at face value.
|
|
246
|
+
*/
|
|
247
|
+
verifyAgentIP?: AgentIPVerifier;
|
|
248
|
+
/**
|
|
249
|
+
* Rate-limit and replay state. Defaults to a bounded `MemoryStore`, which
|
|
250
|
+
* is per-process: two instances mean two independent limiters. Supply a
|
|
251
|
+
* shared implementation to fix that without touching anything else.
|
|
252
|
+
*/
|
|
253
|
+
store?: Store;
|
|
254
|
+
/**
|
|
255
|
+
* Where metering events go. Defaults to a `DirectSink` wrapping `onEvent`.
|
|
256
|
+
* Replace it with a buffering, retrying sink when losing events costs money.
|
|
257
|
+
*/
|
|
258
|
+
sink?: Sink;
|
|
166
259
|
/** Per-lane allow/deny rules, evaluated in order. */
|
|
167
260
|
rules?: Partial<Record<Lane, PolicyRule[]>>;
|
|
168
261
|
/** Max requests per identity per window, per lane. */
|
|
@@ -249,7 +342,7 @@ export function signRequest(
|
|
|
249
342
|
export function verifySignature(
|
|
250
343
|
headers: Record<string, string | string[] | undefined>,
|
|
251
344
|
authority: string,
|
|
252
|
-
directories: KeyDirectories,
|
|
345
|
+
directories: KeyDirectories | KeyResolver,
|
|
253
346
|
now?: number
|
|
254
347
|
): VerifyResult;
|
|
255
348
|
|
|
@@ -259,8 +352,14 @@ export function verifySignature(
|
|
|
259
352
|
*/
|
|
260
353
|
export function classify(
|
|
261
354
|
req: WayleaveRequest,
|
|
262
|
-
directories: KeyDirectories,
|
|
263
|
-
now?: number
|
|
355
|
+
directories: KeyDirectories | KeyResolver,
|
|
356
|
+
now?: number,
|
|
357
|
+
opts?: {
|
|
358
|
+
/** Confirms a declared operator is on its published addresses. */
|
|
359
|
+
verifyAgentIP?: AgentIPVerifier;
|
|
360
|
+
/** Source address handed to `verifyAgentIP`. */
|
|
361
|
+
ip?: string;
|
|
362
|
+
}
|
|
264
363
|
): Classification;
|
|
265
364
|
|
|
266
365
|
export class Wayleave {
|
package/index.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createPublicKey, verify as edVerify, sign as edSign,
|
|
21
|
-
createHash } from 'node:crypto';
|
|
21
|
+
createHash, randomUUID } from 'node:crypto';
|
|
22
22
|
|
|
23
23
|
export const LANES = Object.freeze({
|
|
24
24
|
VERIFIED: 'verified_agent',
|
|
@@ -108,8 +108,28 @@ export function signRequest(headers, authority, privateKey, keyid, directoryUrl,
|
|
|
108
108
|
return headers;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Key lookup, as a seam rather than an object read.
|
|
113
|
+
*
|
|
114
|
+
* `directories` may be the config object it has always been, or a function
|
|
115
|
+
* `(directoryUrl) => { keyid: key }`. The function form is what makes rotation
|
|
116
|
+
* possible without touching this file: point it at a cache your app refreshes
|
|
117
|
+
* from a JWKS endpoint on a timer.
|
|
118
|
+
*
|
|
119
|
+
* It MUST be synchronous. This runs on every signed request, and awaiting a
|
|
120
|
+
* network fetch per crossing would cost far more than the crossing earns —
|
|
121
|
+
* the sub-millisecond claim in the README is a real constraint, not a boast.
|
|
122
|
+
* Fetch on a schedule, resolve from memory.
|
|
123
|
+
*/
|
|
124
|
+
function asResolver(directories) {
|
|
125
|
+
if (typeof directories === 'function') return directories;
|
|
126
|
+
const d = directories || {};
|
|
127
|
+
return (directoryUrl) => d[directoryUrl];
|
|
128
|
+
}
|
|
129
|
+
|
|
111
130
|
/**
|
|
112
131
|
* directories: { [directoryUrl]: { [keyid]: publicKey (KeyObject|PEM|raw b64) } }
|
|
132
|
+
* or (directoryUrl) => { [keyid]: publicKey }
|
|
113
133
|
* Returns { ok, agentId, reason, nonce, expires }
|
|
114
134
|
*
|
|
115
135
|
* `nonce` is set only when the signer supplied RFC 9421's `nonce` parameter,
|
|
@@ -157,7 +177,7 @@ export function verifySignature(h, authority, directories,
|
|
|
157
177
|
if (expires - created > 3600)
|
|
158
178
|
return { ok: false, agentId: null, reason: 'validity window too long (replay risk)' };
|
|
159
179
|
|
|
160
|
-
const dir = directories
|
|
180
|
+
const dir = asResolver(directories)(directory);
|
|
161
181
|
if (!dir) return { ok: false, agentId: null, reason: `unknown key directory ${directory}` };
|
|
162
182
|
let key = dir[keyid];
|
|
163
183
|
if (!key) return { ok: false, agentId: null, reason: `keyid "${keyid}" not in directory` };
|
|
@@ -209,7 +229,20 @@ const AUTOMATION_TELLS = [
|
|
|
209
229
|
['wget', 'wget UA'],
|
|
210
230
|
];
|
|
211
231
|
|
|
212
|
-
|
|
232
|
+
/**
|
|
233
|
+
* A verifier that throws must not decide the lane. Treat the failure as
|
|
234
|
+
* "could not check" rather than "is a forger" — a DNS timeout is not evidence
|
|
235
|
+
* of fraud, and demoting real agents to the fraud lane because a lookup blipped
|
|
236
|
+
* would be a self-inflicted outage on the traffic we most want to bill.
|
|
237
|
+
*/
|
|
238
|
+
function tryVerifyIP(verify, ip, ua) {
|
|
239
|
+
try {
|
|
240
|
+
const r = verify(ip, ua);
|
|
241
|
+
return r === true ? true : r === false ? false : null;
|
|
242
|
+
} catch { return null; }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function classify(req, directories, now, opts = {}) {
|
|
213
246
|
const h = req.headers, ua = (h['user-agent'] || '').toLowerCase();
|
|
214
247
|
const v = verifySignature(h, req.authority, directories, now);
|
|
215
248
|
if (v.ok) return { lane: LANES.VERIFIED, agentId: v.agentId,
|
|
@@ -218,10 +251,25 @@ export function classify(req, directories, now) {
|
|
|
218
251
|
if (h['signature'] || h['signature-input'])
|
|
219
252
|
return { lane: LANES.SUSPECT, agentId: null,
|
|
220
253
|
evidence: [`crypto: ${v.reason}`, 'presented invalid signature'] };
|
|
221
|
-
if (AGENT_UA.some(p => p.test(ua)))
|
|
254
|
+
if (AGENT_UA.some(p => p.test(ua))) {
|
|
255
|
+
// A UA naming a known operator is a claim, and claims are checkable when
|
|
256
|
+
// the operator publishes its addresses. Claiming to be GPTBot from an
|
|
257
|
+
// address OpenAI does not own is a stronger fraud signal than saying
|
|
258
|
+
// nothing at all — so it lands in the fraud lane, exactly like a
|
|
259
|
+
// signature that fails to verify. An unrecognised operator returns
|
|
260
|
+
// null/undefined and stays merely declared: unknown is not guilty.
|
|
261
|
+
const ipOk = opts.verifyAgentIP
|
|
262
|
+
? tryVerifyIP(opts.verifyAgentIP, opts.ip, ua)
|
|
263
|
+
: null;
|
|
264
|
+
if (ipOk === false)
|
|
265
|
+
return { lane: LANES.SUSPECT, agentId: null,
|
|
266
|
+
evidence: [`UA claims "${ua.slice(0, 40)}"`,
|
|
267
|
+
`but ${opts.ip || 'the client'} is not a published address for it`] };
|
|
222
268
|
return { lane: LANES.DECLARED, agentId: null,
|
|
223
269
|
evidence: [`self-identified in UA: "${ua.slice(0, 40)}"`,
|
|
224
|
-
'
|
|
270
|
+
ipOk === true ? 'source address matches published range'
|
|
271
|
+
: 'no signature presented'] };
|
|
272
|
+
}
|
|
225
273
|
for (const [tell, why] of AUTOMATION_TELLS)
|
|
226
274
|
if (ua.includes(tell))
|
|
227
275
|
return { lane: LANES.SUSPECT, agentId: null, evidence: [why] };
|
|
@@ -232,6 +280,77 @@ export function classify(req, directories, now) {
|
|
|
232
280
|
return { lane: LANES.HUMAN, agentId: null, evidence: ['browser-shaped request'] };
|
|
233
281
|
}
|
|
234
282
|
|
|
283
|
+
// ── state ───────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Rate-limit counters and consumed nonces, in this process's memory.
|
|
287
|
+
*
|
|
288
|
+
* This is the seam that a shared backend replaces. Implement the same four
|
|
289
|
+
* methods against Redis or Postgres and pass it as `store` — the gate does not
|
|
290
|
+
* know the difference. Two properties any replacement must preserve:
|
|
291
|
+
*
|
|
292
|
+
* Synchronous. Called on every request. An awaited round trip per crossing
|
|
293
|
+
* is a worse tax than the crossing is worth; a distributed store belongs
|
|
294
|
+
* behind a local view that replicates asynchronously.
|
|
295
|
+
*
|
|
296
|
+
* Bounded. `maxTracked` exists because an attacker who can mint identities
|
|
297
|
+
* can otherwise mint memory. Evicting the oldest keeps recent arrivals
|
|
298
|
+
* counted, which is the useful half.
|
|
299
|
+
*
|
|
300
|
+
* A distributed store trades exactness for reach, and the two uses differ in
|
|
301
|
+
* how much they mind. Fixed-window rate limiting tolerates approximate counts.
|
|
302
|
+
* Replay does NOT: a nonce that has not replicated yet is a nonce that can be
|
|
303
|
+
* spent twice. Say which you chose in your deployment notes.
|
|
304
|
+
*/
|
|
305
|
+
export class MemoryStore {
|
|
306
|
+
constructor({ maxTracked = 10000 } = {}) {
|
|
307
|
+
this.maxTracked = maxTracked;
|
|
308
|
+
this._hits = new Map();
|
|
309
|
+
this._window = -1;
|
|
310
|
+
this._seen = new Map();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Count this identity's requests in the current fixed window. */
|
|
314
|
+
hit(identity, windowSeconds, now) {
|
|
315
|
+
const w = Math.floor(now / windowSeconds);
|
|
316
|
+
if (w !== this._window) {
|
|
317
|
+
this._window = w;
|
|
318
|
+
this._hits.clear();
|
|
319
|
+
for (const [n, exp] of this._seen) if (exp <= now) this._seen.delete(n);
|
|
320
|
+
}
|
|
321
|
+
if (this._hits.size >= this.maxTracked && !this._hits.has(identity))
|
|
322
|
+
this._hits.delete(this._hits.keys().next().value);
|
|
323
|
+
const n = (this._hits.get(identity) || 0) + 1;
|
|
324
|
+
this._hits.set(identity, n);
|
|
325
|
+
return n;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
hasNonce(nonce) { return this._seen.has(nonce); }
|
|
329
|
+
|
|
330
|
+
rememberNonce(nonce, expiresAt) {
|
|
331
|
+
if (this._seen.size >= this.maxTracked)
|
|
332
|
+
this._seen.delete(this._seen.keys().next().value);
|
|
333
|
+
this._seen.set(nonce, expiresAt);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Where metering events go. The default hands each one straight to `onEvent`
|
|
339
|
+
* and swallows anything it throws, because serving must not depend on billing.
|
|
340
|
+
*
|
|
341
|
+
* That swallow is correct for uptime and lossy for revenue, which is the
|
|
342
|
+
* trade a durable sink is meant to change: buffer locally, flush in batches,
|
|
343
|
+
* retry on failure, and dedup on `idempotencyKey` at the far end. Implement
|
|
344
|
+
* `emit(event)` and pass it as `sink`. Whatever you build, it must not throw
|
|
345
|
+
* into the request path — this class is the last line that guarantees that.
|
|
346
|
+
*/
|
|
347
|
+
export class DirectSink {
|
|
348
|
+
constructor(onEvent) { this.onEvent = onEvent || (() => {}); }
|
|
349
|
+
emit(event) {
|
|
350
|
+
try { this.onEvent(event); } catch { /* metering must never break serving */ }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
235
354
|
// ── the gate ────────────────────────────────────────────────────────────
|
|
236
355
|
|
|
237
356
|
export class Wayleave {
|
|
@@ -281,23 +400,40 @@ export class Wayleave {
|
|
|
281
400
|
this.replayProtection = opts.replayProtection !== false;
|
|
282
401
|
this.maxTracked = opts.maxTracked || 10000;
|
|
283
402
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
403
|
+
// Verifies that a UA claiming to be a known operator comes from that
|
|
404
|
+
// operator's published addresses. Absent, a declared agent is taken at
|
|
405
|
+
// its word — which is all "declared" ever meant.
|
|
406
|
+
this.verifyAgentIP = opts.verifyAgentIP || null;
|
|
407
|
+
|
|
408
|
+
// State and metering are interfaces, not implementations. Swap either for
|
|
409
|
+
// a shared or durable one without the gate noticing. See MemoryStore.
|
|
410
|
+
this.store = opts.store || new MemoryStore({ maxTracked: this.maxTracked });
|
|
411
|
+
this.sink = opts.sink || new DirectSink(this.onEvent);
|
|
412
|
+
|
|
413
|
+
// Identifies events from this process so a retrying sink can dedup. A
|
|
414
|
+
// counter alone would collide across instances; a timestamp alone would
|
|
415
|
+
// collide within a millisecond.
|
|
416
|
+
this._instance = randomUUID();
|
|
417
|
+
this._seq = 0;
|
|
287
418
|
}
|
|
288
419
|
|
|
289
420
|
/** req: { method, path, authority, headers, ip } → decision */
|
|
290
421
|
handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
|
|
291
|
-
const c = classify(req, this.directories, now
|
|
422
|
+
const c = classify(req, this.directories, now,
|
|
423
|
+
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(req) });
|
|
292
424
|
const { lane, agentId, evidence } = c;
|
|
293
425
|
const ident = agentId || `${lane}:${this._client(req)}`;
|
|
294
426
|
const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
|
|
295
427
|
const d = this._decide(req, lane, ident, now, proof, c);
|
|
296
|
-
|
|
428
|
+
// v is the event schema version. A meter that outlives one release has to
|
|
429
|
+
// know which shape it is reading; adding this after receipts exist in the
|
|
430
|
+
// wild would mean reconciling two formats forever.
|
|
431
|
+
const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
|
|
432
|
+
t: now, path: req.path, lane, identity: ident,
|
|
297
433
|
status: d.status, why: d.why, evidence,
|
|
298
434
|
billedUsd: d.billed || 0 };
|
|
299
435
|
if (d.paymentRef) entry.paymentRef = d.paymentRef;
|
|
300
|
-
|
|
436
|
+
this.sink.emit(entry);
|
|
301
437
|
return { lane, identity: ident, ...d, evidence };
|
|
302
438
|
}
|
|
303
439
|
|
|
@@ -329,26 +465,9 @@ export class Wayleave {
|
|
|
329
465
|
catch { return false; }
|
|
330
466
|
}
|
|
331
467
|
|
|
332
|
-
/**
|
|
333
|
-
* Fixed-window counting. On rollover the whole table is dropped — last
|
|
334
|
-
* window's counts are meaningless anyway, and it keeps memory bounded to
|
|
335
|
-
* one window's worth of identities instead of growing forever.
|
|
336
|
-
*/
|
|
468
|
+
/** Fixed-window counting, delegated to whichever store is configured. */
|
|
337
469
|
_bump(ident, now) {
|
|
338
|
-
|
|
339
|
-
if (w !== this._window) {
|
|
340
|
-
this._window = w;
|
|
341
|
-
this._hits.clear();
|
|
342
|
-
for (const [n, exp] of this._seen) if (exp <= now) this._seen.delete(n);
|
|
343
|
-
}
|
|
344
|
-
// Hard ceiling so a flood of distinct identities inside a single window
|
|
345
|
-
// cannot exhaust memory. Evicting the oldest keeps the newest arrivals
|
|
346
|
-
// counted; the durable fix is shared state, not a bigger Map.
|
|
347
|
-
if (this._hits.size >= this.maxTracked && !this._hits.has(ident))
|
|
348
|
-
this._hits.delete(this._hits.keys().next().value);
|
|
349
|
-
const n = (this._hits.get(ident) || 0) + 1;
|
|
350
|
-
this._hits.set(ident, n);
|
|
351
|
-
return n;
|
|
470
|
+
return this.store.hit(ident, this.rateWindow, now);
|
|
352
471
|
}
|
|
353
472
|
|
|
354
473
|
_decide(req, lane, ident, now, paymentProof, c) {
|
|
@@ -363,11 +482,9 @@ export class Wayleave {
|
|
|
363
482
|
// Only signers that send a nonce can be held to single use; see
|
|
364
483
|
// verifySignature for why a signature hash is not a safe substitute.
|
|
365
484
|
if (this.replayProtection && c && c.nonce) {
|
|
366
|
-
if (this.
|
|
485
|
+
if (this.store.hasNonce(c.nonce))
|
|
367
486
|
return { status: 403, why: 'signature already used (replay)' };
|
|
368
|
-
|
|
369
|
-
this._seen.delete(this._seen.keys().next().value);
|
|
370
|
-
this._seen.set(c.nonce, c.sigExpires || now + this.rateWindow);
|
|
487
|
+
this.store.rememberNonce(c.nonce, c.sigExpires || now + this.rateWindow);
|
|
371
488
|
}
|
|
372
489
|
const limit = this.rateLimits[lane];
|
|
373
490
|
if (limit) {
|
package/meter.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { MeterEvent, Sink } from './index.js';
|
|
2
|
+
|
|
3
|
+
export interface MeterSinkOptions {
|
|
4
|
+
/** Base URL of your meter, e.g. "https://meter.wayleave.dev". */
|
|
5
|
+
endpoint: string;
|
|
6
|
+
/** API key issued by the meter. Sent as a bearer token. */
|
|
7
|
+
apiKey: string;
|
|
8
|
+
/** Drain interval in ms. Default 5000. */
|
|
9
|
+
flushMs?: number;
|
|
10
|
+
/** Events per request. Default 500; the meter caps batches at 1000. */
|
|
11
|
+
maxBatch?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Hard ceiling on buffered events. Default 10000. Past this the oldest are
|
|
14
|
+
* shed — counted in `stats.dropped` and reported through `onError`, never
|
|
15
|
+
* dropped silently.
|
|
16
|
+
*/
|
|
17
|
+
maxBuffer?: number;
|
|
18
|
+
/** Retries per batch, exponential backoff with jitter. Default 5. */
|
|
19
|
+
maxRetries?: number;
|
|
20
|
+
/** Per-request timeout in ms. Default 10000. */
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Observability hook for drops, permanent rejections, and unreachable
|
|
24
|
+
* meters. Anything it throws is swallowed — a broken error handler must not
|
|
25
|
+
* reach the request path either.
|
|
26
|
+
*/
|
|
27
|
+
onError?: (err: Error, info: Record<string, unknown>) => void;
|
|
28
|
+
/** Injectable for tests. Defaults to global fetch. */
|
|
29
|
+
fetch?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MeterSinkStats {
|
|
33
|
+
queued: number;
|
|
34
|
+
sent: number;
|
|
35
|
+
/** Shed from a full buffer, or permanently rejected by the meter. */
|
|
36
|
+
dropped: number;
|
|
37
|
+
failedBatches: number;
|
|
38
|
+
/** Reported by the meter as already-seen. Retries are supposed to produce these. */
|
|
39
|
+
duplicates: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A `Sink` that buffers, batches, and retries against a wayleave meter.
|
|
44
|
+
*
|
|
45
|
+
* Retrying is safe because every event carries an `idempotencyKey` and the
|
|
46
|
+
* meter deduplicates on it: sending a batch twice costs a round trip, not a
|
|
47
|
+
* double-billed customer.
|
|
48
|
+
*
|
|
49
|
+
* ```js
|
|
50
|
+
* import Wayleave from 'wayleave';
|
|
51
|
+
* import { MeterSink } from 'wayleave/meter';
|
|
52
|
+
*
|
|
53
|
+
* const sink = new MeterSink({
|
|
54
|
+
* endpoint: 'https://meter.wayleave.dev',
|
|
55
|
+
* apiKey: process.env.WAYLEAVE_METER_KEY,
|
|
56
|
+
* onError: (err, info) => log.warn({ err, info }, 'meter'),
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* const gate = new Wayleave({ pricedPaths: { '/api/premium': 0.05 }, sink });
|
|
60
|
+
* process.on('SIGTERM', () => sink.close());
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export class MeterSink implements Sink {
|
|
64
|
+
constructor(opts: MeterSinkOptions);
|
|
65
|
+
readonly stats: MeterSinkStats;
|
|
66
|
+
/** Synchronous, non-throwing, no I/O. Safe on the request path. */
|
|
67
|
+
emit(event: MeterEvent): void;
|
|
68
|
+
/** Drain the buffer. Never rejects; concurrent calls collapse into one. */
|
|
69
|
+
flush(): Promise<void>;
|
|
70
|
+
/** Stop accepting events and make a final drain. Call on SIGTERM. */
|
|
71
|
+
close(): Promise<MeterSinkStats>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default MeterSink;
|
package/meter.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeterSink — ships metering events to a wayleave meter.
|
|
3
|
+
*
|
|
4
|
+
* This is the concrete `sink` that 0.2.0's interface was shaped for. The
|
|
5
|
+
* default DirectSink hands each event straight to `onEvent` and swallows what
|
|
6
|
+
* it throws: correct for uptime, lossy for revenue. This one buffers, batches,
|
|
7
|
+
* and retries, so a meter that is briefly unreachable costs you nothing.
|
|
8
|
+
*
|
|
9
|
+
* Three rules govern every line in this file, in this order:
|
|
10
|
+
*
|
|
11
|
+
* 1. NEVER throw into the request path. A billing backend having a bad day
|
|
12
|
+
* must not become your customers having a bad day. `emit` cannot throw,
|
|
13
|
+
* cannot await, and cannot block.
|
|
14
|
+
*
|
|
15
|
+
* 2. NEVER lose an event silently. Dropping is sometimes unavoidable — a
|
|
16
|
+
* buffer that grows forever is an outage, not a feature — but a drop is
|
|
17
|
+
* always counted and always reported.
|
|
18
|
+
*
|
|
19
|
+
* 3. Retry freely. Every event carries an `idempotencyKey`, and the meter
|
|
20
|
+
* deduplicates on it. Sending the same batch twice costs a round trip,
|
|
21
|
+
* not a double-billed customer. This is the entire reason that field was
|
|
22
|
+
* added before there was anything to receive it.
|
|
23
|
+
*
|
|
24
|
+
* Zero dependencies: `fetch` is global from Node 18.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const DEFAULTS = {
|
|
28
|
+
flushMs: 5000,
|
|
29
|
+
maxBatch: 500,
|
|
30
|
+
maxBuffer: 10000,
|
|
31
|
+
maxRetries: 5,
|
|
32
|
+
timeoutMs: 10000,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class MeterSink {
|
|
36
|
+
/**
|
|
37
|
+
* opts = {
|
|
38
|
+
* endpoint: 'https://meter.wayleave.dev', // required
|
|
39
|
+
* apiKey: 'wm_live_...', // required
|
|
40
|
+
* flushMs: 5000, // how often to drain the buffer
|
|
41
|
+
* maxBatch: 500, // events per request; the meter caps at 1000
|
|
42
|
+
* maxBuffer: 10000, // hard ceiling before shedding
|
|
43
|
+
* maxRetries: 5, // per batch, exponential backoff
|
|
44
|
+
* timeoutMs: 10000,
|
|
45
|
+
* onError: (err, info) => {} // observability hook; never called with
|
|
46
|
+
* // anything that should crash your process
|
|
47
|
+
* }
|
|
48
|
+
*/
|
|
49
|
+
constructor(opts = {}) {
|
|
50
|
+
if (!opts.endpoint) throw new Error('MeterSink requires an endpoint');
|
|
51
|
+
if (!opts.apiKey) throw new Error('MeterSink requires an apiKey');
|
|
52
|
+
|
|
53
|
+
this.endpoint = String(opts.endpoint).replace(/\/$/, '');
|
|
54
|
+
this.apiKey = opts.apiKey;
|
|
55
|
+
this.flushMs = opts.flushMs ?? DEFAULTS.flushMs;
|
|
56
|
+
this.maxBatch = opts.maxBatch ?? DEFAULTS.maxBatch;
|
|
57
|
+
this.maxBuffer = opts.maxBuffer ?? DEFAULTS.maxBuffer;
|
|
58
|
+
this.maxRetries = opts.maxRetries ?? DEFAULTS.maxRetries;
|
|
59
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULTS.timeoutMs;
|
|
60
|
+
this.onError = typeof opts.onError === 'function' ? opts.onError : () => {};
|
|
61
|
+
this._fetch = opts.fetch || globalThis.fetch;
|
|
62
|
+
|
|
63
|
+
this._buffer = [];
|
|
64
|
+
this._inFlight = false;
|
|
65
|
+
this._closed = false;
|
|
66
|
+
|
|
67
|
+
/** Counters worth exposing: a silent drop is a billing hole. */
|
|
68
|
+
this.stats = { queued: 0, sent: 0, dropped: 0, failedBatches: 0, duplicates: 0 };
|
|
69
|
+
|
|
70
|
+
this._timer = setInterval(() => { this.flush(); }, this.flushMs);
|
|
71
|
+
// Do not hold the process open just to run a billing timer.
|
|
72
|
+
if (typeof this._timer.unref === 'function') this._timer.unref();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Called by the gate, once per request, on the hot path.
|
|
77
|
+
*
|
|
78
|
+
* Synchronous, non-throwing, and does no I/O. The only work here is a push
|
|
79
|
+
* and possibly scheduling a flush on a later tick.
|
|
80
|
+
*/
|
|
81
|
+
emit(event) {
|
|
82
|
+
if (this._closed) return;
|
|
83
|
+
try {
|
|
84
|
+
if (this._buffer.length >= this.maxBuffer) {
|
|
85
|
+
// Shed the oldest. They have been failing longest and are the most
|
|
86
|
+
// likely to be stale; the newest are the most likely to succeed on
|
|
87
|
+
// the next attempt. Either choice loses billing data, which is why
|
|
88
|
+
// this is counted and surfaced rather than quietly absorbed.
|
|
89
|
+
const shed = this._buffer.shift();
|
|
90
|
+
this.stats.dropped++;
|
|
91
|
+
this.onError(new Error('meter buffer full, dropped oldest event'), {
|
|
92
|
+
dropped: this.stats.dropped, idempotencyKey: shed?.idempotencyKey,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
this._buffer.push(event);
|
|
96
|
+
this.stats.queued++;
|
|
97
|
+
|
|
98
|
+
// Schedule rather than await: emit must return to the request handler
|
|
99
|
+
// immediately.
|
|
100
|
+
if (this._buffer.length >= this.maxBatch && !this._inFlight)
|
|
101
|
+
queueMicrotask(() => this.flush());
|
|
102
|
+
} catch (err) {
|
|
103
|
+
// Even the buffering path must not throw into serving.
|
|
104
|
+
try { this.onError(err, { phase: 'emit' }); } catch { /* nothing left to do */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Drain the buffer. Safe to call at any time; concurrent calls collapse
|
|
110
|
+
* into the one already running.
|
|
111
|
+
*
|
|
112
|
+
* Never rejects. Failures are reported through `onError` and the events go
|
|
113
|
+
* back on the buffer for the next attempt.
|
|
114
|
+
*/
|
|
115
|
+
async flush() {
|
|
116
|
+
if (this._inFlight || this._buffer.length === 0) return;
|
|
117
|
+
this._inFlight = true;
|
|
118
|
+
try {
|
|
119
|
+
while (this._buffer.length > 0) {
|
|
120
|
+
const batch = this._buffer.splice(0, this.maxBatch);
|
|
121
|
+
const ok = await this._sendWithRetry(batch);
|
|
122
|
+
if (!ok) {
|
|
123
|
+
// Put them back at the FRONT so ordering is preserved, and stop —
|
|
124
|
+
// hammering a meter that is down helps nobody.
|
|
125
|
+
this._buffer.unshift(...batch.slice(0, Math.max(0, this.maxBuffer - this._buffer.length)));
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
try { this.onError(err, { phase: 'flush' }); } catch { /* ignore */ }
|
|
131
|
+
} finally {
|
|
132
|
+
this._inFlight = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async _sendWithRetry(batch) {
|
|
137
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
138
|
+
const r = await this._send(batch);
|
|
139
|
+
|
|
140
|
+
if (r.ok) {
|
|
141
|
+
this.stats.sent += r.accepted ?? batch.length;
|
|
142
|
+
this.stats.duplicates += r.duplicates ?? 0;
|
|
143
|
+
if (r.rejected?.length) {
|
|
144
|
+
// The meter refused specific events permanently — a schema mismatch,
|
|
145
|
+
// usually. Retrying cannot fix them and would wedge everything
|
|
146
|
+
// queued behind. Report and move on.
|
|
147
|
+
this.onError(new Error(`meter rejected ${r.rejected.length} event(s)`), {
|
|
148
|
+
phase: 'send', rejected: r.rejected,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 4xx other than 429 is our bug, not a blip. Retrying a malformed or
|
|
155
|
+
// unauthorised request forever is how a buffer becomes a memory leak.
|
|
156
|
+
if (r.status && r.status >= 400 && r.status < 500 && r.status !== 429) {
|
|
157
|
+
this.stats.failedBatches++;
|
|
158
|
+
this.stats.dropped += batch.length;
|
|
159
|
+
this.onError(new Error(`meter rejected batch: ${r.status} ${r.error ?? ''}`.trim()), {
|
|
160
|
+
phase: 'send', status: r.status, dropped: batch.length, permanent: true,
|
|
161
|
+
});
|
|
162
|
+
return true; // consumed: do not requeue something that can never succeed
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (attempt < this.maxRetries) {
|
|
166
|
+
// Exponential backoff with jitter, so a fleet of instances coming back
|
|
167
|
+
// after an outage does not stampede the meter in lockstep.
|
|
168
|
+
const base = Math.min(30000, 500 * 2 ** attempt);
|
|
169
|
+
await sleep(base / 2 + Math.random() * base / 2);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this.stats.failedBatches++;
|
|
174
|
+
this.onError(new Error('meter unreachable after retries; events requeued'), {
|
|
175
|
+
phase: 'send', batch: batch.length,
|
|
176
|
+
});
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async _send(batch) {
|
|
181
|
+
const ctrl = new AbortController();
|
|
182
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
183
|
+
try {
|
|
184
|
+
const res = await this._fetch(`${this.endpoint}/v1/events`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: {
|
|
187
|
+
'content-type': 'application/json',
|
|
188
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
189
|
+
},
|
|
190
|
+
body: JSON.stringify({ events: batch }),
|
|
191
|
+
signal: ctrl.signal,
|
|
192
|
+
});
|
|
193
|
+
let body = null;
|
|
194
|
+
try { body = await res.json(); } catch { /* non-JSON body stays null */ }
|
|
195
|
+
if (!res.ok) return { ok: false, status: res.status, error: body?.error };
|
|
196
|
+
return { ok: true, ...body };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
return { ok: false, status: 0, error: err.message };
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Stop accepting events and make a final attempt to drain.
|
|
206
|
+
*
|
|
207
|
+
* Call this on SIGTERM. Without it, whatever is buffered at shutdown is
|
|
208
|
+
* revenue you never billed for. With it, a rolling deploy costs nothing.
|
|
209
|
+
*/
|
|
210
|
+
async close() {
|
|
211
|
+
this._closed = true;
|
|
212
|
+
clearInterval(this._timer);
|
|
213
|
+
await this.flush();
|
|
214
|
+
return this.stats;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Deliberately NOT unref'd, unlike the flush interval.
|
|
220
|
+
*
|
|
221
|
+
* The interval is a heartbeat and must not hold a process open. A backoff is
|
|
222
|
+
* an operation already in flight: unref'ing it lets the event loop drain
|
|
223
|
+
* mid-retry, which abandons events that were about to be delivered — during
|
|
224
|
+
* exactly the shutdown where `close()` is trying to save them.
|
|
225
|
+
*/
|
|
226
|
+
function sleep(ms) {
|
|
227
|
+
return new Promise(r => setTimeout(r, ms));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default MeterSink;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wayleave",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Charge AI agents for passage across your app. Verify agent signatures (Web Bot Auth / Ed25519), classify every request, and return 402 with a price on routes you choose. Humans browse free.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -9,16 +9,22 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./index.d.ts",
|
|
11
11
|
"default": "./index.js"
|
|
12
|
+
},
|
|
13
|
+
"./meter": {
|
|
14
|
+
"types": "./meter.d.ts",
|
|
15
|
+
"default": "./meter.js"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"files": [
|
|
15
|
-
"
|
|
16
|
-
"index.d.ts",
|
|
19
|
+
"LICENSE",
|
|
17
20
|
"README.md",
|
|
18
|
-
"
|
|
21
|
+
"index.d.ts",
|
|
22
|
+
"index.js",
|
|
23
|
+
"meter.d.ts",
|
|
24
|
+
"meter.js"
|
|
19
25
|
],
|
|
20
26
|
"scripts": {
|
|
21
|
-
"test": "node --test"
|
|
27
|
+
"test": "node --test *.test.js"
|
|
22
28
|
},
|
|
23
29
|
"engines": {
|
|
24
30
|
"node": ">=18"
|