@ordspv/gateway 0.1.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/LICENSE +15 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +468 -0
- package/dist/index.js.map +1 -0
- package/dist/lru.d.ts +30 -0
- package/dist/metrics.d.ts +33 -0
- package/dist/ratelimit.d.ts +19 -0
- package/package.json +23 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the ordspv contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Server } from 'node:http';
|
|
2
|
+
import { type FetchFn } from '@ordspv/fetch';
|
|
3
|
+
export { ByteLru } from './lru.js';
|
|
4
|
+
export { Counter, Histogram, Registry } from './metrics.js';
|
|
5
|
+
export { TokenBucketLimiter } from './ratelimit.js';
|
|
6
|
+
/**
|
|
7
|
+
* Reference ord gateway (SPEC-GATEWAY.md).
|
|
8
|
+
*
|
|
9
|
+
* Personalities:
|
|
10
|
+
* - proxy : replicate an upstream ord server's /content and /r/* surface
|
|
11
|
+
* with ord-parity headers. Availability play only; adds no trust.
|
|
12
|
+
* - verify : serve /content only after locally verifying the bytes against
|
|
13
|
+
* Bitcoin (L2 by default) via esplora backends.
|
|
14
|
+
*
|
|
15
|
+
* Both serve: GET /ord/v1/proof/<id>?level=l2|l3, GET /ord/v1/verified/<id>,
|
|
16
|
+
* GET /healthz, GET /metrics (prometheus text)
|
|
17
|
+
*
|
|
18
|
+
* Operational features (SPEC-GATEWAY §7): bounded byte-budget LRU on
|
|
19
|
+
* immutable 200s (x-cache header), per-IP token-bucket rate limiting,
|
|
20
|
+
* streaming proxy for oversized bodies, structured JSON request logs,
|
|
21
|
+
* graceful shutdown. Verified responses are buffered, because a merkle proof
|
|
22
|
+
* cannot be verified over bytes that have not all been read.
|
|
23
|
+
*/
|
|
24
|
+
export interface GatewayOptions {
|
|
25
|
+
port?: number;
|
|
26
|
+
upstream?: string;
|
|
27
|
+
esplora?: string[];
|
|
28
|
+
mode?: 'proxy' | 'verify';
|
|
29
|
+
verification?: 'L2' | 'L3';
|
|
30
|
+
fetchFn?: FetchFn;
|
|
31
|
+
/** LRU budget across cached bodies (default 256 MiB; 0 disables) */
|
|
32
|
+
cacheMaxBytes?: number;
|
|
33
|
+
/** largest single cacheable body (default 8 MiB) */
|
|
34
|
+
cacheMaxEntryBytes?: number;
|
|
35
|
+
/** sustained requests/second per IP (default 10; 0 disables) */
|
|
36
|
+
rateLimitPerSec?: number;
|
|
37
|
+
/** burst size per IP (default 40) */
|
|
38
|
+
rateBurst?: number;
|
|
39
|
+
/** trust the first X-Forwarded-For hop for the client IP (behind a LB/CDN) */
|
|
40
|
+
trustProxy?: boolean;
|
|
41
|
+
/** structured log sink; false silences (default). CLI wires console.log. */
|
|
42
|
+
log?: ((line: Record<string, unknown>) => void) | false;
|
|
43
|
+
}
|
|
44
|
+
/** low-cardinality route label for metrics/logs */
|
|
45
|
+
export declare function routeLabel(path: string): string;
|
|
46
|
+
export declare function createGateway(options?: GatewayOptions): Server;
|
|
47
|
+
/** graceful shutdown: stop accepting, drain in-flight, force-close after grace */
|
|
48
|
+
export declare function installShutdown(server: Server, graceMs?: number, log?: (l: Record<string, unknown>) => void): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// packages/gateway/src/index.ts
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
import { Readable } from "stream";
|
|
4
|
+
import { isInscriptionId, parseInscriptionId } from "@ordspv/core";
|
|
5
|
+
import {
|
|
6
|
+
buildProofBundle,
|
|
7
|
+
EsploraBackend,
|
|
8
|
+
OrdResolver,
|
|
9
|
+
toResponse
|
|
10
|
+
} from "@ordspv/fetch";
|
|
11
|
+
|
|
12
|
+
// packages/gateway/src/lru.ts
|
|
13
|
+
var ByteLru = class {
|
|
14
|
+
constructor(maxBytes, maxEntryBytes) {
|
|
15
|
+
this.maxBytes = maxBytes;
|
|
16
|
+
this.maxEntryBytes = maxEntryBytes;
|
|
17
|
+
}
|
|
18
|
+
maxBytes;
|
|
19
|
+
maxEntryBytes;
|
|
20
|
+
entries = /* @__PURE__ */ new Map();
|
|
21
|
+
bytes = 0;
|
|
22
|
+
hits = 0;
|
|
23
|
+
misses = 0;
|
|
24
|
+
get(key) {
|
|
25
|
+
const hit = this.entries.get(key);
|
|
26
|
+
if (!hit) {
|
|
27
|
+
this.misses++;
|
|
28
|
+
return void 0;
|
|
29
|
+
}
|
|
30
|
+
this.entries.delete(key);
|
|
31
|
+
this.entries.set(key, hit);
|
|
32
|
+
this.hits++;
|
|
33
|
+
return hit;
|
|
34
|
+
}
|
|
35
|
+
set(key, value) {
|
|
36
|
+
if (value.body.length > this.maxEntryBytes) return;
|
|
37
|
+
const existing = this.entries.get(key);
|
|
38
|
+
if (existing) {
|
|
39
|
+
this.bytes -= existing.body.length;
|
|
40
|
+
this.entries.delete(key);
|
|
41
|
+
}
|
|
42
|
+
this.entries.set(key, value);
|
|
43
|
+
this.bytes += value.body.length;
|
|
44
|
+
for (const [oldest, entry] of this.entries) {
|
|
45
|
+
if (this.bytes <= this.maxBytes) break;
|
|
46
|
+
if (oldest === key && this.entries.size === 1) break;
|
|
47
|
+
this.entries.delete(oldest);
|
|
48
|
+
this.bytes -= entry.body.length;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
get size() {
|
|
52
|
+
return this.entries.size;
|
|
53
|
+
}
|
|
54
|
+
get usedBytes() {
|
|
55
|
+
return this.bytes;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// packages/gateway/src/metrics.ts
|
|
60
|
+
function labelKey(labels) {
|
|
61
|
+
return Object.keys(labels).sort().map((k) => `${k}=${JSON.stringify(labels[k])}`).join(",");
|
|
62
|
+
}
|
|
63
|
+
function renderLabels(labels, extra) {
|
|
64
|
+
const parts = Object.keys(labels).sort().map((k) => `${k}=${JSON.stringify(labels[k])}`);
|
|
65
|
+
if (extra) parts.push(extra);
|
|
66
|
+
return parts.length ? `{${parts.join(",")}}` : "";
|
|
67
|
+
}
|
|
68
|
+
var Counter = class {
|
|
69
|
+
constructor(name, help) {
|
|
70
|
+
this.name = name;
|
|
71
|
+
this.help = help;
|
|
72
|
+
}
|
|
73
|
+
name;
|
|
74
|
+
help;
|
|
75
|
+
series = /* @__PURE__ */ new Map();
|
|
76
|
+
inc(labels = {}, by = 1) {
|
|
77
|
+
const key = labelKey(labels);
|
|
78
|
+
const entry = this.series.get(key) ?? { labels, value: 0 };
|
|
79
|
+
entry.value += by;
|
|
80
|
+
this.series.set(key, entry);
|
|
81
|
+
}
|
|
82
|
+
render() {
|
|
83
|
+
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} counter`];
|
|
84
|
+
if (this.series.size === 0) lines.push(`${this.name} 0`);
|
|
85
|
+
for (const { labels, value } of this.series.values()) {
|
|
86
|
+
lines.push(`${this.name}${renderLabels(labels)} ${value}`);
|
|
87
|
+
}
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
var Histogram = class {
|
|
92
|
+
constructor(name, help, bounds = [5e-3, 0.025, 0.1, 0.25, 1, 2.5, 10, 30]) {
|
|
93
|
+
this.name = name;
|
|
94
|
+
this.help = help;
|
|
95
|
+
this.bounds = bounds;
|
|
96
|
+
}
|
|
97
|
+
name;
|
|
98
|
+
help;
|
|
99
|
+
bounds;
|
|
100
|
+
series = /* @__PURE__ */ new Map();
|
|
101
|
+
observe(labels, value) {
|
|
102
|
+
const key = labelKey(labels);
|
|
103
|
+
let entry = this.series.get(key);
|
|
104
|
+
if (!entry) {
|
|
105
|
+
entry = { labels, buckets: this.bounds.map(() => 0), sum: 0, count: 0 };
|
|
106
|
+
this.series.set(key, entry);
|
|
107
|
+
}
|
|
108
|
+
for (let i = 0; i < this.bounds.length; i++) {
|
|
109
|
+
if (value <= this.bounds[i]) entry.buckets[i]++;
|
|
110
|
+
}
|
|
111
|
+
entry.sum += value;
|
|
112
|
+
entry.count++;
|
|
113
|
+
}
|
|
114
|
+
render() {
|
|
115
|
+
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} histogram`];
|
|
116
|
+
for (const { labels, buckets, sum, count } of this.series.values()) {
|
|
117
|
+
this.bounds.forEach((bound, i) => {
|
|
118
|
+
lines.push(`${this.name}_bucket${renderLabels(labels, `le="${bound}"`)} ${buckets[i]}`);
|
|
119
|
+
});
|
|
120
|
+
lines.push(`${this.name}_bucket${renderLabels(labels, 'le="+Inf"')} ${count}`);
|
|
121
|
+
lines.push(`${this.name}_sum${renderLabels(labels)} ${sum}`);
|
|
122
|
+
lines.push(`${this.name}_count${renderLabels(labels)} ${count}`);
|
|
123
|
+
}
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
var Registry = class {
|
|
128
|
+
metrics = [];
|
|
129
|
+
gauges = [];
|
|
130
|
+
counter(name, help) {
|
|
131
|
+
const c = new Counter(name, help);
|
|
132
|
+
this.metrics.push(c);
|
|
133
|
+
return c;
|
|
134
|
+
}
|
|
135
|
+
histogram(name, help, bounds) {
|
|
136
|
+
const h = new Histogram(name, help, bounds);
|
|
137
|
+
this.metrics.push(h);
|
|
138
|
+
return h;
|
|
139
|
+
}
|
|
140
|
+
/** callback gauge, sampled at render time (cache bytes, tracked IPs, …) */
|
|
141
|
+
gauge(name, help, read) {
|
|
142
|
+
this.gauges.push({ name, help, read });
|
|
143
|
+
}
|
|
144
|
+
render() {
|
|
145
|
+
const parts = this.metrics.map((m) => m.render());
|
|
146
|
+
for (const g of this.gauges) {
|
|
147
|
+
parts.push(`# HELP ${g.name} ${g.help}
|
|
148
|
+
# TYPE ${g.name} gauge
|
|
149
|
+
${g.name} ${g.read()}`);
|
|
150
|
+
}
|
|
151
|
+
return `${parts.join("\n")}
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// packages/gateway/src/ratelimit.ts
|
|
157
|
+
var TokenBucketLimiter = class {
|
|
158
|
+
constructor(ratePerSec, burst, now = () => Date.now()) {
|
|
159
|
+
this.ratePerSec = ratePerSec;
|
|
160
|
+
this.burst = burst;
|
|
161
|
+
this.now = now;
|
|
162
|
+
this.lastSweep = this.now();
|
|
163
|
+
}
|
|
164
|
+
ratePerSec;
|
|
165
|
+
burst;
|
|
166
|
+
now;
|
|
167
|
+
buckets = /* @__PURE__ */ new Map();
|
|
168
|
+
lastSweep;
|
|
169
|
+
/** true = allowed (a token was taken); false = rate limited */
|
|
170
|
+
take(key) {
|
|
171
|
+
const t = this.now();
|
|
172
|
+
this.maybeSweep(t);
|
|
173
|
+
let bucket = this.buckets.get(key);
|
|
174
|
+
if (!bucket) {
|
|
175
|
+
bucket = { tokens: this.burst, lastRefill: t };
|
|
176
|
+
this.buckets.set(key, bucket);
|
|
177
|
+
} else {
|
|
178
|
+
const elapsed = (t - bucket.lastRefill) / 1e3;
|
|
179
|
+
bucket.tokens = Math.min(this.burst, bucket.tokens + elapsed * this.ratePerSec);
|
|
180
|
+
bucket.lastRefill = t;
|
|
181
|
+
}
|
|
182
|
+
if (bucket.tokens < 1) return false;
|
|
183
|
+
bucket.tokens -= 1;
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
/** seconds until one token is available (for retry-after) */
|
|
187
|
+
retryAfterSeconds(key) {
|
|
188
|
+
const bucket = this.buckets.get(key);
|
|
189
|
+
if (!bucket || bucket.tokens >= 1) return 0;
|
|
190
|
+
return Math.ceil((1 - bucket.tokens) / this.ratePerSec);
|
|
191
|
+
}
|
|
192
|
+
maybeSweep(t) {
|
|
193
|
+
if (t - this.lastSweep < 6e4) return;
|
|
194
|
+
this.lastSweep = t;
|
|
195
|
+
const idleCutoff = t - 12e4;
|
|
196
|
+
for (const [key, bucket] of this.buckets) {
|
|
197
|
+
const effective = bucket.tokens + (t - bucket.lastRefill) / 1e3 * this.ratePerSec;
|
|
198
|
+
if (bucket.lastRefill < idleCutoff && effective >= this.burst) {
|
|
199
|
+
this.buckets.delete(key);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
get trackedKeys() {
|
|
204
|
+
return this.buckets.size;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// packages/gateway/src/index.ts
|
|
209
|
+
var CONTENT_CSP = [
|
|
210
|
+
"default-src 'self' 'unsafe-eval' 'unsafe-inline' data: blob:",
|
|
211
|
+
"default-src *:*/content/ *:*/blockheight *:*/blockhash *:*/blockhash/ *:*/blocktime *:*/r/ 'unsafe-eval' 'unsafe-inline' data: blob:"
|
|
212
|
+
];
|
|
213
|
+
var IMMUTABLE = "public, max-age=1209600, immutable";
|
|
214
|
+
function send(res, status, body, headers = {}) {
|
|
215
|
+
res.writeHead(status, { "access-control-allow-origin": "*", ...headers });
|
|
216
|
+
res.end(typeof body === "string" ? body : Buffer.from(body));
|
|
217
|
+
}
|
|
218
|
+
function sendJson(res, status, value, headers = {}) {
|
|
219
|
+
send(res, status, JSON.stringify(value, null, 2), { "content-type": "application/json", ...headers });
|
|
220
|
+
}
|
|
221
|
+
function routeLabel(path) {
|
|
222
|
+
if (path === "/healthz") return "healthz";
|
|
223
|
+
if (path === "/metrics") return "metrics";
|
|
224
|
+
if (path.startsWith("/content/")) return "/content/:id";
|
|
225
|
+
if (path.startsWith("/preview/")) return "/preview/:id";
|
|
226
|
+
if (/^\/ord\/v1\/proof\//.test(path)) return "/ord/v1/proof/:id";
|
|
227
|
+
if (/^\/ord\/v1\/verified\//.test(path)) return "/ord/v1/verified/:id";
|
|
228
|
+
if (path.startsWith("/r/")) return "/r/*";
|
|
229
|
+
if (path === "/blockheight" || path === "/blocktime" || path.startsWith("/blockhash")) return "/chain";
|
|
230
|
+
return "other";
|
|
231
|
+
}
|
|
232
|
+
function createGateway(options = {}) {
|
|
233
|
+
const upstream = (options.upstream ?? "https://ordinals.com").replace(/\/+$/, "");
|
|
234
|
+
const mode = options.mode ?? "proxy";
|
|
235
|
+
const level = options.verification ?? "L2";
|
|
236
|
+
const fetchFn = options.fetchFn ?? ((u, i) => fetch(u, i));
|
|
237
|
+
const esploras = (options.esplora ?? ["https://mempool.space/api", "https://blockstream.info/api"]).map(
|
|
238
|
+
(u) => new EsploraBackend(u, fetchFn)
|
|
239
|
+
);
|
|
240
|
+
const resolver = new OrdResolver({
|
|
241
|
+
esplora: esploras.map((e) => e.baseUrl),
|
|
242
|
+
ordGateways: [upstream],
|
|
243
|
+
fetchFn,
|
|
244
|
+
verification: level
|
|
245
|
+
});
|
|
246
|
+
const cacheMaxBytes = options.cacheMaxBytes ?? 256 * 1024 * 1024;
|
|
247
|
+
const cacheMaxEntry = options.cacheMaxEntryBytes ?? 8 * 1024 * 1024;
|
|
248
|
+
const cache = new ByteLru(cacheMaxBytes, cacheMaxEntry);
|
|
249
|
+
const ratePerSec = options.rateLimitPerSec ?? 10;
|
|
250
|
+
const limiter = new TokenBucketLimiter(ratePerSec, options.rateBurst ?? 40);
|
|
251
|
+
const log = options.log || void 0;
|
|
252
|
+
const registry = new Registry();
|
|
253
|
+
const mRequests = registry.counter("gateway_http_requests_total", "HTTP requests by route/method/status");
|
|
254
|
+
const mDuration = registry.histogram("gateway_http_request_duration_seconds", "request latency");
|
|
255
|
+
const mCacheHits = registry.counter("gateway_cache_hits_total", "LRU cache hits");
|
|
256
|
+
const mCacheMisses = registry.counter("gateway_cache_misses_total", "LRU cache misses (cacheable routes only)");
|
|
257
|
+
const mRateLimited = registry.counter("gateway_rate_limited_total", "requests rejected by the per-IP token bucket");
|
|
258
|
+
const mUpstreamErrors = registry.counter("gateway_upstream_errors_total", "errors talking to upstream/esplora");
|
|
259
|
+
registry.gauge("gateway_cache_bytes", "bytes held by the LRU", () => cache.usedBytes);
|
|
260
|
+
registry.gauge("gateway_cache_entries", "entries in the LRU", () => cache.size);
|
|
261
|
+
registry.gauge("gateway_ratelimit_tracked_ips", "token buckets currently tracked", () => limiter.trackedKeys);
|
|
262
|
+
function clientIp(req) {
|
|
263
|
+
if (options.trustProxy) {
|
|
264
|
+
const xff = req.headers["x-forwarded-for"];
|
|
265
|
+
const first = (Array.isArray(xff) ? xff[0] : xff)?.split(",")[0]?.trim();
|
|
266
|
+
if (first) return first;
|
|
267
|
+
}
|
|
268
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
269
|
+
}
|
|
270
|
+
function sendCached(res, cacheKey, body, headers) {
|
|
271
|
+
if (cacheMaxBytes > 0) cache.set(cacheKey, { status: 200, headers, body });
|
|
272
|
+
send(res, 200, body, { ...headers, "x-cache": "MISS" });
|
|
273
|
+
}
|
|
274
|
+
async function proxy(req, res, path, cacheKey) {
|
|
275
|
+
const url = `${upstream}${path}`;
|
|
276
|
+
const upstreamRes = await fetchFn(url, {
|
|
277
|
+
headers: { "accept-encoding": req.headers["accept-encoding"] ?? "identity" }
|
|
278
|
+
});
|
|
279
|
+
const headers = {};
|
|
280
|
+
for (const name of ["content-type", "content-encoding", "cache-control"]) {
|
|
281
|
+
const v = upstreamRes.headers.get(name);
|
|
282
|
+
if (v) headers[name] = v;
|
|
283
|
+
}
|
|
284
|
+
if (path.startsWith("/content/")) {
|
|
285
|
+
headers["cache-control"] = IMMUTABLE;
|
|
286
|
+
}
|
|
287
|
+
const extra = path.startsWith("/content/") ? { "content-security-policy": CONTENT_CSP } : {};
|
|
288
|
+
const length = Number(upstreamRes.headers.get("content-length") ?? NaN);
|
|
289
|
+
const cacheable = upstreamRes.status === 200 && !Number.isNaN(length) && length <= cacheMaxEntry;
|
|
290
|
+
if (cacheable || !upstreamRes.body) {
|
|
291
|
+
const body = new Uint8Array(await upstreamRes.arrayBuffer());
|
|
292
|
+
if (upstreamRes.status === 200) {
|
|
293
|
+
if (cacheMaxBytes > 0) cache.set(cacheKey, { status: 200, headers: { ...headers, ...flat(extra) }, body });
|
|
294
|
+
return send(res, 200, body, { ...headers, ...extra, "x-cache": "MISS" });
|
|
295
|
+
}
|
|
296
|
+
return send(res, upstreamRes.status, body, { ...headers, ...extra });
|
|
297
|
+
}
|
|
298
|
+
res.writeHead(upstreamRes.status, { "access-control-allow-origin": "*", ...headers, ...extra });
|
|
299
|
+
Readable.fromWeb(upstreamRes.body).pipe(res);
|
|
300
|
+
await new Promise((resolve) => res.on("close", resolve));
|
|
301
|
+
}
|
|
302
|
+
function flat(h) {
|
|
303
|
+
const out = {};
|
|
304
|
+
for (const [k, v] of Object.entries(h)) out[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
async function handle(req, res) {
|
|
308
|
+
const url = new URL(req.url ?? "/", "http://gateway.local");
|
|
309
|
+
const path = url.pathname;
|
|
310
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
311
|
+
return sendJson(res, 405, { error: "method not allowed" });
|
|
312
|
+
}
|
|
313
|
+
if (path === "/healthz") {
|
|
314
|
+
return sendJson(res, 200, { ok: true, mode, upstream, esplora: esploras.map((e) => e.baseUrl) });
|
|
315
|
+
}
|
|
316
|
+
if (path === "/metrics") {
|
|
317
|
+
return send(res, 200, registry.render(), { "content-type": "text/plain; version=0.0.4" });
|
|
318
|
+
}
|
|
319
|
+
if (ratePerSec > 0) {
|
|
320
|
+
const ip = clientIp(req);
|
|
321
|
+
if (!limiter.take(ip)) {
|
|
322
|
+
mRateLimited.inc();
|
|
323
|
+
return sendJson(res, 429, { error: "rate limited" }, { "retry-after": String(limiter.retryAfterSeconds(ip)) });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const cacheKey = path + url.search;
|
|
327
|
+
if (cacheMaxBytes > 0) {
|
|
328
|
+
const hit = cache.get(cacheKey);
|
|
329
|
+
if (hit) {
|
|
330
|
+
mCacheHits.inc();
|
|
331
|
+
return send(res, hit.status, hit.body, { ...hit.headers, "x-cache": "HIT" });
|
|
332
|
+
}
|
|
333
|
+
mCacheMisses.inc();
|
|
334
|
+
}
|
|
335
|
+
const proofMatch = path.match(/^\/ord\/v1\/proof\/([^/]+)$/);
|
|
336
|
+
if (proofMatch) {
|
|
337
|
+
const id = proofMatch[1];
|
|
338
|
+
if (!isInscriptionId(id)) return sendJson(res, 400, { error: `invalid inscription id: ${id}` });
|
|
339
|
+
const wanted = (url.searchParams.get("level") ?? "l2").toUpperCase() === "L3" ? "L3" : "L2";
|
|
340
|
+
try {
|
|
341
|
+
const bundle = await tryBackends(esploras, (e) => buildProofBundle(e, parseInscriptionId(id), wanted));
|
|
342
|
+
return sendCached(res, cacheKey, new TextEncoder().encode(JSON.stringify(bundle)), {
|
|
343
|
+
"content-type": "application/vnd.ord.proof+json; version=1",
|
|
344
|
+
"cache-control": IMMUTABLE
|
|
345
|
+
});
|
|
346
|
+
} catch (e) {
|
|
347
|
+
mUpstreamErrors.inc();
|
|
348
|
+
return sendJson(res, 502, { error: e.message });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const verifiedMatch = path.match(/^\/ord\/v1\/verified\/([^/]+)$/);
|
|
352
|
+
const contentMatch = path.match(/^\/content\/([^/]+)$/);
|
|
353
|
+
if (verifiedMatch || contentMatch && mode === "verify") {
|
|
354
|
+
const id = (verifiedMatch ?? contentMatch)[1];
|
|
355
|
+
if (!isInscriptionId(id)) return sendJson(res, 400, { error: `invalid inscription id: ${id}` });
|
|
356
|
+
try {
|
|
357
|
+
const result = await resolver.resolve(`ord:${id}/content`);
|
|
358
|
+
const response = toResponse(result);
|
|
359
|
+
const headers = {};
|
|
360
|
+
response.headers.forEach((v, k) => headers[k] = v);
|
|
361
|
+
headers["content-security-policy"] = CONTENT_CSP.join(", ");
|
|
362
|
+
return sendCached(res, cacheKey, new Uint8Array(await response.arrayBuffer()), headers);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
mUpstreamErrors.inc();
|
|
365
|
+
return sendJson(res, 502, { error: e.message });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (contentMatch || path.startsWith("/r/") || path.startsWith("/preview/") || path === "/blockheight" || path === "/blocktime" || path.startsWith("/blockhash")) {
|
|
369
|
+
try {
|
|
370
|
+
return await proxy(req, res, path + url.search, cacheKey);
|
|
371
|
+
} catch (e) {
|
|
372
|
+
mUpstreamErrors.inc();
|
|
373
|
+
return sendJson(res, 502, { error: e.message });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return sendJson(res, 404, {
|
|
377
|
+
error: "not found",
|
|
378
|
+
routes: [
|
|
379
|
+
"/content/<id>",
|
|
380
|
+
"/r/*",
|
|
381
|
+
"/ord/v1/proof/<id>?level=l2|l3",
|
|
382
|
+
"/ord/v1/verified/<id>",
|
|
383
|
+
"/healthz",
|
|
384
|
+
"/metrics"
|
|
385
|
+
]
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return createServer((req, res) => {
|
|
389
|
+
const started = performance.now();
|
|
390
|
+
res.on("finish", () => {
|
|
391
|
+
const seconds = (performance.now() - started) / 1e3;
|
|
392
|
+
const route = routeLabel(new URL(req.url ?? "/", "http://x").pathname);
|
|
393
|
+
mRequests.inc({ route, method: req.method ?? "GET", status: String(res.statusCode) });
|
|
394
|
+
mDuration.observe({ route }, seconds);
|
|
395
|
+
log?.({
|
|
396
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
397
|
+
msg: "req",
|
|
398
|
+
ip: clientIp(req),
|
|
399
|
+
method: req.method,
|
|
400
|
+
path: req.url,
|
|
401
|
+
status: res.statusCode,
|
|
402
|
+
ms: Math.round(seconds * 1e3),
|
|
403
|
+
cache: res.getHeader("x-cache") ?? void 0,
|
|
404
|
+
bytes: Number(res.getHeader("content-length") ?? 0) || void 0
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
handle(req, res).catch((e) => sendJson(res, 500, { error: e.message }));
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
async function tryBackends(backends, fn) {
|
|
411
|
+
const errors = [];
|
|
412
|
+
for (const b of backends) {
|
|
413
|
+
try {
|
|
414
|
+
return await fn(b);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
errors.push(`${b.baseUrl}: ${e.message}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
throw new Error(`all backends failed: ${errors.join("; ")}`);
|
|
420
|
+
}
|
|
421
|
+
function installShutdown(server, graceMs = 1e4, log) {
|
|
422
|
+
let closing = false;
|
|
423
|
+
const close = (signal) => {
|
|
424
|
+
if (closing) return;
|
|
425
|
+
closing = true;
|
|
426
|
+
log?.({ t: (/* @__PURE__ */ new Date()).toISOString(), msg: "shutdown", signal, graceMs });
|
|
427
|
+
server.close(() => process.exit(0));
|
|
428
|
+
server.closeIdleConnections();
|
|
429
|
+
setTimeout(() => {
|
|
430
|
+
server.closeAllConnections();
|
|
431
|
+
process.exit(0);
|
|
432
|
+
}, graceMs).unref();
|
|
433
|
+
};
|
|
434
|
+
process.on("SIGTERM", () => close("SIGTERM"));
|
|
435
|
+
process.on("SIGINT", () => close("SIGINT"));
|
|
436
|
+
}
|
|
437
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
438
|
+
const port = Number(process.env.PORT ?? 8317);
|
|
439
|
+
const log = (line) => console.log(JSON.stringify(line));
|
|
440
|
+
const gateway = createGateway({
|
|
441
|
+
port,
|
|
442
|
+
upstream: process.env.ORD_UPSTREAM,
|
|
443
|
+
esplora: process.env.ESPLORA?.split(","),
|
|
444
|
+
mode: process.env.GATEWAY_MODE ?? "proxy",
|
|
445
|
+
verification: process.env.GATEWAY_LEVEL === "L3" ? "L3" : "L2",
|
|
446
|
+
cacheMaxBytes: process.env.CACHE_MAX_BYTES ? Number(process.env.CACHE_MAX_BYTES) : void 0,
|
|
447
|
+
cacheMaxEntryBytes: process.env.CACHE_MAX_ENTRY_BYTES ? Number(process.env.CACHE_MAX_ENTRY_BYTES) : void 0,
|
|
448
|
+
rateLimitPerSec: process.env.RATE_LIMIT ? Number(process.env.RATE_LIMIT) : void 0,
|
|
449
|
+
rateBurst: process.env.RATE_BURST ? Number(process.env.RATE_BURST) : void 0,
|
|
450
|
+
trustProxy: process.env.TRUST_PROXY === "1",
|
|
451
|
+
log
|
|
452
|
+
});
|
|
453
|
+
installShutdown(gateway, void 0, log);
|
|
454
|
+
gateway.listen(port, () => {
|
|
455
|
+
log({ t: (/* @__PURE__ */ new Date()).toISOString(), msg: "listening", port, mode: process.env.GATEWAY_MODE ?? "proxy" });
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
export {
|
|
459
|
+
ByteLru,
|
|
460
|
+
Counter,
|
|
461
|
+
Histogram,
|
|
462
|
+
Registry,
|
|
463
|
+
TokenBucketLimiter,
|
|
464
|
+
createGateway,
|
|
465
|
+
installShutdown,
|
|
466
|
+
routeLabel
|
|
467
|
+
};
|
|
468
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/lru.ts","../src/metrics.ts","../src/ratelimit.ts"],"sourcesContent":["import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';\nimport { Readable } from 'node:stream';\nimport { isInscriptionId, parseInscriptionId } from '@ordspv/core';\nimport {\n buildProofBundle,\n EsploraBackend,\n OrdResolver,\n toResponse,\n type FetchFn,\n} from '@ordspv/fetch';\nimport { ByteLru } from './lru.js';\nimport { Registry } from './metrics.js';\nimport { TokenBucketLimiter } from './ratelimit.js';\n\nexport { ByteLru } from './lru.js';\nexport { Counter, Histogram, Registry } from './metrics.js';\nexport { TokenBucketLimiter } from './ratelimit.js';\n\n/**\n * Reference ord gateway (SPEC-GATEWAY.md).\n *\n * Personalities:\n * - proxy : replicate an upstream ord server's /content and /r/* surface\n * with ord-parity headers. Availability play only; adds no trust.\n * - verify : serve /content only after locally verifying the bytes against\n * Bitcoin (L2 by default) via esplora backends.\n *\n * Both serve: GET /ord/v1/proof/<id>?level=l2|l3, GET /ord/v1/verified/<id>,\n * GET /healthz, GET /metrics (prometheus text)\n *\n * Operational features (SPEC-GATEWAY §7): bounded byte-budget LRU on\n * immutable 200s (x-cache header), per-IP token-bucket rate limiting,\n * streaming proxy for oversized bodies, structured JSON request logs,\n * graceful shutdown. Verified responses are buffered, because a merkle proof\n * cannot be verified over bytes that have not all been read.\n */\n\nexport interface GatewayOptions {\n port?: number;\n upstream?: string;\n esplora?: string[];\n mode?: 'proxy' | 'verify';\n verification?: 'L2' | 'L3';\n fetchFn?: FetchFn;\n /** LRU budget across cached bodies (default 256 MiB; 0 disables) */\n cacheMaxBytes?: number;\n /** largest single cacheable body (default 8 MiB) */\n cacheMaxEntryBytes?: number;\n /** sustained requests/second per IP (default 10; 0 disables) */\n rateLimitPerSec?: number;\n /** burst size per IP (default 40) */\n rateBurst?: number;\n /** trust the first X-Forwarded-For hop for the client IP (behind a LB/CDN) */\n trustProxy?: boolean;\n /** structured log sink; false silences (default). CLI wires console.log. */\n log?: ((line: Record<string, unknown>) => void) | false;\n}\n\n// ord-parity security headers for /content (see research: ord server.rs)\nconst CONTENT_CSP = [\n \"default-src 'self' 'unsafe-eval' 'unsafe-inline' data: blob:\",\n \"default-src *:*/content/ *:*/blockheight *:*/blockhash *:*/blockhash/ *:*/blocktime *:*/r/ 'unsafe-eval' 'unsafe-inline' data: blob:\",\n];\nconst IMMUTABLE = 'public, max-age=1209600, immutable';\n\nfunction send(\n res: ServerResponse,\n status: number,\n body: string | Uint8Array,\n headers: Record<string, string | string[]> = {},\n): void {\n res.writeHead(status, { 'access-control-allow-origin': '*', ...headers });\n res.end(typeof body === 'string' ? body : Buffer.from(body));\n}\n\nfunction sendJson(res: ServerResponse, status: number, value: unknown, headers: Record<string, string> = {}): void {\n send(res, status, JSON.stringify(value, null, 2), { 'content-type': 'application/json', ...headers });\n}\n\n/** low-cardinality route label for metrics/logs */\nexport function routeLabel(path: string): string {\n if (path === '/healthz') return 'healthz';\n if (path === '/metrics') return 'metrics';\n if (path.startsWith('/content/')) return '/content/:id';\n if (path.startsWith('/preview/')) return '/preview/:id';\n if (/^\\/ord\\/v1\\/proof\\//.test(path)) return '/ord/v1/proof/:id';\n if (/^\\/ord\\/v1\\/verified\\//.test(path)) return '/ord/v1/verified/:id';\n if (path.startsWith('/r/')) return '/r/*';\n if (path === '/blockheight' || path === '/blocktime' || path.startsWith('/blockhash')) return '/chain';\n return 'other';\n}\n\nexport function createGateway(options: GatewayOptions = {}): Server {\n const upstream = (options.upstream ?? 'https://ordinals.com').replace(/\\/+$/, '');\n const mode = options.mode ?? 'proxy';\n const level = options.verification ?? 'L2';\n const fetchFn: FetchFn = options.fetchFn ?? ((u, i) => fetch(u, i));\n const esploras = (options.esplora ?? ['https://mempool.space/api', 'https://blockstream.info/api']).map(\n (u) => new EsploraBackend(u, fetchFn),\n );\n const resolver = new OrdResolver({\n esplora: esploras.map((e) => e.baseUrl),\n ordGateways: [upstream],\n fetchFn,\n verification: level,\n });\n\n const cacheMaxBytes = options.cacheMaxBytes ?? 256 * 1024 * 1024;\n const cacheMaxEntry = options.cacheMaxEntryBytes ?? 8 * 1024 * 1024;\n const cache = new ByteLru(cacheMaxBytes, cacheMaxEntry);\n const ratePerSec = options.rateLimitPerSec ?? 10;\n const limiter = new TokenBucketLimiter(ratePerSec, options.rateBurst ?? 40);\n const log = options.log || undefined;\n\n const registry = new Registry();\n const mRequests = registry.counter('gateway_http_requests_total', 'HTTP requests by route/method/status');\n const mDuration = registry.histogram('gateway_http_request_duration_seconds', 'request latency');\n const mCacheHits = registry.counter('gateway_cache_hits_total', 'LRU cache hits');\n const mCacheMisses = registry.counter('gateway_cache_misses_total', 'LRU cache misses (cacheable routes only)');\n const mRateLimited = registry.counter('gateway_rate_limited_total', 'requests rejected by the per-IP token bucket');\n const mUpstreamErrors = registry.counter('gateway_upstream_errors_total', 'errors talking to upstream/esplora');\n registry.gauge('gateway_cache_bytes', 'bytes held by the LRU', () => cache.usedBytes);\n registry.gauge('gateway_cache_entries', 'entries in the LRU', () => cache.size);\n registry.gauge('gateway_ratelimit_tracked_ips', 'token buckets currently tracked', () => limiter.trackedKeys);\n\n function clientIp(req: IncomingMessage): string {\n if (options.trustProxy) {\n const xff = req.headers['x-forwarded-for'];\n const first = (Array.isArray(xff) ? xff[0] : xff)?.split(',')[0]?.trim();\n if (first) return first;\n }\n return req.socket.remoteAddress ?? 'unknown';\n }\n\n /** cache + send an immutable 200 */\n function sendCached(\n res: ServerResponse,\n cacheKey: string,\n body: Uint8Array,\n headers: Record<string, string>,\n ): void {\n if (cacheMaxBytes > 0) cache.set(cacheKey, { status: 200, headers, body });\n send(res, 200, body, { ...headers, 'x-cache': 'MISS' });\n }\n\n async function proxy(req: IncomingMessage, res: ServerResponse, path: string, cacheKey: string): Promise<void> {\n const url = `${upstream}${path}`;\n const upstreamRes = await fetchFn(url, {\n headers: { 'accept-encoding': req.headers['accept-encoding'] ?? 'identity' },\n });\n const headers: Record<string, string> = {};\n for (const name of ['content-type', 'content-encoding', 'cache-control']) {\n const v = upstreamRes.headers.get(name);\n if (v) headers[name] = v;\n }\n if (path.startsWith('/content/')) {\n headers['cache-control'] = IMMUTABLE;\n }\n const extra: Record<string, string | string[]> = path.startsWith('/content/')\n ? { 'content-security-policy': CONTENT_CSP }\n : {};\n\n const length = Number(upstreamRes.headers.get('content-length') ?? NaN);\n const cacheable = upstreamRes.status === 200 && !Number.isNaN(length) && length <= cacheMaxEntry;\n if (cacheable || !upstreamRes.body) {\n const body = new Uint8Array(await upstreamRes.arrayBuffer());\n if (upstreamRes.status === 200) {\n if (cacheMaxBytes > 0) cache.set(cacheKey, { status: 200, headers: { ...headers, ...flat(extra) }, body });\n return send(res, 200, body, { ...headers, ...extra, 'x-cache': 'MISS' });\n }\n return send(res, upstreamRes.status, body, { ...headers, ...extra });\n }\n // large or unknown-length body: stream through, uncached\n res.writeHead(upstreamRes.status, { 'access-control-allow-origin': '*', ...headers, ...extra });\n Readable.fromWeb(upstreamRes.body as import('node:stream/web').ReadableStream).pipe(res);\n await new Promise<void>((resolve) => res.on('close', resolve));\n }\n\n function flat(h: Record<string, string | string[]>): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [k, v] of Object.entries(h)) out[k] = Array.isArray(v) ? v.join(', ') : v;\n return out;\n }\n\n async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {\n const url = new URL(req.url ?? '/', 'http://gateway.local');\n const path = url.pathname;\n\n if (req.method !== 'GET' && req.method !== 'HEAD') {\n return sendJson(res, 405, { error: 'method not allowed' });\n }\n\n if (path === '/healthz') {\n return sendJson(res, 200, { ok: true, mode, upstream, esplora: esploras.map((e) => e.baseUrl) });\n }\n if (path === '/metrics') {\n return send(res, 200, registry.render(), { 'content-type': 'text/plain; version=0.0.4' });\n }\n\n // rate limit everything else\n if (ratePerSec > 0) {\n const ip = clientIp(req);\n if (!limiter.take(ip)) {\n mRateLimited.inc();\n return sendJson(res, 429, { error: 'rate limited' }, { 'retry-after': String(limiter.retryAfterSeconds(ip)) });\n }\n }\n\n // immutable-response cache\n const cacheKey = path + url.search;\n if (cacheMaxBytes > 0) {\n const hit = cache.get(cacheKey);\n if (hit) {\n mCacheHits.inc();\n return send(res, hit.status, hit.body, { ...hit.headers, 'x-cache': 'HIT' });\n }\n mCacheMisses.inc();\n }\n\n // proof bundles\n const proofMatch = path.match(/^\\/ord\\/v1\\/proof\\/([^/]+)$/);\n if (proofMatch) {\n const id = proofMatch[1];\n if (!isInscriptionId(id)) return sendJson(res, 400, { error: `invalid inscription id: ${id}` });\n const wanted = (url.searchParams.get('level') ?? 'l2').toUpperCase() === 'L3' ? 'L3' : 'L2';\n try {\n const bundle = await tryBackends(esploras, (e) => buildProofBundle(e, parseInscriptionId(id), wanted));\n return sendCached(res, cacheKey, new TextEncoder().encode(JSON.stringify(bundle)), {\n 'content-type': 'application/vnd.ord.proof+json; version=1',\n 'cache-control': IMMUTABLE,\n });\n } catch (e) {\n mUpstreamErrors.inc();\n return sendJson(res, 502, { error: (e as Error).message });\n }\n }\n\n // verified content (also the verify-mode /content handler)\n const verifiedMatch = path.match(/^\\/ord\\/v1\\/verified\\/([^/]+)$/);\n const contentMatch = path.match(/^\\/content\\/([^/]+)$/);\n if (verifiedMatch || (contentMatch && mode === 'verify')) {\n const id = (verifiedMatch ?? contentMatch)![1];\n if (!isInscriptionId(id)) return sendJson(res, 400, { error: `invalid inscription id: ${id}` });\n try {\n const result = await resolver.resolve(`ord:${id}/content`);\n const response = toResponse(result);\n const headers: Record<string, string> = {};\n response.headers.forEach((v, k) => (headers[k] = v));\n headers['content-security-policy'] = CONTENT_CSP.join(', ');\n return sendCached(res, cacheKey, new Uint8Array(await response.arrayBuffer()), headers);\n } catch (e) {\n mUpstreamErrors.inc();\n return sendJson(res, 502, { error: (e as Error).message });\n }\n }\n\n // ord-server surface passthrough (recursion compatibility)\n if (\n contentMatch ||\n path.startsWith('/r/') ||\n path.startsWith('/preview/') ||\n path === '/blockheight' ||\n path === '/blocktime' ||\n path.startsWith('/blockhash')\n ) {\n try {\n return await proxy(req, res, path + url.search, cacheKey);\n } catch (e) {\n mUpstreamErrors.inc();\n return sendJson(res, 502, { error: (e as Error).message });\n }\n }\n\n return sendJson(res, 404, {\n error: 'not found',\n routes: [\n '/content/<id>',\n '/r/*',\n '/ord/v1/proof/<id>?level=l2|l3',\n '/ord/v1/verified/<id>',\n '/healthz',\n '/metrics',\n ],\n });\n }\n\n return createServer((req, res) => {\n const started = performance.now();\n res.on('finish', () => {\n const seconds = (performance.now() - started) / 1000;\n const route = routeLabel(new URL(req.url ?? '/', 'http://x').pathname);\n mRequests.inc({ route, method: req.method ?? 'GET', status: String(res.statusCode) });\n mDuration.observe({ route }, seconds);\n log?.({\n t: new Date().toISOString(),\n msg: 'req',\n ip: clientIp(req),\n method: req.method,\n path: req.url,\n status: res.statusCode,\n ms: Math.round(seconds * 1000),\n cache: res.getHeader('x-cache') ?? undefined,\n bytes: Number(res.getHeader('content-length') ?? 0) || undefined,\n });\n });\n handle(req, res).catch((e) => sendJson(res, 500, { error: (e as Error).message }));\n });\n}\n\nasync function tryBackends<T>(backends: EsploraBackend[], fn: (e: EsploraBackend) => Promise<T>): Promise<T> {\n const errors: string[] = [];\n for (const b of backends) {\n try {\n return await fn(b);\n } catch (e) {\n errors.push(`${b.baseUrl}: ${(e as Error).message}`);\n }\n }\n throw new Error(`all backends failed: ${errors.join('; ')}`);\n}\n\n/** graceful shutdown: stop accepting, drain in-flight, force-close after grace */\nexport function installShutdown(server: Server, graceMs = 10_000, log?: (l: Record<string, unknown>) => void): void {\n let closing = false;\n const close = (signal: string) => {\n if (closing) return;\n closing = true;\n log?.({ t: new Date().toISOString(), msg: 'shutdown', signal, graceMs });\n server.close(() => process.exit(0));\n server.closeIdleConnections();\n setTimeout(() => {\n server.closeAllConnections();\n process.exit(0);\n }, graceMs).unref();\n };\n process.on('SIGTERM', () => close('SIGTERM'));\n process.on('SIGINT', () => close('SIGINT'));\n}\n\n/** CLI entry: npx tsx packages/gateway/src/index.ts */\nif (import.meta.url === `file://${process.argv[1]}`) {\n const port = Number(process.env.PORT ?? 8317);\n const log = (line: Record<string, unknown>) => console.log(JSON.stringify(line));\n const gateway = createGateway({\n port,\n upstream: process.env.ORD_UPSTREAM,\n esplora: process.env.ESPLORA?.split(','),\n mode: (process.env.GATEWAY_MODE as 'proxy' | 'verify') ?? 'proxy',\n verification: process.env.GATEWAY_LEVEL === 'L3' ? 'L3' : 'L2',\n cacheMaxBytes: process.env.CACHE_MAX_BYTES ? Number(process.env.CACHE_MAX_BYTES) : undefined,\n cacheMaxEntryBytes: process.env.CACHE_MAX_ENTRY_BYTES ? Number(process.env.CACHE_MAX_ENTRY_BYTES) : undefined,\n rateLimitPerSec: process.env.RATE_LIMIT ? Number(process.env.RATE_LIMIT) : undefined,\n rateBurst: process.env.RATE_BURST ? Number(process.env.RATE_BURST) : undefined,\n trustProxy: process.env.TRUST_PROXY === '1',\n log,\n });\n installShutdown(gateway, undefined, log);\n gateway.listen(port, () => {\n log({ t: new Date().toISOString(), msg: 'listening', port, mode: process.env.GATEWAY_MODE ?? 'proxy' });\n });\n}\n","/**\n * Byte-budgeted LRU for immutable responses. Everything a gateway serves with\n * `immutable` cache semantics is safe to cache forever; the only pressure is\n * memory, so the budget is BYTES, not entries. Map iteration order gives us\n * recency for free (delete + re-set on hit).\n */\n\nexport interface CachedResponse {\n status: number;\n headers: Record<string, string>;\n body: Uint8Array;\n}\n\nexport class ByteLru {\n private readonly entries = new Map<string, CachedResponse>();\n private bytes = 0;\n hits = 0;\n misses = 0;\n\n constructor(\n /** total budget across all bodies */\n private readonly maxBytes: number,\n /** refuse to cache any single body larger than this */\n private readonly maxEntryBytes: number,\n ) {}\n\n get(key: string): CachedResponse | undefined {\n const hit = this.entries.get(key);\n if (!hit) {\n this.misses++;\n return undefined;\n }\n // refresh recency\n this.entries.delete(key);\n this.entries.set(key, hit);\n this.hits++;\n return hit;\n }\n\n set(key: string, value: CachedResponse): void {\n if (value.body.length > this.maxEntryBytes) return;\n const existing = this.entries.get(key);\n if (existing) {\n this.bytes -= existing.body.length;\n this.entries.delete(key);\n }\n this.entries.set(key, value);\n this.bytes += value.body.length;\n // evict least-recently-used until inside budget\n for (const [oldest, entry] of this.entries) {\n if (this.bytes <= this.maxBytes) break;\n if (oldest === key && this.entries.size === 1) break;\n this.entries.delete(oldest);\n this.bytes -= entry.body.length;\n }\n }\n\n get size(): number {\n return this.entries.size;\n }\n\n get usedBytes(): number {\n return this.bytes;\n }\n}\n","/**\n * Hand-rolled Prometheus text-format (0.0.4) registry: counters and\n * histograms with a small fixed label space. No dependency, no magic;\n * exactly what /metrics needs and nothing more.\n */\n\ntype Labels = Record<string, string>;\n\nfunction labelKey(labels: Labels): string {\n return Object.keys(labels)\n .sort()\n .map((k) => `${k}=${JSON.stringify(labels[k])}`)\n .join(',');\n}\n\nfunction renderLabels(labels: Labels, extra?: string): string {\n const parts = Object.keys(labels)\n .sort()\n .map((k) => `${k}=${JSON.stringify(labels[k])}`);\n if (extra) parts.push(extra);\n return parts.length ? `{${parts.join(',')}}` : '';\n}\n\nexport class Counter {\n private readonly series = new Map<string, { labels: Labels; value: number }>();\n constructor(\n readonly name: string,\n readonly help: string,\n ) {}\n\n inc(labels: Labels = {}, by = 1): void {\n const key = labelKey(labels);\n const entry = this.series.get(key) ?? { labels, value: 0 };\n entry.value += by;\n this.series.set(key, entry);\n }\n\n render(): string {\n const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} counter`];\n if (this.series.size === 0) lines.push(`${this.name} 0`);\n for (const { labels, value } of this.series.values()) {\n lines.push(`${this.name}${renderLabels(labels)} ${value}`);\n }\n return lines.join('\\n');\n }\n}\n\nexport class Histogram {\n private readonly series = new Map<string, { labels: Labels; buckets: number[]; sum: number; count: number }>();\n constructor(\n readonly name: string,\n readonly help: string,\n readonly bounds: number[] = [0.005, 0.025, 0.1, 0.25, 1, 2.5, 10, 30],\n ) {}\n\n observe(labels: Labels, value: number): void {\n const key = labelKey(labels);\n let entry = this.series.get(key);\n if (!entry) {\n entry = { labels, buckets: this.bounds.map(() => 0), sum: 0, count: 0 };\n this.series.set(key, entry);\n }\n for (let i = 0; i < this.bounds.length; i++) {\n if (value <= this.bounds[i]) entry.buckets[i]++;\n }\n entry.sum += value;\n entry.count++;\n }\n\n render(): string {\n const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} histogram`];\n for (const { labels, buckets, sum, count } of this.series.values()) {\n this.bounds.forEach((bound, i) => {\n lines.push(`${this.name}_bucket${renderLabels(labels, `le=\"${bound}\"`)} ${buckets[i]}`);\n });\n lines.push(`${this.name}_bucket${renderLabels(labels, 'le=\"+Inf\"')} ${count}`);\n lines.push(`${this.name}_sum${renderLabels(labels)} ${sum}`);\n lines.push(`${this.name}_count${renderLabels(labels)} ${count}`);\n }\n return lines.join('\\n');\n }\n}\n\nexport class Registry {\n private readonly metrics: Array<Counter | Histogram> = [];\n private readonly gauges: Array<{ name: string; help: string; read: () => number }> = [];\n\n counter(name: string, help: string): Counter {\n const c = new Counter(name, help);\n this.metrics.push(c);\n return c;\n }\n\n histogram(name: string, help: string, bounds?: number[]): Histogram {\n const h = new Histogram(name, help, bounds);\n this.metrics.push(h);\n return h;\n }\n\n /** callback gauge, sampled at render time (cache bytes, tracked IPs, …) */\n gauge(name: string, help: string, read: () => number): void {\n this.gauges.push({ name, help, read });\n }\n\n render(): string {\n const parts = this.metrics.map((m) => m.render());\n for (const g of this.gauges) {\n parts.push(`# HELP ${g.name} ${g.help}\\n# TYPE ${g.name} gauge\\n${g.name} ${g.read()}`);\n }\n return `${parts.join('\\n')}\\n`;\n }\n}\n","/**\n * Per-key token bucket: `ratePerSec` sustained, `burst` peak. Injectable\n * clock for tests. Idle buckets are swept so hostile IP churn cannot grow the\n * map without bound.\n */\n\ninterface Bucket {\n tokens: number;\n lastRefill: number;\n}\n\nexport class TokenBucketLimiter {\n private readonly buckets = new Map<string, Bucket>();\n private lastSweep: number;\n\n constructor(\n private readonly ratePerSec: number,\n private readonly burst: number,\n private readonly now: () => number = () => Date.now(),\n ) {\n this.lastSweep = this.now();\n }\n\n /** true = allowed (a token was taken); false = rate limited */\n take(key: string): boolean {\n const t = this.now();\n this.maybeSweep(t);\n let bucket = this.buckets.get(key);\n if (!bucket) {\n bucket = { tokens: this.burst, lastRefill: t };\n this.buckets.set(key, bucket);\n } else {\n const elapsed = (t - bucket.lastRefill) / 1000;\n bucket.tokens = Math.min(this.burst, bucket.tokens + elapsed * this.ratePerSec);\n bucket.lastRefill = t;\n }\n if (bucket.tokens < 1) return false;\n bucket.tokens -= 1;\n return true;\n }\n\n /** seconds until one token is available (for retry-after) */\n retryAfterSeconds(key: string): number {\n const bucket = this.buckets.get(key);\n if (!bucket || bucket.tokens >= 1) return 0;\n return Math.ceil((1 - bucket.tokens) / this.ratePerSec);\n }\n\n private maybeSweep(t: number): void {\n if (t - this.lastSweep < 60_000) return;\n this.lastSweep = t;\n const idleCutoff = t - 120_000;\n for (const [key, bucket] of this.buckets) {\n // effective tokens: what the bucket WOULD hold if refilled right now;\n // an idle bucket is safe to drop once it is logically full again\n const effective = bucket.tokens + ((t - bucket.lastRefill) / 1000) * this.ratePerSec;\n if (bucket.lastRefill < idleCutoff && effective >= this.burst) {\n this.buckets.delete(key);\n }\n }\n }\n\n get trackedKeys(): number {\n return this.buckets.size;\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/lru.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-budgeted LRU for immutable responses. Everything a gateway serves with
|
|
3
|
+
* `immutable` cache semantics is safe to cache forever; the only pressure is
|
|
4
|
+
* memory, so the budget is BYTES, not entries. Map iteration order gives us
|
|
5
|
+
* recency for free (delete + re-set on hit).
|
|
6
|
+
*/
|
|
7
|
+
export interface CachedResponse {
|
|
8
|
+
status: number;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
export declare class ByteLru {
|
|
13
|
+
/** total budget across all bodies */
|
|
14
|
+
private readonly maxBytes;
|
|
15
|
+
/** refuse to cache any single body larger than this */
|
|
16
|
+
private readonly maxEntryBytes;
|
|
17
|
+
private readonly entries;
|
|
18
|
+
private bytes;
|
|
19
|
+
hits: number;
|
|
20
|
+
misses: number;
|
|
21
|
+
constructor(
|
|
22
|
+
/** total budget across all bodies */
|
|
23
|
+
maxBytes: number,
|
|
24
|
+
/** refuse to cache any single body larger than this */
|
|
25
|
+
maxEntryBytes: number);
|
|
26
|
+
get(key: string): CachedResponse | undefined;
|
|
27
|
+
set(key: string, value: CachedResponse): void;
|
|
28
|
+
get size(): number;
|
|
29
|
+
get usedBytes(): number;
|
|
30
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-rolled Prometheus text-format (0.0.4) registry: counters and
|
|
3
|
+
* histograms with a small fixed label space. No dependency, no magic;
|
|
4
|
+
* exactly what /metrics needs and nothing more.
|
|
5
|
+
*/
|
|
6
|
+
type Labels = Record<string, string>;
|
|
7
|
+
export declare class Counter {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly help: string;
|
|
10
|
+
private readonly series;
|
|
11
|
+
constructor(name: string, help: string);
|
|
12
|
+
inc(labels?: Labels, by?: number): void;
|
|
13
|
+
render(): string;
|
|
14
|
+
}
|
|
15
|
+
export declare class Histogram {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly help: string;
|
|
18
|
+
readonly bounds: number[];
|
|
19
|
+
private readonly series;
|
|
20
|
+
constructor(name: string, help: string, bounds?: number[]);
|
|
21
|
+
observe(labels: Labels, value: number): void;
|
|
22
|
+
render(): string;
|
|
23
|
+
}
|
|
24
|
+
export declare class Registry {
|
|
25
|
+
private readonly metrics;
|
|
26
|
+
private readonly gauges;
|
|
27
|
+
counter(name: string, help: string): Counter;
|
|
28
|
+
histogram(name: string, help: string, bounds?: number[]): Histogram;
|
|
29
|
+
/** callback gauge, sampled at render time (cache bytes, tracked IPs, …) */
|
|
30
|
+
gauge(name: string, help: string, read: () => number): void;
|
|
31
|
+
render(): string;
|
|
32
|
+
}
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-key token bucket: `ratePerSec` sustained, `burst` peak. Injectable
|
|
3
|
+
* clock for tests. Idle buckets are swept so hostile IP churn cannot grow the
|
|
4
|
+
* map without bound.
|
|
5
|
+
*/
|
|
6
|
+
export declare class TokenBucketLimiter {
|
|
7
|
+
private readonly ratePerSec;
|
|
8
|
+
private readonly burst;
|
|
9
|
+
private readonly now;
|
|
10
|
+
private readonly buckets;
|
|
11
|
+
private lastSweep;
|
|
12
|
+
constructor(ratePerSec: number, burst: number, now?: () => number);
|
|
13
|
+
/** true = allowed (a token was taken); false = rate limited */
|
|
14
|
+
take(key: string): boolean;
|
|
15
|
+
/** seconds until one token is available (for retry-after) */
|
|
16
|
+
retryAfterSeconds(key: string): number;
|
|
17
|
+
private maybeSweep;
|
|
18
|
+
get trackedKeys(): number;
|
|
19
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ordspv/gateway",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "HTTP gateway serving ord:// content and SPV proof bundles.",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@ordspv/core": "0.1.0",
|
|
21
|
+
"@ordspv/fetch": "0.1.0"
|
|
22
|
+
}
|
|
23
|
+
}
|