wayleave 0.2.0 → 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 +24 -2
- package/meter.d.ts +74 -0
- package/meter.js +230 -0
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -59,13 +59,13 @@ app.use(gate.express());
|
|
|
59
59
|
|
|
60
60
|
## Status
|
|
61
61
|
|
|
62
|
-
v0.2.
|
|
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.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).
|
|
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
69
|
|
|
70
70
|
## How payment actually works
|
|
71
71
|
|
|
@@ -159,6 +159,28 @@ new Wayleave({
|
|
|
159
159
|
Every metering event carries `v` (schema version) and `idempotencyKey`, so a
|
|
160
160
|
sink that retries can be deduplicated at the far end rather than double-billing.
|
|
161
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
|
+
|
|
162
184
|
`verifyAgentIP` is the one that changes a lane. A `user-agent` naming a known
|
|
163
185
|
operator is a claim; when that operator publishes its ranges the claim is
|
|
164
186
|
checkable. Returning `false` moves the request to `suspected_bot` — a false
|
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.2.
|
|
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"
|