openzoo 0.51.4 → 0.51.6
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 +12 -1
- package/bin/openzoo.js +21 -46
- package/lib/chatgpt-install.js +106 -0
- package/lib/chatgpt.js +108 -90
- package/lib/config.js +34 -0
- package/lib/modelcommand.js +443 -0
- package/lib/pay.js +60 -4
- package/lib/proxy.js +39 -0
- package/package.json +4 -7
- package/transmute/README.md +0 -385
- package/transmute/bin/openzoo-transmute.js +0 -7
- package/transmute/docs/OPENZOO_CLI_PATCH.md +0 -139
- package/transmute/docs/VERCEL_TO_SOLANA.md +0 -381
- package/transmute/lib/build.js +0 -381
- package/transmute/lib/cli.js +0 -293
- package/transmute/lib/compile/bytecode.js +0 -591
- package/transmute/lib/compile/hostids.js +0 -11
- package/transmute/lib/compile/index.js +0 -159
- package/transmute/lib/compile/ir.js +0 -1634
- package/transmute/lib/compile/parse.js +0 -753
- package/transmute/lib/compile/rust.js +0 -781
- package/transmute/lib/deploy.js +0 -357
- package/transmute/lib/eligibility.js +0 -211
- package/transmute/lib/gateway.js +0 -710
- package/transmute/lib/hub.js +0 -290
- package/transmute/lib/solana.js +0 -387
- package/transmute/lib/vercel.js +0 -296
- package/transmute/lib/wallet.js +0 -41
- package/transmute/lib/wire.js +0 -208
- package/transmute/prebuilt/zoo_vm.v0.so +0 -0
- package/transmute/runtime/zoo-host/Cargo.lock +0 -588
- package/transmute/runtime/zoo-host/Cargo.toml +0 -30
- package/transmute/runtime/zoo-host/examples/vmrun.rs +0 -43
- package/transmute/runtime/zoo-host/src/assets.rs +0 -214
- package/transmute/runtime/zoo-host/src/ctx.rs +0 -350
- package/transmute/runtime/zoo-host/src/fmt.rs +0 -188
- package/transmute/runtime/zoo-host/src/helpers.rs +0 -245
- package/transmute/runtime/zoo-host/src/json.rs +0 -263
- package/transmute/runtime/zoo-host/src/kv.rs +0 -206
- package/transmute/runtime/zoo-host/src/lib.rs +0 -137
- package/transmute/runtime/zoo-host/src/site.rs +0 -94
- package/transmute/runtime/zoo-host/src/val.rs +0 -1050
- package/transmute/runtime/zoo-host/src/vm.rs +0 -902
- package/transmute/runtime/zoo-host/src/wire.rs +0 -239
- package/transmute/runtime/zoo-vm/Cargo.lock +0 -596
- package/transmute/runtime/zoo-vm/Cargo.toml +0 -23
- package/transmute/runtime/zoo-vm/src/lib.rs +0 -92
- package/transmute/site/.env.production +0 -2
- package/transmute/site/api/echo.js +0 -10
- package/transmute/site/api/hello.js +0 -12
- package/transmute/site/api/hits.js +0 -13
- package/transmute/site/api/time.js +0 -5
- package/transmute/site/package.json +0 -6
- package/transmute/site/public/app.js +0 -16
- package/transmute/site/public/index.html +0 -96
package/transmute/lib/gateway.js
DELETED
|
@@ -1,710 +0,0 @@
|
|
|
1
|
-
// The local gateway: an HTTP front for one transmuted site program.
|
|
2
|
-
//
|
|
3
|
-
// It plays the part Vercel's edge plays for a deployment — static files
|
|
4
|
-
// first (from asset PDAs, cached in memory), `config.routes` rewrites with
|
|
5
|
-
// `handle: 'filesystem'` precedence, cleanUrls fallbacks, then the function
|
|
6
|
-
// routes by pattern. A function hit becomes one bridge Invoke event:
|
|
7
|
-
// GET/HEAD/OPTIONS are simulated (free, no signature), everything else is a
|
|
8
|
-
// signed transaction paid by the gateway keypair. Without a keypair a
|
|
9
|
-
// mutating request answers 402 — that is the seam where openzoo's x402
|
|
10
|
-
// payment flow takes over later.
|
|
11
|
-
//
|
|
12
|
-
// `handleRequest(state, req)` is the whole gateway minus sockets so it can
|
|
13
|
-
// be unit-tested with a fake `invoke`; `startGateway()` wraps it in
|
|
14
|
-
// `node:http`.
|
|
15
|
-
import http from 'node:http';
|
|
16
|
-
import { createHash } from 'node:crypto';
|
|
17
|
-
import { PublicKey, Transaction, TransactionInstruction, ComputeBudgetProgram, SystemProgram } from '@solana/web3.js';
|
|
18
|
-
import { FORWARDED_HEADERS, MANIFEST_PATH, assetPda, assetPdaFor, decodeAsset, encodeInvoke } from './wire.js';
|
|
19
|
-
import { connect, invoke as chainInvoke, readManifest, getProgramInfo, getSiteInfo, DEFAULT_CU, DEFAULT_HEAP } from './solana.js';
|
|
20
|
-
import { rpcUrl } from './wallet.js';
|
|
21
|
-
|
|
22
|
-
export const DEFAULT_PORT = 4402;
|
|
23
|
-
/** Coarse cap on the request body (checked while the body streams in, before any RPC). */
|
|
24
|
-
export const BODY_LIMIT = 900;
|
|
25
|
-
/** A serialized Solana transaction may not exceed this many bytes. */
|
|
26
|
-
export const PACKET_DATA_SIZE = 1232;
|
|
27
|
-
/** Bytes `invoke` appends for the `x-zoo-nonce:<hex>\n` header on mutating requests. */
|
|
28
|
-
export const NONCE_HEADER_BYTES = 32;
|
|
29
|
-
/** Methods that never mutate: served by `simulateTransaction`, no signer needed. */
|
|
30
|
-
export const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
31
|
-
export const ALL_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'];
|
|
32
|
-
export const ZOO_PREFIX = '/.zoo';
|
|
33
|
-
|
|
34
|
-
const JSON_CT = 'application/json; charset=utf-8';
|
|
35
|
-
const HTML_CT = 'text/html; charset=utf-8';
|
|
36
|
-
|
|
37
|
-
// ---------------------------------------------------------------- state
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Build the gateway state. Everything `handleRequest` needs lives here so
|
|
41
|
-
* tests can construct one with a fake `invoke` and no connection.
|
|
42
|
-
*
|
|
43
|
-
* @param {{programId:string|PublicKey, cluster?:string, connection?:object, keypair?:object,
|
|
44
|
-
* manifest?:object, invoke?:Function, cacheTtlMs?:number, port?:number, log?:Function}} o
|
|
45
|
-
*/
|
|
46
|
-
export function makeState(o) {
|
|
47
|
-
const programId = new PublicKey(o.programId);
|
|
48
|
-
const cluster = o.cluster || process.env.OPENZOO_CLUSTER || 'localnet';
|
|
49
|
-
// Shared runtime: `programId` is the runtime program and `site` the site id;
|
|
50
|
-
// every PDA is namespaced by it and the invoke carries it.
|
|
51
|
-
const site = o.site ? new PublicKey(o.site) : null;
|
|
52
|
-
const state = {
|
|
53
|
-
programId,
|
|
54
|
-
programIdStr: programId.toBase58(),
|
|
55
|
-
site,
|
|
56
|
-
siteStr: site ? site.toBase58() : null,
|
|
57
|
-
cluster,
|
|
58
|
-
rpc: o.connection?.rpcEndpoint || safeRpcUrl(cluster),
|
|
59
|
-
connection: o.connection ?? null,
|
|
60
|
-
keypair: o.keypair ?? null,
|
|
61
|
-
manifest: normalizeManifest(o.manifest),
|
|
62
|
-
manifestAt: o.manifest ? Date.now() : 0,
|
|
63
|
-
invoke: o.invoke ?? ((args) => chainInvoke(state.connection, args)),
|
|
64
|
-
assets: new Map(), // path -> { contentType, data, etag, at } | { missing:true, at } | { incomplete:true, at }
|
|
65
|
-
cacheTtlMs: o.cacheTtlMs ?? 30_000,
|
|
66
|
-
bodyLimit: o.bodyLimit ?? BODY_LIMIT,
|
|
67
|
-
port: o.port ?? null,
|
|
68
|
-
startedAt: Date.now(),
|
|
69
|
-
readPayer: null, // PublicKey used as fee payer for simulations when there is no keypair
|
|
70
|
-
log: o.log ?? (() => {}),
|
|
71
|
-
stats: { requests: 0, invokes: 0, simulated: 0, signed: 0, staticHits: 0 },
|
|
72
|
-
};
|
|
73
|
-
return state;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function safeRpcUrl(cluster) { try { return rpcUrl(cluster); } catch { return null; } }
|
|
77
|
-
|
|
78
|
-
/** Fill in the optional manifest fields so routing never has to null-check. */
|
|
79
|
-
export function normalizeManifest(m) {
|
|
80
|
-
if (!m) return { version: 1, framework: null, routes: [], static: null, env: [], config: { routes: [] }, missing: true };
|
|
81
|
-
return {
|
|
82
|
-
...m,
|
|
83
|
-
routes: Array.isArray(m.routes) ? m.routes : [],
|
|
84
|
-
static: Array.isArray(m.static) ? m.static : null,
|
|
85
|
-
env: Array.isArray(m.env) ? m.env : [],
|
|
86
|
-
config: { ...(m.config || {}), routes: Array.isArray(m.config?.routes) ? m.config.routes : [] },
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ---------------------------------------------------------------- responses
|
|
91
|
-
|
|
92
|
-
function json(status, obj, headers = {}) {
|
|
93
|
-
return { status, headers: { 'content-type': JSON_CT, ...headers }, body: Buffer.from(JSON.stringify(obj, null, 2)) };
|
|
94
|
-
}
|
|
95
|
-
function html(status, text, headers = {}) {
|
|
96
|
-
return { status, headers: { 'content-type': HTML_CT, ...headers }, body: Buffer.from(text) };
|
|
97
|
-
}
|
|
98
|
-
function escape(s) {
|
|
99
|
-
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
100
|
-
}
|
|
101
|
-
export function etagOf(data) { return '"' + createHash('sha256').update(data).digest('hex').slice(0, 32) + '"'; }
|
|
102
|
-
|
|
103
|
-
// ---------------------------------------------------------------- assets
|
|
104
|
-
|
|
105
|
-
/** Look an asset up in the in-memory cache, refreshing from chain after the TTL. */
|
|
106
|
-
export async function getAsset(state, path) {
|
|
107
|
-
const now = Date.now();
|
|
108
|
-
const cached = state.assets.get(path);
|
|
109
|
-
if (cached && now - cached.at < state.cacheTtlMs) return cached;
|
|
110
|
-
if (!state.connection) return cached ?? { missing: true, at: now };
|
|
111
|
-
const [pda] = assetPdaFor(state.programId, state.site, path);
|
|
112
|
-
let entry;
|
|
113
|
-
try {
|
|
114
|
-
const info = await state.connection.getAccountInfo(pda);
|
|
115
|
-
const a = info ? decodeAsset(info.data) : null;
|
|
116
|
-
if (!a) entry = { missing: true, at: now };
|
|
117
|
-
else if (!a.complete) entry = { incomplete: true, total: a.total, have: a.data.length, at: now };
|
|
118
|
-
else {
|
|
119
|
-
const data = Buffer.from(a.data);
|
|
120
|
-
entry = { contentType: a.contentType || staticEntry(state, path)?.contentType || 'application/octet-stream', data, etag: etagOf(data), pda: pda.toBase58(), at: now };
|
|
121
|
-
}
|
|
122
|
-
} catch (e) {
|
|
123
|
-
// RPC hiccup: keep serving what we have.
|
|
124
|
-
if (cached) return cached;
|
|
125
|
-
entry = { error: String(e?.message || e), at: now - state.cacheTtlMs + 2000 };
|
|
126
|
-
}
|
|
127
|
-
state.assets.set(path, entry);
|
|
128
|
-
return entry;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function staticEntry(state, path) {
|
|
132
|
-
const list = state.manifest.static;
|
|
133
|
-
if (!list) return null;
|
|
134
|
-
if (!state._staticIndex || state._staticIndexOf !== list) {
|
|
135
|
-
state._staticIndex = new Map(list.map((s) => [s.path, s]));
|
|
136
|
-
state._staticIndexOf = list;
|
|
137
|
-
}
|
|
138
|
-
return state._staticIndex.get(path) || null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Is `path` a static file of this deployment? Without a manifest list, ask the chain. */
|
|
142
|
-
async function staticExists(state, path) {
|
|
143
|
-
if (state.manifest.static) return !!staticEntry(state, path);
|
|
144
|
-
const a = await getAsset(state, path);
|
|
145
|
-
return !a.missing && !a.error;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Vercel cleanUrls semantics: '/' → /index.html, '/foo' → /foo, /foo.html, /foo/index.html. */
|
|
149
|
-
export function staticCandidates(pathname) {
|
|
150
|
-
const out = [];
|
|
151
|
-
if (pathname === '/' || pathname === '') return ['/index.html'];
|
|
152
|
-
if (pathname.endsWith('/')) {
|
|
153
|
-
out.push(pathname + 'index.html', pathname.slice(0, -1) + '.html', pathname.slice(0, -1));
|
|
154
|
-
} else {
|
|
155
|
-
out.push(pathname, pathname + '.html', pathname + '/index.html');
|
|
156
|
-
}
|
|
157
|
-
return out;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function lookupStatic(state, pathname) {
|
|
161
|
-
for (const cand of staticCandidates(pathname)) {
|
|
162
|
-
if (await staticExists(state, cand)) return cand;
|
|
163
|
-
}
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function serveStatic(state, path, req, extraHeaders = {}) {
|
|
168
|
-
const a = await getAsset(state, path);
|
|
169
|
-
if (a.missing) return null;
|
|
170
|
-
if (a.error) return json(503, { error: 'asset unavailable', path, message: a.error });
|
|
171
|
-
if (a.incomplete) return json(503, { error: 'asset upload incomplete', path, have: a.have, total: a.total, hint: 'the deploy did not finish writing this file; run deploy again' }, { 'retry-after': '5' });
|
|
172
|
-
state.stats.staticHits++;
|
|
173
|
-
const headers = {
|
|
174
|
-
'content-type': a.contentType,
|
|
175
|
-
'etag': a.etag,
|
|
176
|
-
'cache-control': 'public, max-age=0, must-revalidate',
|
|
177
|
-
'x-zoo-asset': a.pda || '',
|
|
178
|
-
...extraHeaders,
|
|
179
|
-
};
|
|
180
|
-
const inm = req.headers?.['if-none-match'];
|
|
181
|
-
if (inm && inm.split(',').map((s) => s.trim()).includes(a.etag)) return { status: 304, headers, body: Buffer.alloc(0) };
|
|
182
|
-
if (req.method === 'OPTIONS') return { status: 204, headers: { ...headers, allow: 'GET, HEAD, OPTIONS' }, body: Buffer.alloc(0) };
|
|
183
|
-
return { status: 200, headers, body: a.data };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ---------------------------------------------------------------- routing
|
|
187
|
-
|
|
188
|
-
function routeRegex(src, caseSensitive) {
|
|
189
|
-
try { return new RegExp(src, caseSensitive ? '' : 'i'); } catch { return null; }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** `$1` / `$name` substitution in a `dest`, like @vercel/routing-utils. */
|
|
193
|
-
export function resolveDest(dest, match) {
|
|
194
|
-
return dest
|
|
195
|
-
.replace(/\$(\d+)/g, (_, i) => match[Number(i)] ?? '')
|
|
196
|
-
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, n) => match.groups?.[n] ?? '');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Match `route` (a Build Output API route object) against a request; null when it does not apply. */
|
|
200
|
-
function matchRoute(route, pathname, method) {
|
|
201
|
-
if (!route.src) return null;
|
|
202
|
-
if (route.methods && !methodAllowed(route, method)) return null;
|
|
203
|
-
const re = routeRegex(route.src, route.caseSensitive);
|
|
204
|
-
if (!re) return null;
|
|
205
|
-
return re.exec(pathname);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Split `config.routes` into the phases Vercel evaluates them in. */
|
|
209
|
-
export function routePhases(routes) {
|
|
210
|
-
const phases = { pre: [], post: [], hit: [], error: [] };
|
|
211
|
-
let cur = 'pre';
|
|
212
|
-
for (const r of routes) {
|
|
213
|
-
if (r && typeof r.handle === 'string') {
|
|
214
|
-
cur = r.handle === 'filesystem' || r.handle === 'miss' || r.handle === 'rewrite' || r.handle === 'resource' ? 'post' : r.handle === 'hit' ? 'hit' : r.handle === 'error' ? 'error' : cur;
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
if (r && r.src) phases[cur].push(r);
|
|
218
|
-
}
|
|
219
|
-
return phases;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Find the function whose route pattern matches; returns {route, params, allowed}. */
|
|
223
|
-
export function matchFunction(state, pathname, method) {
|
|
224
|
-
let methodMiss = null;
|
|
225
|
-
for (const r of state.manifest.routes) {
|
|
226
|
-
if (!r || typeof r.index !== 'number') continue;
|
|
227
|
-
const re = r.pattern ? routeRegex(r.pattern, true) : null;
|
|
228
|
-
const m = re ? re.exec(pathname) : (r.routePath === pathname ? [pathname] : null);
|
|
229
|
-
if (!m) continue;
|
|
230
|
-
const params = {};
|
|
231
|
-
(r.params || []).forEach((name, i) => { if (m[i + 1] != null) params[name] = decodeURIComponentSafe(m[i + 1]); });
|
|
232
|
-
if (m.groups) for (const [k, v] of Object.entries(m.groups)) if (v != null) params[k] = decodeURIComponentSafe(v);
|
|
233
|
-
if (!methodAllowed(r, method)) { methodMiss = methodMiss || { route: r, params, allowed: false }; continue; }
|
|
234
|
-
return { route: r, params, allowed: true };
|
|
235
|
-
}
|
|
236
|
-
return methodMiss;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function decodeURIComponentSafe(s) { try { return decodeURIComponent(s); } catch { return s; } }
|
|
240
|
-
|
|
241
|
-
export function methodAllowed(route, method) {
|
|
242
|
-
if (!route.methods || !route.methods.length) return true;
|
|
243
|
-
const ms = route.methods.map((m) => m.toUpperCase());
|
|
244
|
-
if (ms.includes(method)) return true;
|
|
245
|
-
if (method === 'HEAD' && ms.includes('GET')) return true;
|
|
246
|
-
return false;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Exact `dest` → function lookup: `dest` is the function's routePath (e.g. "/api/users/[id]"). */
|
|
250
|
-
function functionByRoutePath(state, dest) {
|
|
251
|
-
return state.manifest.routes.find((r) => r && typeof r.index === 'number' && r.routePath === dest) || null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function parseUrl(url) {
|
|
255
|
-
const raw = String(url || '/');
|
|
256
|
-
const q = raw.indexOf('?');
|
|
257
|
-
let pathname = q >= 0 ? raw.slice(0, q) : raw;
|
|
258
|
-
const query = q >= 0 ? raw.slice(q + 1) : '';
|
|
259
|
-
if (!pathname.startsWith('/')) pathname = '/' + pathname;
|
|
260
|
-
// Collapse duplicate slashes and resolve dot segments so "/a/../b" cannot escape a namespace.
|
|
261
|
-
pathname = pathname.replace(/\/{2,}/g, '/');
|
|
262
|
-
const segs = [];
|
|
263
|
-
for (const s of pathname.split('/')) {
|
|
264
|
-
if (s === '..') segs.pop(); else if (s !== '.') segs.push(s);
|
|
265
|
-
}
|
|
266
|
-
pathname = segs.join('/') || '/';
|
|
267
|
-
if (!pathname.startsWith('/')) pathname = '/' + pathname;
|
|
268
|
-
return { pathname, query };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function mergeQuery(a, b) { return [a, b].filter(Boolean).join('&'); }
|
|
272
|
-
|
|
273
|
-
// ---------------------------------------------------------------- tx budget
|
|
274
|
-
|
|
275
|
-
const overheadByKv = new Map();
|
|
276
|
-
/**
|
|
277
|
-
* Bytes of a serialized invoke transaction that are not instruction data:
|
|
278
|
-
* signature, message header, the payer/system/compute-budget/program keys,
|
|
279
|
-
* `kvCount` KV accounts, blockhash, and the heap + CU instructions `invoke`
|
|
280
|
-
* always adds. Measured by serializing the same shape once per `kvCount`
|
|
281
|
-
* (252 B for no KV accounts, +33 B per account).
|
|
282
|
-
*/
|
|
283
|
-
export function invokeTxOverhead(kvCount = 0) {
|
|
284
|
-
kvCount = Math.max(0, kvCount | 0);
|
|
285
|
-
if (overheadByKv.has(kvCount)) return overheadByKv.get(kvCount);
|
|
286
|
-
const key = (i) => new PublicKey(Buffer.alloc(32, i + 1));
|
|
287
|
-
const keys = [{ pubkey: key(0), isSigner: true, isWritable: true }, { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }];
|
|
288
|
-
for (let i = 0; i < kvCount; i++) keys.push({ pubkey: key(2 + i), isSigner: false, isWritable: true });
|
|
289
|
-
const tx = new Transaction();
|
|
290
|
-
if (DEFAULT_HEAP) tx.add(ComputeBudgetProgram.requestHeapFrame({ bytes: DEFAULT_HEAP }));
|
|
291
|
-
tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: DEFAULT_CU }));
|
|
292
|
-
const probe = 128; // ≥128 so the compact-u16 data length takes its 2-byte form, as it will for any real request
|
|
293
|
-
tx.add(new TransactionInstruction({ programId: key(1), keys, data: Buffer.alloc(probe) }));
|
|
294
|
-
tx.feePayer = key(0);
|
|
295
|
-
tx.recentBlockhash = '11111111111111111111111111111111';
|
|
296
|
-
const n = tx.serialize({ requireAllSignatures: false, verifySignatures: false }).length - probe;
|
|
297
|
-
overheadByKv.set(kvCount, n);
|
|
298
|
-
return n;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Instruction-data bytes (path + query + headers + body + framing) one invoke may carry. */
|
|
302
|
-
export function invokeBudget(kvCount = 0, mutate = false) {
|
|
303
|
-
return PACKET_DATA_SIZE - invokeTxOverhead(kvCount) - (mutate ? NONCE_HEADER_BYTES : 0);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// ---------------------------------------------------------------- functions
|
|
307
|
-
|
|
308
|
-
function normalizeIp(a) { return typeof a === 'string' ? a.replace(/^::ffff:/i, '') : null; }
|
|
309
|
-
|
|
310
|
-
function forwardedHeaders(req, params) {
|
|
311
|
-
const out = {};
|
|
312
|
-
for (const [k, v] of Object.entries(req.headers || {})) {
|
|
313
|
-
const name = k.toLowerCase();
|
|
314
|
-
if (name.startsWith('x-zoo-param-')) continue; // never trust client-supplied params
|
|
315
|
-
if (FORWARDED_HEADERS.includes(name) || name.startsWith('x-')) out[name] = Array.isArray(v) ? v.join(', ') : String(v);
|
|
316
|
-
}
|
|
317
|
-
// The client cannot pick its own address: `x-real-ip` is always the socket
|
|
318
|
-
// peer, and a client-supplied `x-forwarded-for` gets the peer appended the
|
|
319
|
-
// way any proxy does (so the last hop is the trustworthy one).
|
|
320
|
-
const peer = normalizeIp(req.remoteAddress);
|
|
321
|
-
if (peer) {
|
|
322
|
-
out['x-real-ip'] = peer;
|
|
323
|
-
if (out['x-forwarded-for']) out['x-forwarded-for'] = `${out['x-forwarded-for']}, ${peer}`;
|
|
324
|
-
}
|
|
325
|
-
for (const [k, v] of Object.entries(params)) out['x-zoo-param-' + k.toLowerCase().replace(/[^a-z0-9]+/g, '-')] = v;
|
|
326
|
-
return out;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
async function readPayerFor(state) {
|
|
330
|
-
if (state.keypair) return state.keypair;
|
|
331
|
-
if (state.readPayer) return state.readPayer;
|
|
332
|
-
// Simulations still need a fee payer that exists and holds lamports; the
|
|
333
|
-
// program's upgrade authority is the best guess when the gateway is unsigned.
|
|
334
|
-
if (state.connection) {
|
|
335
|
-
try {
|
|
336
|
-
const info = await getProgramInfo(state.connection, state.programId);
|
|
337
|
-
if (info.authority) { state.readPayer = info.authority; return info.authority; }
|
|
338
|
-
} catch { /* fall through */ }
|
|
339
|
-
}
|
|
340
|
-
state.readPayer = state.programId;
|
|
341
|
-
return state.readPayer;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
async function serveFunction(state, hit, req, pathname, query, extraHeaders = {}) {
|
|
345
|
-
const { route, params } = hit;
|
|
346
|
-
const method = req.method;
|
|
347
|
-
const body = req.body == null ? Buffer.alloc(0) : Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body));
|
|
348
|
-
// Coarse cut first: nothing above a whole packet can ever fit. Anything
|
|
349
|
-
// smaller gets the exact budget below, which also tells the caller how much
|
|
350
|
-
// body DOES fit next to its own headers (maxBody).
|
|
351
|
-
if (body.length > PACKET_DATA_SIZE) {
|
|
352
|
-
return json(413, {
|
|
353
|
-
error: 'payload too large',
|
|
354
|
-
limit: state.bodyLimit,
|
|
355
|
-
received: body.length,
|
|
356
|
-
message: `request bodies travel inside a Solana transaction (${PACKET_DATA_SIZE} bytes total, at most ${state.bodyLimit} for the body); send less, or put large payloads in an asset and reference it`,
|
|
357
|
-
}, { 'x-zoo-program': state.programIdStr });
|
|
358
|
-
}
|
|
359
|
-
const mutate = !READ_METHODS.includes(method);
|
|
360
|
-
const headers = forwardedHeaders(req, params);
|
|
361
|
-
const event = { route: route.index, method, path: pathname, query, headers, body };
|
|
362
|
-
// The exact check: the whole event (framing, path, query, forwarded headers,
|
|
363
|
-
// body) plus the KV accounts the route declares must fit in one transaction.
|
|
364
|
-
const data = encodeInvoke(event);
|
|
365
|
-
const kvCount = Array.isArray(route.kv) ? route.kv.length : 0;
|
|
366
|
-
const budget = invokeBudget(kvCount, mutate);
|
|
367
|
-
if (data.length > budget) {
|
|
368
|
-
const envelope = data.length - body.length;
|
|
369
|
-
const maxBody = Math.max(0, Math.min(state.bodyLimit, budget - envelope));
|
|
370
|
-
return json(413, {
|
|
371
|
-
error: 'payload too large',
|
|
372
|
-
limit: state.bodyLimit,
|
|
373
|
-
maxBody,
|
|
374
|
-
received: body.length,
|
|
375
|
-
envelope,
|
|
376
|
-
budget,
|
|
377
|
-
message: `the request must fit in one ${PACKET_DATA_SIZE}-byte Solana transaction: path, query and forwarded headers take ${envelope} bytes and ${kvCount} KV account(s) are reserved, leaving ${maxBody} for the body (got ${body.length}); send fewer/shorter headers or a smaller body`,
|
|
378
|
-
}, { 'x-zoo-program': state.programIdStr });
|
|
379
|
-
}
|
|
380
|
-
if (mutate && !state.keypair) {
|
|
381
|
-
return json(402, {
|
|
382
|
-
error: 'payment required',
|
|
383
|
-
message: `${method} ${pathname} mutates on-chain state and must be sent as a signed transaction; this gateway has no wallet. Start it with --keypair <path>, or pay per request via x402.`,
|
|
384
|
-
method, path: pathname, programId: state.programIdStr, cluster: state.cluster,
|
|
385
|
-
x402: { scheme: 'solana', network: state.cluster, resource: pathname, payTo: null },
|
|
386
|
-
}, { 'x-zoo-program': state.programIdStr, 'x-zoo-needs-signer': 'true' });
|
|
387
|
-
}
|
|
388
|
-
const payer = await readPayerFor(state);
|
|
389
|
-
state.stats.invokes++;
|
|
390
|
-
let r;
|
|
391
|
-
try {
|
|
392
|
-
r = await state.invoke({ programId: state.programId, site: state.site, payer, event, mutate });
|
|
393
|
-
} catch (e) {
|
|
394
|
-
const msg = String(e?.message || e);
|
|
395
|
-
if (/signing keypair is required/i.test(msg)) return json(402, { error: 'payment required', message: msg, method, path: pathname }, { 'x-zoo-program': state.programIdStr });
|
|
396
|
-
// The handler touched more KV accounts than the manifest declared and the tx outgrew the packet: still the client's payload, not the chain.
|
|
397
|
-
if (/transaction too large/i.test(msg)) return json(413, { error: 'payload too large', limit: state.bodyLimit, received: body.length, message: `${msg.split('\n')[0]}; the handler needs more accounts than the manifest declares, send a smaller body or fewer headers` }, { 'x-zoo-program': state.programIdStr });
|
|
398
|
-
state.log(`invoke ${method} ${pathname} failed: ${msg.split('\n')[0]}`);
|
|
399
|
-
return json(502, { error: 'invoke failed', message: msg.split('\n')[0], route: route.routePath, logs: (e?.logs || []).slice(-25) }, { 'x-zoo-program': state.programIdStr });
|
|
400
|
-
}
|
|
401
|
-
if (r.simulated === false) state.stats.signed++; else state.stats.simulated++;
|
|
402
|
-
const outHeaders = { ...extraHeaders };
|
|
403
|
-
for (const [k, v] of Object.entries(r.headers || {})) outHeaders[k.toLowerCase()] = v;
|
|
404
|
-
outHeaders['x-zoo-program'] = state.programIdStr;
|
|
405
|
-
outHeaders['x-zoo-route'] = String(route.index);
|
|
406
|
-
outHeaders['x-zoo-simulated'] = r.simulated === false ? 'false' : 'true';
|
|
407
|
-
if (r.signature) outHeaders['x-zoo-signature'] = r.signature;
|
|
408
|
-
if (r.unitsConsumed != null) outHeaders['x-zoo-cu'] = String(r.unitsConsumed);
|
|
409
|
-
const out = r.body == null ? Buffer.alloc(0) : Buffer.isBuffer(r.body) ? r.body : Buffer.from(String(r.body));
|
|
410
|
-
// The wire status is a u16; anything HTTP cannot express is the program's bug, not a valid reply.
|
|
411
|
-
const status = r.status == null ? 200 : Number.isInteger(r.status) && r.status >= 100 && r.status <= 599 ? r.status : 502;
|
|
412
|
-
if (status === 502 && r.status !== 502) outHeaders['x-zoo-bad-status'] = String(r.status);
|
|
413
|
-
return { status, headers: outHeaders, body: out };
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// ---------------------------------------------------------------- /.zoo/*
|
|
417
|
-
|
|
418
|
-
async function serveZoo(state, pathname, req) {
|
|
419
|
-
if (pathname === `${ZOO_PREFIX}/manifest.json` || pathname === MANIFEST_PATH) {
|
|
420
|
-
await maybeRefreshManifest(state, true);
|
|
421
|
-
return json(200, { ...state.manifest, programId: state.manifest.programId || state.programIdStr }, { 'x-zoo-program': state.programIdStr });
|
|
422
|
-
}
|
|
423
|
-
if (pathname === `${ZOO_PREFIX}/status`) return json(200, await statusOf(state), { 'x-zoo-program': state.programIdStr });
|
|
424
|
-
if (pathname === ZOO_PREFIX || pathname === `${ZOO_PREFIX}/`) return html(200, explorerHtml(state), { 'x-zoo-program': state.programIdStr });
|
|
425
|
-
return null;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
export async function statusOf(state) {
|
|
429
|
-
let chain = null;
|
|
430
|
-
if (state.connection) {
|
|
431
|
-
try {
|
|
432
|
-
const [info, slot, siteInfo] = await Promise.all([getProgramInfo(state.connection, state.programId), state.connection.getSlot('confirmed'), state.site ? getSiteInfo(state.connection, state.programId, state.site) : null]);
|
|
433
|
-
chain = {
|
|
434
|
-
slot,
|
|
435
|
-
program: { exists: info.exists, executable: info.executable ?? null, authority: info.authority ? info.authority.toBase58() : null, deploySlot: info.slot, maxDataLen: info.maxDataLen, programData: info.programData.toBase58() },
|
|
436
|
-
site: siteInfo ? { id: state.siteStr, exists: siteInfo.exists, authority: siteInfo.authority ? siteInfo.authority.toBase58() : null, account: siteInfo.pda.toBase58() } : null,
|
|
437
|
-
};
|
|
438
|
-
} catch (e) { chain = { error: String(e?.message || e) }; }
|
|
439
|
-
}
|
|
440
|
-
return {
|
|
441
|
-
ok: true,
|
|
442
|
-
programId: state.programIdStr,
|
|
443
|
-
cluster: state.cluster,
|
|
444
|
-
rpc: state.rpc,
|
|
445
|
-
signer: state.keypair ? state.keypair.publicKey.toBase58() : null,
|
|
446
|
-
bodyLimit: state.bodyLimit,
|
|
447
|
-
uptimeMs: Date.now() - state.startedAt,
|
|
448
|
-
stats: state.stats,
|
|
449
|
-
manifest: { present: !state.manifest.missing, version: state.manifest.version ?? null, framework: state.manifest.framework ?? null, routes: state.manifest.routes.length, static: state.manifest.static ? state.manifest.static.length : null, deployedAt: state.manifest.deployedAt ?? null },
|
|
450
|
-
cache: { assets: state.assets.size, ttlMs: state.cacheTtlMs },
|
|
451
|
-
...(chain || {}),
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export function explorerHtml(state) {
|
|
456
|
-
const m = state.manifest;
|
|
457
|
-
const rows = m.routes.map((r) => {
|
|
458
|
-
const methods = r.methods && r.methods.length ? r.methods.join(', ') : 'any';
|
|
459
|
-
const kv = Array.isArray(r.kv) && r.kv.length ? r.kv.join(', ') : '';
|
|
460
|
-
const link = (!r.params || !r.params.length) && (!r.methods || r.methods.some((x) => /^(GET|HEAD)$/i.test(x))) ? `<a href="${escape(r.routePath)}">${escape(r.routePath)}</a>` : escape(r.routePath);
|
|
461
|
-
return `<tr><td>${r.index}</td><td>${link}</td><td><code>${escape(methods)}</code></td><td>${escape(r.style || '')}</td><td>${escape(r.name || '')}</td><td>${escape(kv)}</td></tr>`;
|
|
462
|
-
}).join('\n');
|
|
463
|
-
const assets = (m.static || []).map((s) => `<tr><td><a href="${escape(s.path)}">${escape(s.path)}</a></td><td>${escape(s.contentType || '')}</td><td style="text-align:right">${escape(s.size ?? '')}</td></tr>`).join('\n');
|
|
464
|
-
return `<!doctype html>
|
|
465
|
-
<meta charset="utf-8">
|
|
466
|
-
<title>openzoo-transmute · ${escape(m.name || state.programIdStr)}</title>
|
|
467
|
-
<style>
|
|
468
|
-
body{font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;margin:2rem auto;max-width:72rem;padding:0 1rem;color:#222;background:#fafafa}
|
|
469
|
-
h1{font-size:1.2rem}h2{font-size:1rem;margin-top:2rem}table{border-collapse:collapse;width:100%}
|
|
470
|
-
td,th{border-bottom:1px solid #ddd;padding:.3rem .5rem;text-align:left;vertical-align:top}th{background:#eee}
|
|
471
|
-
code{background:#eee;padding:0 .2rem}a{color:#0645ad}.muted{color:#777}
|
|
472
|
-
</style>
|
|
473
|
-
<h1>openzoo-transmute explorer</h1>
|
|
474
|
-
<p>program <code>${escape(state.programIdStr)}</code> on <code>${escape(state.cluster)}</code>${state.rpc ? ` (<span class="muted">${escape(state.rpc.replace(/\?.*$/, ''))}</span>)` : ''}<br>
|
|
475
|
-
signer: <code>${state.keypair ? escape(state.keypair.publicKey.toBase58()) : 'none — mutating requests answer 402'}</code><br>
|
|
476
|
-
framework: <code>${escape(m.framework || 'unknown')}</code>${m.deployedAt ? ` · deployed ${escape(m.deployedAt)}` : ''}${m.missing ? ' · <b>no manifest on chain</b>' : ''}</p>
|
|
477
|
-
<p><a href="${ZOO_PREFIX}/manifest.json">manifest.json</a> · <a href="${ZOO_PREFIX}/status">status</a></p>
|
|
478
|
-
<h2>functions (${m.routes.length})</h2>
|
|
479
|
-
<table><tr><th>#</th><th>route</th><th>methods</th><th>style</th><th>source</th><th>kv</th></tr>
|
|
480
|
-
${rows || '<tr><td colspan="6" class="muted">none</td></tr>'}
|
|
481
|
-
</table>
|
|
482
|
-
<h2>static assets (${m.static ? m.static.length : '?'})</h2>
|
|
483
|
-
<table><tr><th>path</th><th>content-type</th><th>bytes</th></tr>
|
|
484
|
-
${assets || `<tr><td colspan="3" class="muted">${m.static ? 'none' : 'no list in manifest; assets are looked up on chain per request'}</td></tr>`}
|
|
485
|
-
</table>
|
|
486
|
-
${m.env.length ? `<h2>environment</h2><p><code>${m.env.map(escape).join('</code> <code>')}</code></p>` : ''}
|
|
487
|
-
<p class="muted">reads are simulated transactions; writes are signed by the gateway wallet. Bodies are capped at ${state.bodyLimit} bytes.</p>
|
|
488
|
-
`;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
async function maybeRefreshManifest(state, force = false) {
|
|
492
|
-
if (!state.connection) return;
|
|
493
|
-
if (!force && Date.now() - state.manifestAt < state.cacheTtlMs) return;
|
|
494
|
-
if (state._refreshing) return state._refreshing;
|
|
495
|
-
state._refreshing = (async () => {
|
|
496
|
-
try {
|
|
497
|
-
const m = await readManifest(state.connection, state.programId);
|
|
498
|
-
if (m) state.manifest = normalizeManifest(m);
|
|
499
|
-
state.manifestAt = Date.now();
|
|
500
|
-
} catch (e) {
|
|
501
|
-
state.log(`manifest refresh failed: ${e?.message || e}`);
|
|
502
|
-
state.manifestAt = Date.now() - state.cacheTtlMs + 5000;
|
|
503
|
-
} finally { state._refreshing = null; }
|
|
504
|
-
})();
|
|
505
|
-
return force ? state._refreshing : undefined;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// ---------------------------------------------------------------- the request loop
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Route one request. `req` is `{method, url, headers, body, remoteAddress?}` (body: Buffer|string|null).
|
|
512
|
-
* Returns `{status, headers, body}`; the http wrapper writes it out.
|
|
513
|
-
*/
|
|
514
|
-
export async function handleRequest(state, req) {
|
|
515
|
-
state.stats.requests++;
|
|
516
|
-
const method = String(req.method || 'GET').toUpperCase();
|
|
517
|
-
req = { ...req, method, headers: lowerKeys(req.headers) };
|
|
518
|
-
if (!ALL_METHODS.includes(method)) return json(405, { error: 'method not allowed', method }, { allow: ALL_METHODS.join(', ') });
|
|
519
|
-
const { pathname, query } = parseUrl(req.url);
|
|
520
|
-
|
|
521
|
-
if (pathname === ZOO_PREFIX || pathname.startsWith(ZOO_PREFIX + '/') || pathname === MANIFEST_PATH) {
|
|
522
|
-
const r = await serveZoo(state, pathname, req);
|
|
523
|
-
if (r) return r;
|
|
524
|
-
return json(404, { error: 'not found', path: pathname });
|
|
525
|
-
}
|
|
526
|
-
void maybeRefreshManifest(state);
|
|
527
|
-
|
|
528
|
-
const phases = routePhases(state.manifest.config.routes);
|
|
529
|
-
let p = pathname, q = query;
|
|
530
|
-
const extraHeaders = {};
|
|
531
|
-
let lastMatch = null;
|
|
532
|
-
|
|
533
|
-
// Phase 1: routes before `handle: filesystem` (redirects, header rules, early rewrites).
|
|
534
|
-
const pre = applyPhase(phases.pre, p, q, method, extraHeaders);
|
|
535
|
-
if (pre.response) return pre.response;
|
|
536
|
-
p = pre.pathname; q = pre.query; lastMatch = pre.match || lastMatch;
|
|
537
|
-
|
|
538
|
-
// Phase 2: the filesystem — static files, then functions by exact output path.
|
|
539
|
-
let hit = await resolveFilesystem(state, p, method, lastMatch, pathname);
|
|
540
|
-
if (!hit) {
|
|
541
|
-
// Phase 3: routes after the filesystem marker (dynamic function routes, SPA fallbacks).
|
|
542
|
-
const post = applyPhase(phases.post, p, q, method, extraHeaders);
|
|
543
|
-
if (post.response) return post.response;
|
|
544
|
-
if (post.rewritten) {
|
|
545
|
-
p = post.pathname; q = post.query; lastMatch = post.match || lastMatch;
|
|
546
|
-
hit = await resolveFilesystem(state, p, method, lastMatch, pathname);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
if (!hit) {
|
|
550
|
-
// Manifests without a `config.routes` list still route by function pattern.
|
|
551
|
-
const fn = matchFunction(state, p, method);
|
|
552
|
-
if (fn) hit = { kind: 'function', ...fn };
|
|
553
|
-
}
|
|
554
|
-
if (hit?.kind === 'static') {
|
|
555
|
-
const res = await serveStatic(state, hit.path, req, extraHeaders);
|
|
556
|
-
if (res) return res;
|
|
557
|
-
// Listed in the manifest but not on chain (partial deploy): say so instead of falling through.
|
|
558
|
-
return json(404, { error: 'asset missing on chain', path: hit.path, hint: 'the manifest lists this file but its account is absent; run deploy again' }, { 'x-zoo-program': state.programIdStr });
|
|
559
|
-
}
|
|
560
|
-
if (hit?.kind === 'function') {
|
|
561
|
-
if (!hit.allowed) {
|
|
562
|
-
const allow = (hit.route.methods || []).map((m) => m.toUpperCase());
|
|
563
|
-
if (allow.includes('GET') && !allow.includes('HEAD')) allow.push('HEAD');
|
|
564
|
-
return json(405, { error: 'method not allowed', method, path: pathname, allow }, { allow: allow.join(', '), 'x-zoo-program': state.programIdStr });
|
|
565
|
-
}
|
|
566
|
-
return serveFunction(state, hit, req, p, q, extraHeaders);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Phase 4: error routes (custom 404 pages).
|
|
570
|
-
for (const r of phases.error) {
|
|
571
|
-
const m = matchRoute(r, p, method);
|
|
572
|
-
if (!m || !r.dest) continue;
|
|
573
|
-
const dest = resolveDest(r.dest, m).split('?')[0];
|
|
574
|
-
const res = await serveStatic(state, dest, req, extraHeaders);
|
|
575
|
-
if (res) return { ...res, status: res.status === 200 ? (r.status || 404) : res.status };
|
|
576
|
-
}
|
|
577
|
-
return json(404, { error: 'not found', path: pathname, method, hint: `no static file or function matched; see ${ZOO_PREFIX}/ for what this program serves` }, { 'x-zoo-program': state.programIdStr });
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function lowerKeys(h) {
|
|
581
|
-
const out = {};
|
|
582
|
-
for (const [k, v] of Object.entries(h || {})) out[k.toLowerCase()] = v;
|
|
583
|
-
return out;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/** Run one phase of `src`/`dest` routes against the current path. */
|
|
587
|
-
function applyPhase(routes, pathname, query, method, extraHeaders) {
|
|
588
|
-
let p = pathname, q = query, rewritten = false, match = null;
|
|
589
|
-
for (const r of routes) {
|
|
590
|
-
const m = matchRoute(r, p, method);
|
|
591
|
-
if (!m) continue;
|
|
592
|
-
if (r.headers) for (const [k, v] of Object.entries(r.headers)) extraHeaders[k.toLowerCase()] = resolveDest(String(v), m);
|
|
593
|
-
if (r.status && !r.dest) {
|
|
594
|
-
const loc = extraHeaders['location'];
|
|
595
|
-
return { response: { status: r.status, headers: { ...extraHeaders, 'content-type': JSON_CT }, body: Buffer.from(JSON.stringify(loc ? { redirect: loc } : { status: r.status })) } };
|
|
596
|
-
}
|
|
597
|
-
if (r.dest) {
|
|
598
|
-
const dest = resolveDest(r.dest, m);
|
|
599
|
-
if (/^https?:\/\//i.test(dest)) {
|
|
600
|
-
if (r.status && r.status >= 300 && r.status < 400) return { response: { status: r.status, headers: { ...extraHeaders, location: dest }, body: Buffer.alloc(0) } };
|
|
601
|
-
return { response: json(501, { error: 'external rewrite not supported', dest, hint: 'the on-chain program cannot proxy to other hosts' }) };
|
|
602
|
-
}
|
|
603
|
-
const qi = dest.indexOf('?');
|
|
604
|
-
p = qi >= 0 ? dest.slice(0, qi) : dest;
|
|
605
|
-
if (qi >= 0) q = mergeQuery(q, dest.slice(qi + 1));
|
|
606
|
-
if (r.status && r.status >= 300 && r.status < 400) return { response: { status: r.status, headers: { ...extraHeaders, location: p + (q ? '?' + q : '') }, body: Buffer.alloc(0) } };
|
|
607
|
-
rewritten = true; match = m;
|
|
608
|
-
}
|
|
609
|
-
if (!r.continue) break;
|
|
610
|
-
}
|
|
611
|
-
return { pathname: p, query: q, rewritten, match };
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
async function resolveFilesystem(state, p, method, lastMatch, originalPath) {
|
|
615
|
-
const st = await lookupStatic(state, p);
|
|
616
|
-
if (st) return { kind: 'static', path: st };
|
|
617
|
-
// A rewrite to a function's routePath (e.g. "/api/users/[id]") lands here.
|
|
618
|
-
const byPath = functionByRoutePath(state, p);
|
|
619
|
-
if (byPath) {
|
|
620
|
-
let params = {};
|
|
621
|
-
const re = byPath.pattern ? routeRegex(byPath.pattern, true) : null;
|
|
622
|
-
const m = re ? re.exec(originalPath) : null;
|
|
623
|
-
if (m) (byPath.params || []).forEach((name, i) => { if (m[i + 1] != null) params[name] = decodeURIComponentSafe(m[i + 1]); });
|
|
624
|
-
else if (lastMatch) (byPath.params || []).forEach((name, i) => { if (lastMatch[i + 1] != null) params[name] = decodeURIComponentSafe(lastMatch[i + 1]); });
|
|
625
|
-
return { kind: 'function', route: byPath, params, allowed: methodAllowed(byPath, method) };
|
|
626
|
-
}
|
|
627
|
-
const fn = matchFunction(state, p, method);
|
|
628
|
-
return fn ? { kind: 'function', ...fn } : null;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// ---------------------------------------------------------------- http
|
|
632
|
-
|
|
633
|
-
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
634
|
-
/** Hop-by-hop and framing headers the gateway owns; a handler's values must not reach the socket. */
|
|
635
|
-
const OWNED_HEADERS = new Set(['content-length', 'transfer-encoding', 'connection', 'keep-alive', 'upgrade', 'proxy-connection', 'te', 'trailer']);
|
|
636
|
-
|
|
637
|
-
/** Drop headers a program could use to break HTTP framing or that node would refuse to write. */
|
|
638
|
-
export function safeResponseHeaders(headers) {
|
|
639
|
-
const out = {};
|
|
640
|
-
for (const [k, v] of Object.entries(headers || {})) {
|
|
641
|
-
const name = String(k).toLowerCase();
|
|
642
|
-
if (OWNED_HEADERS.has(name) || !HEADER_NAME.test(name)) continue;
|
|
643
|
-
const val = Array.isArray(v) ? v.map(String).join(', ') : String(v ?? '');
|
|
644
|
-
if (/[\r\n\0]/.test(val)) continue;
|
|
645
|
-
out[name] = val;
|
|
646
|
-
}
|
|
647
|
-
return out;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function readBody(req, limit) {
|
|
651
|
-
return new Promise((resolve, reject) => {
|
|
652
|
-
const chunks = [];
|
|
653
|
-
let len = 0, tooBig = false;
|
|
654
|
-
req.on('data', (c) => {
|
|
655
|
-
if (tooBig) return;
|
|
656
|
-
len += c.length;
|
|
657
|
-
if (len > limit + 1) { tooBig = true; chunks.push(c.subarray(0, Math.max(0, limit + 2 - (len - c.length)))); return; }
|
|
658
|
-
chunks.push(c);
|
|
659
|
-
});
|
|
660
|
-
req.on('end', () => resolve({ body: Buffer.concat(chunks), truncated: tooBig, length: len }));
|
|
661
|
-
req.on('error', reject);
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* Serve a program over HTTP. Resolves once listening.
|
|
667
|
-
* @returns {Promise<{server:import('node:http').Server, port:number, url:string, state:object, close:()=>Promise<void>}>}
|
|
668
|
-
*/
|
|
669
|
-
export async function startGateway({ programId, site = null, cluster, port = DEFAULT_PORT, host = '127.0.0.1', keypair, manifest, invoke, connection, cacheTtlMs, log = console.log, quiet = false } = {}) {
|
|
670
|
-
const rpc = safeRpcUrl(cluster || process.env.OPENZOO_CLUSTER || 'localnet');
|
|
671
|
-
connection = connection ?? (rpc ? connect(rpc) : null);
|
|
672
|
-
const state = makeState({ programId, site, cluster, connection, keypair, manifest, invoke, cacheTtlMs, port, log: quiet ? () => {} : log });
|
|
673
|
-
if (!manifest && connection) {
|
|
674
|
-
try {
|
|
675
|
-
const m = await readManifest(connection, state.programId, state.site);
|
|
676
|
-
if (m) { state.manifest = normalizeManifest(m); state.manifestAt = Date.now(); }
|
|
677
|
-
else if (!quiet) log(`warning: no manifest at ${MANIFEST_PATH} for ${state.programIdStr}; routing by on-chain lookup only`);
|
|
678
|
-
} catch (e) { if (!quiet) log(`warning: could not read the manifest: ${e?.message || e}`); }
|
|
679
|
-
}
|
|
680
|
-
const server = http.createServer(async (req, res) => {
|
|
681
|
-
const t0 = Date.now();
|
|
682
|
-
try {
|
|
683
|
-
const { body, truncated, length } = await readBody(req, PACKET_DATA_SIZE);
|
|
684
|
-
const r = truncated
|
|
685
|
-
? json(413, { error: 'payload too large', limit: state.bodyLimit, received: length, message: `request bodies travel inside a Solana transaction (${PACKET_DATA_SIZE} bytes); at most ${state.bodyLimit} bytes of body` })
|
|
686
|
-
: await handleRequest(state, { method: req.method, url: req.url, headers: req.headers, body, remoteAddress: req.socket?.remoteAddress });
|
|
687
|
-
const out = Buffer.isBuffer(r.body) ? r.body : Buffer.from(String(r.body ?? ''));
|
|
688
|
-
const headers = safeResponseHeaders(r.headers);
|
|
689
|
-
headers['content-length'] = String(out.length);
|
|
690
|
-
res.writeHead(r.status, headers);
|
|
691
|
-
res.end(req.method === 'HEAD' ? undefined : out);
|
|
692
|
-
if (!quiet) log(`${req.method} ${req.url} → ${r.status} ${out.length}B ${Date.now() - t0}ms${headers['x-zoo-signature'] ? ' sig=' + headers['x-zoo-signature'].slice(0, 12) : ''}`);
|
|
693
|
-
} catch (e) {
|
|
694
|
-
if (!quiet) log(`${req.method} ${req.url} → 500 ${e?.stack || e}`);
|
|
695
|
-
if (!res.headersSent) { res.writeHead(500, { 'content-type': JSON_CT }); }
|
|
696
|
-
res.end(JSON.stringify({ error: 'gateway error', message: String(e?.message || e) }));
|
|
697
|
-
}
|
|
698
|
-
});
|
|
699
|
-
await new Promise((resolve, reject) => {
|
|
700
|
-
server.once('error', reject);
|
|
701
|
-
server.listen(port, host, () => { server.off('error', reject); resolve(); });
|
|
702
|
-
});
|
|
703
|
-
const actualPort = server.address().port;
|
|
704
|
-
state.port = actualPort;
|
|
705
|
-
const url = `http://${host}:${actualPort}`;
|
|
706
|
-
return {
|
|
707
|
-
server, port: actualPort, url, state,
|
|
708
|
-
close: () => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); }),
|
|
709
|
-
};
|
|
710
|
-
}
|