openzoo 0.51.2 → 0.51.3
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/bin/openzoo.js +12 -8
- package/package.json +2 -2
- package/transmute/README.md +42 -9
- package/transmute/lib/build.js +72 -2
- package/transmute/lib/cli.js +73 -17
- package/transmute/lib/compile/bytecode.js +591 -0
- package/transmute/lib/compile/hostids.js +11 -0
- package/transmute/lib/compile/index.js +16 -3
- package/transmute/lib/deploy.js +149 -1
- package/transmute/lib/gateway.js +14 -8
- package/transmute/lib/hub.js +13 -3
- package/transmute/lib/solana.js +67 -21
- package/transmute/lib/wire.js +42 -1
- package/transmute/prebuilt/zoo_vm.v0.so +0 -0
- package/transmute/runtime/zoo-host/examples/vmrun.rs +43 -0
- package/transmute/runtime/zoo-host/src/assets.rs +53 -10
- package/transmute/runtime/zoo-host/src/ctx.rs +15 -0
- package/transmute/runtime/zoo-host/src/helpers.rs +245 -0
- package/transmute/runtime/zoo-host/src/kv.rs +29 -8
- package/transmute/runtime/zoo-host/src/lib.rs +13 -0
- package/transmute/runtime/zoo-host/src/site.rs +94 -0
- package/transmute/runtime/zoo-host/src/vm.rs +902 -0
- package/transmute/runtime/zoo-vm/Cargo.lock +596 -0
- package/transmute/runtime/zoo-vm/Cargo.toml +23 -0
- package/transmute/runtime/zoo-vm/src/lib.rs +92 -0
- package/transmute/site/package.json +6 -0
- package/transmute/site/public/index.html +4 -4
package/transmute/lib/deploy.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { Keypair, PublicKey } from '@solana/web3.js';
|
|
12
|
-
import { MANIFEST_PATH } from './wire.js';
|
|
12
|
+
import { MANIFEST_PATH, CODE_PATH } from './wire.js';
|
|
13
13
|
import { connect, deployProgram, upgradeProgram, getProgramInfo, putAsset, detectSbpfArch, pool, assetRent } from './solana.js';
|
|
14
|
+
import { initSite, getSiteInfo } from './solana.js';
|
|
14
15
|
import { loadWallet, rpcUrl } from './wallet.js';
|
|
15
16
|
import { runCargoBuildSbf, estimateCost, formatCostTable, LAMPORTS_PER_SOL, INSTALL_HINT } from './build.js';
|
|
16
17
|
import { DEFAULT_PORT } from './gateway.js';
|
|
@@ -83,6 +84,7 @@ export async function deploy(o = {}) {
|
|
|
83
84
|
const buildInfo = readJson(path.join(zooDir, 'build.json'), {});
|
|
84
85
|
const staticPlan = readJson(path.join(zooDir, 'static-plan.json'), []);
|
|
85
86
|
const crateDir = path.join(zooDir, 'crate');
|
|
87
|
+
if ((buildInfo.target || manifest.target) === 'shared') return deployShared(o, { log, outDir, zooDir, manifest, buildInfo, staticPlan });
|
|
86
88
|
|
|
87
89
|
const rpc = o.connection?.rpcEndpoint || rpcUrl(o.cluster);
|
|
88
90
|
const cluster = clusterLabel(o.cluster);
|
|
@@ -207,3 +209,149 @@ export async function deploy(o = {}) {
|
|
|
207
209
|
export async function assetRentSol(connection, size, contentType = '') {
|
|
208
210
|
return (await assetRent(connection, size, contentType)) / LAMPORTS_PER_SOL;
|
|
209
211
|
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------- shared runtime
|
|
214
|
+
|
|
215
|
+
/** Load or create the site keypair: its public key is the site id (looks like a program id, costs nothing). */
|
|
216
|
+
export function siteKeypairFor(zooDir, { create = true } = {}) {
|
|
217
|
+
const p = path.join(zooDir, 'site-keypair.json');
|
|
218
|
+
if (fs.existsSync(p)) return { keypair: Keypair.fromSecretKey(Uint8Array.from(readJson(p))), path: p, created: false };
|
|
219
|
+
if (!create) return null;
|
|
220
|
+
const kp = Keypair.generate();
|
|
221
|
+
fs.writeFileSync(p, JSON.stringify([...kp.secretKey]), { mode: 0o600 });
|
|
222
|
+
return { keypair: kp, path: p, created: true };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Deploy to the shared runtime: no program, only accounts — the site account,
|
|
227
|
+
* the bytecode module at /.zoo/code.bin, the static assets and the manifest,
|
|
228
|
+
* all PDAs of the runtime program namespaced by the site id.
|
|
229
|
+
*/
|
|
230
|
+
async function deployShared(o, { log, outDir, zooDir, manifest, buildInfo, staticPlan }) {
|
|
231
|
+
const runtime = o.runtime || process.env.OPENZOO_VM_PROGRAM || manifest.runtime || buildInfo.runtime;
|
|
232
|
+
if (!runtime) throw new Error('shared target needs the runtime program id: --runtime <id> (or OPENZOO_VM_PROGRAM)');
|
|
233
|
+
const rpc = o.connection?.rpcEndpoint || rpcUrl(o.cluster);
|
|
234
|
+
const cluster = clusterLabel(o.cluster);
|
|
235
|
+
const connection = o.connection || connect(rpc);
|
|
236
|
+
let mainnet = isMainnet(o.cluster, rpc);
|
|
237
|
+
const genesis = await genesisCluster(connection);
|
|
238
|
+
if (genesis === 'mainnet' && !mainnet) { mainnet = true; log(`note: ${rpc.replace(/\?.*$/, '')} serves mainnet (genesis hash); treating it as mainnet`); }
|
|
239
|
+
const wallet = o.wallet || loadWallet({ keypair: o.keypair });
|
|
240
|
+
const payer = wallet.keypair;
|
|
241
|
+
const codePath = buildInfo.codePath && fs.existsSync(buildInfo.codePath) ? buildInfo.codePath : path.join(zooDir, 'code.bin');
|
|
242
|
+
if (!fs.existsSync(codePath)) throw new Error(`no bytecode module at ${codePath}; run build --target shared first`);
|
|
243
|
+
const code = fs.readFileSync(codePath);
|
|
244
|
+
const siteKp = o.site ? null : siteKeypairFor(zooDir);
|
|
245
|
+
const site = o.site ? new PublicKey(o.site) : siteKp.keypair.publicKey;
|
|
246
|
+
const runtimeInfo = await getProgramInfo(connection, runtime);
|
|
247
|
+
if (!runtimeInfo.exists) throw new Error(`runtime program ${runtime} is not deployed on ${cluster}`);
|
|
248
|
+
log(`cluster ${cluster} (${rpc.replace(/\?.*$/, '')}) · payer ${payer.publicKey.toBase58()} · runtime ${new PublicKey(runtime).toBase58()} · site ${site.toBase58()}${siteKp?.created ? ' (new)' : ''}`);
|
|
249
|
+
|
|
250
|
+
// Cost: site account + code + assets + manifest, all rent-exempt accounts.
|
|
251
|
+
const manifestOut = { ...manifest, target: 'shared', runtime: new PublicKey(runtime).toBase58(), site: site.toBase58(), cluster, rpc: rpc.replace(/\?.*$/, ''), deployedAt: new Date().toISOString() };
|
|
252
|
+
const manifestBytes = Buffer.byteLength(JSON.stringify(manifestOut));
|
|
253
|
+
const items = [{ label: 'site account', bytes: 66 }, { label: `code module (${code.length.toLocaleString()} B)`, bytes: code.length + 20 }];
|
|
254
|
+
for (const f of staticPlan) items.push({ label: f.path, bytes: (f.size ?? 0) + 7 + (f.contentType || '').length });
|
|
255
|
+
items.push({ label: '/.zoo/manifest.json', bytes: manifestBytes + 40 });
|
|
256
|
+
let total = 0;
|
|
257
|
+
for (const it of items) { it.lamports = await connection.getMinimumBalanceForRentExemption(it.bytes); total += it.lamports; }
|
|
258
|
+
const fees = 5000 * (3 + staticPlan.length * 3 + Math.ceil(code.length / 900) + 4);
|
|
259
|
+
log(' item bytes SOL');
|
|
260
|
+
for (const it of items) log(` ${it.label.padEnd(34)}${String(it.bytes).padStart(8)} ${(it.lamports / LAMPORTS_PER_SOL).toFixed(4).padStart(8)}`);
|
|
261
|
+
log(` ${'rent total'.padEnd(34)}${''.padStart(8)} ${(total / LAMPORTS_PER_SOL).toFixed(4).padStart(8)}`);
|
|
262
|
+
log(` ${'+ tx fees (approx.)'.padEnd(34)}${''.padStart(8)} ${(fees / LAMPORTS_PER_SOL).toFixed(4).padStart(8)}`);
|
|
263
|
+
log(` ${'TOTAL'.padEnd(34)}${''.padStart(8)} ${((total + fees) / LAMPORTS_PER_SOL).toFixed(4).padStart(8)}`);
|
|
264
|
+
if (mainnet && !o.yes) throw new Error('this is mainnet: pass --yes to spend the amount above');
|
|
265
|
+
const balance = await connection.getBalance(payer.publicKey);
|
|
266
|
+
if (balance < total + fees) throw new Error(`payer has ${(balance / LAMPORTS_PER_SOL).toFixed(4)} SOL, needs ~${((total + fees) / LAMPORTS_PER_SOL).toFixed(4)}`);
|
|
267
|
+
|
|
268
|
+
const t0 = Date.now();
|
|
269
|
+
const si = await initSite(connection, { authority: payer, runtime, site });
|
|
270
|
+
log(`${si.created ? 'created' : 'reusing'} site account ${si.pda.toBase58()}`);
|
|
271
|
+
const cr = await putAsset(connection, { authority: payer, programId: runtime, site, path: CODE_PATH, contentType: 'application/x-zoo-bytecode', data: code, force: o.force });
|
|
272
|
+
log(`code → ${CODE_PATH} (${cr.pda.toBase58()}, ${code.length.toLocaleString()} B${cr.skipped ? ', unchanged' : ''})`);
|
|
273
|
+
let uploaded = 0, skipped = 0, failed = 0;
|
|
274
|
+
const failures = [];
|
|
275
|
+
if (!o.skipAssets) {
|
|
276
|
+
const files = staticPlan.filter((f) => f.file || f.path);
|
|
277
|
+
let done = 0;
|
|
278
|
+
await pool(files, async (f) => {
|
|
279
|
+
try {
|
|
280
|
+
const data = fs.readFileSync(f.file);
|
|
281
|
+
const r = await putAsset(connection, { authority: payer, programId: runtime, site, path: f.path, contentType: f.contentType || 'application/octet-stream', data, force: o.force });
|
|
282
|
+
if (r.skipped) skipped++; else uploaded++;
|
|
283
|
+
log(`[${++done}/${files.length}] ${f.path} ${r.skipped ? 'unchanged' : `${data.length.toLocaleString()} B in ${r.txs} tx`}`);
|
|
284
|
+
} catch (e) { failed++; failures.push({ path: f.path, error: String(e?.message || e) }); log(`[${++done}/${files.length}] ${f.path} FAILED: ${e?.message || e}`); }
|
|
285
|
+
}, { concurrency: o.concurrency ? Number(o.concurrency) : 4 });
|
|
286
|
+
log(`assets: ${uploaded} uploaded, ${skipped} unchanged, ${failed} failed`);
|
|
287
|
+
}
|
|
288
|
+
const mr = await putAsset(connection, { authority: payer, programId: runtime, site, path: MANIFEST_PATH, contentType: MANIFEST_CONTENT_TYPE, data: Buffer.from(JSON.stringify(manifestOut)), force: true });
|
|
289
|
+
log(`manifest → ${MANIFEST_PATH} (${mr.pda.toBase58()}, ${manifestBytes.toLocaleString()} B) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
290
|
+
const out = { target: 'shared', site: site.toBase58(), programId: site.toBase58(), runtime: new PublicKey(runtime).toBase58(), sitePda: si.pda.toBase58(), manifestPda: mr.pda.toBase58(), assets: { uploaded, skipped, failed }, cluster, rpc, deployedAt: manifestOut.deployedAt, failures, url: `${process.env.OPENZOO_HUB_URL || 'https://sites.openzoo.fun'}/s/${site.toBase58()}` };
|
|
291
|
+
writeJson(path.join(zooDir, 'deploy.json'), out);
|
|
292
|
+
log(`\nserve it: openzoo-transmute serve ${site.toBase58()} --runtime ${new PublicKey(runtime).toBase58()} --cluster ${cluster}`);
|
|
293
|
+
log(`or open ${out.url}`);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------- runtime program
|
|
298
|
+
|
|
299
|
+
const HERE_DEPLOY = path.dirname(new URL(import.meta.url).pathname);
|
|
300
|
+
export const PREBUILT_DIR = path.resolve(HERE_DEPLOY, '..', 'prebuilt');
|
|
301
|
+
export const RUNTIME_CRATE = path.resolve(HERE_DEPLOY, '..', 'runtime', 'zoo-vm');
|
|
302
|
+
|
|
303
|
+
/** The zoo-vm .so for an SBPF arch: prebuilt (v0 = mainnet) or built from runtime/zoo-vm. */
|
|
304
|
+
export async function runtimeSoFor(arch, { log = console.log, so } = {}) {
|
|
305
|
+
if (so) return { soPath: path.resolve(so), source: 'given' };
|
|
306
|
+
const pre = path.join(PREBUILT_DIR, `zoo_vm.${arch}.so`);
|
|
307
|
+
if (fs.existsSync(pre)) return { soPath: pre, source: 'prebuilt' };
|
|
308
|
+
if (!fs.existsSync(path.join(RUNTIME_CRATE, 'Cargo.toml'))) throw new Error(`no prebuilt zoo-vm for ${arch} (${pre}) and no runtime/zoo-vm crate to build it from`);
|
|
309
|
+
const r = await runCargoBuildSbf(RUNTIME_CRATE, { arch, outDir: path.join(RUNTIME_CRATE, 'target', 'deploy-' + arch), log });
|
|
310
|
+
if (!r.ok) throw new Error(r.missing ? `no prebuilt zoo-vm for ${arch}; install the toolchain to build it: ${INSTALL_HINT}` : `zoo-vm build failed: ${r.error}`);
|
|
311
|
+
return { soPath: r.soPath, source: 'built' };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Deploy (or upgrade) the shared runtime program once per cluster. Sites then
|
|
316
|
+
* cost only their accounts. Keypair kept at `programKeypair` (default
|
|
317
|
+
* ./zoo-vm-keypair.json) so a redeploy upgrades in place.
|
|
318
|
+
*/
|
|
319
|
+
export async function deployRuntime(o = {}) {
|
|
320
|
+
const log = o.log ?? console.log;
|
|
321
|
+
const rpc = rpcUrl(o.cluster);
|
|
322
|
+
const cluster = clusterLabel(o.cluster);
|
|
323
|
+
const connection = o.connection || connect(rpc);
|
|
324
|
+
let mainnet = isMainnet(o.cluster, rpc);
|
|
325
|
+
if ((await genesisCluster(connection)) === 'mainnet') mainnet = true;
|
|
326
|
+
const wallet = o.wallet || loadWallet({ keypair: o.keypair });
|
|
327
|
+
const payer = wallet.keypair;
|
|
328
|
+
const arch = await detectSbpfArch(connection);
|
|
329
|
+
const { soPath, source } = await runtimeSoFor(arch, { log, so: o.so });
|
|
330
|
+
const so = fs.readFileSync(soPath);
|
|
331
|
+
const kpPath = path.resolve(o.programKeypair || 'zoo-vm-keypair.json');
|
|
332
|
+
let programKeypair;
|
|
333
|
+
if (fs.existsSync(kpPath)) programKeypair = Keypair.fromSecretKey(Uint8Array.from(readJson(kpPath)));
|
|
334
|
+
else { programKeypair = Keypair.generate(); fs.writeFileSync(kpPath, JSON.stringify([...programKeypair.secretKey]), { mode: 0o600 }); log(`new runtime keypair → ${kpPath} (keep it: redeploys upgrade in place)`); }
|
|
335
|
+
const programId = programKeypair.publicKey;
|
|
336
|
+
const info = await getProgramInfo(connection, programId);
|
|
337
|
+
const upgrade = info.exists;
|
|
338
|
+
const est = await estimateCost({ staticFiles: [], soSize: so.length, manifestBytes: 0, connection, upgrade });
|
|
339
|
+
est.items = est.items.filter((i) => i.kind !== 'asset');
|
|
340
|
+
est.lamports = est.items.filter((i) => !i.transient).reduce((n, i) => n + i.lamports, 0); est.sol = est.lamports / LAMPORTS_PER_SOL;
|
|
341
|
+
est.totalLamports = est.lamports + est.transientLamports + est.feeLamports; est.totalSol = est.totalLamports / LAMPORTS_PER_SOL;
|
|
342
|
+
log(`cluster ${cluster} (${rpc.replace(/\?.*$/, '')}) · payer ${payer.publicKey.toBase58()} · runtime ${programId.toBase58()} (${upgrade ? 'upgrade' : 'new'}) · ${path.basename(soPath)} ${so.length.toLocaleString()} B ${arch} (${source})`);
|
|
343
|
+
log(formatCostTable(est));
|
|
344
|
+
const bal = await connection.getBalance(payer.publicKey);
|
|
345
|
+
if (bal < est.totalLamports) throw new Error(`payer has ${(bal / LAMPORTS_PER_SOL).toFixed(4)} SOL, needs ≈ ${est.totalSol.toFixed(4)} SOL`);
|
|
346
|
+
if (mainnet && !o.yes) throw new Error(`refusing to spend ≈ ${est.totalSol.toFixed(4)} SOL on mainnet without --yes`);
|
|
347
|
+
let done = 0; const total = Math.ceil(so.length / 900);
|
|
348
|
+
const onProgress = (n) => { done += 1; if (done % 25 === 0 || done === total) log(` wrote ${Math.min(done, total)}/${total} chunks`); };
|
|
349
|
+
const t0 = Date.now();
|
|
350
|
+
const r = upgrade
|
|
351
|
+
? await upgradeProgram(connection, { payer, programId, so, onProgress })
|
|
352
|
+
: await deployProgram(connection, { payer, programKeypair, so, onProgress, headroom: o.headroom ? Number(o.headroom) : 1 });
|
|
353
|
+
log(`${upgrade ? 'upgraded' : 'deployed'} runtime ${programId.toBase58()} in ${((Date.now() - t0) / 1000).toFixed(1)}s (${r.signature})`);
|
|
354
|
+
log(`\nsites now deploy against it: openzoo-transmute build <dir> --target shared && openzoo-transmute deploy <dir> --runtime ${programId.toBase58()} --cluster ${cluster}`);
|
|
355
|
+
log(`hub: OPENZOO_VM_PROGRAM=${programId.toBase58()}`);
|
|
356
|
+
return { programId: programId.toBase58(), signature: r.signature, upgrade, arch, soPath, cluster, rpc, keypairPath: kpPath };
|
|
357
|
+
}
|
package/transmute/lib/gateway.js
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
import http from 'node:http';
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
import { PublicKey, Transaction, TransactionInstruction, ComputeBudgetProgram, SystemProgram } from '@solana/web3.js';
|
|
18
|
-
import { FORWARDED_HEADERS, MANIFEST_PATH, assetPda, decodeAsset, encodeInvoke } from './wire.js';
|
|
19
|
-
import { connect, invoke as chainInvoke, readManifest, getProgramInfo, DEFAULT_CU, DEFAULT_HEAP } from './solana.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
20
|
import { rpcUrl } from './wallet.js';
|
|
21
21
|
|
|
22
22
|
export const DEFAULT_PORT = 4402;
|
|
@@ -46,9 +46,14 @@ const HTML_CT = 'text/html; charset=utf-8';
|
|
|
46
46
|
export function makeState(o) {
|
|
47
47
|
const programId = new PublicKey(o.programId);
|
|
48
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;
|
|
49
52
|
const state = {
|
|
50
53
|
programId,
|
|
51
54
|
programIdStr: programId.toBase58(),
|
|
55
|
+
site,
|
|
56
|
+
siteStr: site ? site.toBase58() : null,
|
|
52
57
|
cluster,
|
|
53
58
|
rpc: o.connection?.rpcEndpoint || safeRpcUrl(cluster),
|
|
54
59
|
connection: o.connection ?? null,
|
|
@@ -103,7 +108,7 @@ export async function getAsset(state, path) {
|
|
|
103
108
|
const cached = state.assets.get(path);
|
|
104
109
|
if (cached && now - cached.at < state.cacheTtlMs) return cached;
|
|
105
110
|
if (!state.connection) return cached ?? { missing: true, at: now };
|
|
106
|
-
const [pda] =
|
|
111
|
+
const [pda] = assetPdaFor(state.programId, state.site, path);
|
|
107
112
|
let entry;
|
|
108
113
|
try {
|
|
109
114
|
const info = await state.connection.getAccountInfo(pda);
|
|
@@ -384,7 +389,7 @@ async function serveFunction(state, hit, req, pathname, query, extraHeaders = {}
|
|
|
384
389
|
state.stats.invokes++;
|
|
385
390
|
let r;
|
|
386
391
|
try {
|
|
387
|
-
r = await state.invoke({ programId: state.programId, payer, event, mutate });
|
|
392
|
+
r = await state.invoke({ programId: state.programId, site: state.site, payer, event, mutate });
|
|
388
393
|
} catch (e) {
|
|
389
394
|
const msg = String(e?.message || e);
|
|
390
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 });
|
|
@@ -424,10 +429,11 @@ export async function statusOf(state) {
|
|
|
424
429
|
let chain = null;
|
|
425
430
|
if (state.connection) {
|
|
426
431
|
try {
|
|
427
|
-
const [info, slot] = await Promise.all([getProgramInfo(state.connection, state.programId), state.connection.getSlot('confirmed')]);
|
|
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]);
|
|
428
433
|
chain = {
|
|
429
434
|
slot,
|
|
430
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,
|
|
431
437
|
};
|
|
432
438
|
} catch (e) { chain = { error: String(e?.message || e) }; }
|
|
433
439
|
}
|
|
@@ -660,13 +666,13 @@ function readBody(req, limit) {
|
|
|
660
666
|
* Serve a program over HTTP. Resolves once listening.
|
|
661
667
|
* @returns {Promise<{server:import('node:http').Server, port:number, url:string, state:object, close:()=>Promise<void>}>}
|
|
662
668
|
*/
|
|
663
|
-
export async function startGateway({ programId, cluster, port = DEFAULT_PORT, host = '127.0.0.1', keypair, manifest, invoke, connection, cacheTtlMs, log = console.log, quiet = false } = {}) {
|
|
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 } = {}) {
|
|
664
670
|
const rpc = safeRpcUrl(cluster || process.env.OPENZOO_CLUSTER || 'localnet');
|
|
665
671
|
connection = connection ?? (rpc ? connect(rpc) : null);
|
|
666
|
-
const state = makeState({ programId, cluster, connection, keypair, manifest, invoke, cacheTtlMs, port, log: quiet ? () => {} : log });
|
|
672
|
+
const state = makeState({ programId, site, cluster, connection, keypair, manifest, invoke, cacheTtlMs, port, log: quiet ? () => {} : log });
|
|
667
673
|
if (!manifest && connection) {
|
|
668
674
|
try {
|
|
669
|
-
const m = await readManifest(connection, state.programId);
|
|
675
|
+
const m = await readManifest(connection, state.programId, state.site);
|
|
670
676
|
if (m) { state.manifest = normalizeManifest(m); state.manifestAt = Date.now(); }
|
|
671
677
|
else if (!quiet) log(`warning: no manifest at ${MANIFEST_PATH} for ${state.programIdStr}; routing by on-chain lookup only`);
|
|
672
678
|
} catch (e) { if (!quiet) log(`warning: could not read the manifest: ${e?.message || e}`); }
|
package/transmute/lib/hub.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import http from 'node:http';
|
|
21
21
|
import { PublicKey } from '@solana/web3.js';
|
|
22
22
|
import { makeState, normalizeManifest, handleRequest, safeResponseHeaders, PACKET_DATA_SIZE, BODY_LIMIT } from './gateway.js';
|
|
23
|
-
import { connect, readManifest } from './solana.js';
|
|
23
|
+
import { connect, readManifest, getSiteInfo } from './solana.js';
|
|
24
24
|
import { rpcUrl } from './wallet.js';
|
|
25
25
|
import { MANIFEST_PATH } from './wire.js';
|
|
26
26
|
|
|
@@ -80,6 +80,9 @@ export function makeHub(o = {}) {
|
|
|
80
80
|
startedAt: Date.now(),
|
|
81
81
|
stats: { requests: 0, sitesLoaded: 0, writes: 0, writesRefused: 0, spentLamports: 0 },
|
|
82
82
|
publicUrl: o.publicUrl || null,
|
|
83
|
+
// The shared runtime program on this cluster: ids that are site accounts
|
|
84
|
+
// under it are served as shared sites; anything else as a compiled program.
|
|
85
|
+
runtime: o.runtime || process.env.OPENZOO_VM_PROGRAM || null,
|
|
83
86
|
// write governor: per-IP token bucket + a daily lamport budget for the signer
|
|
84
87
|
writesPerMin: o.writesPerMin ?? Number(process.env.OPENZOO_HUB_WRITES_PER_MIN || 3),
|
|
85
88
|
budgetLamports: Math.round((o.writeBudgetSol ?? Number(process.env.OPENZOO_HUB_WRITE_BUDGET_SOL || 0.05)) * 1e9),
|
|
@@ -115,9 +118,15 @@ async function siteFor(hub, programId) {
|
|
|
115
118
|
let state;
|
|
116
119
|
if (hub.makeSite) state = await hub.makeSite(programId);
|
|
117
120
|
else {
|
|
118
|
-
|
|
121
|
+
let shared = false;
|
|
122
|
+
if (hub.runtime) {
|
|
123
|
+
try { shared = (await getSiteInfo(hub.connection, hub.runtime, programId)).exists; } catch { /* fall through to program mode */ }
|
|
124
|
+
}
|
|
125
|
+
state = shared
|
|
126
|
+
? makeState({ programId: hub.runtime, site: programId, cluster: hub.cluster, connection: hub.connection, keypair: hub.keypair, log: hub.log, cacheTtlMs: hub.manifestTtlMs })
|
|
127
|
+
: makeState({ programId, cluster: hub.cluster, connection: hub.connection, keypair: hub.keypair, log: hub.log, cacheTtlMs: hub.manifestTtlMs });
|
|
119
128
|
try {
|
|
120
|
-
const m = await readManifest(hub.connection, state.programId);
|
|
129
|
+
const m = await readManifest(hub.connection, state.programId, state.site);
|
|
121
130
|
if (m) { state.manifest = normalizeManifest(m); state.manifestAt = Date.now(); }
|
|
122
131
|
else state.noManifest = true;
|
|
123
132
|
} catch (e) { state.manifestError = String(e?.message || e); }
|
|
@@ -135,6 +144,7 @@ function siteSummary(id, entry) {
|
|
|
135
144
|
const m = entry.state.manifest || {};
|
|
136
145
|
return {
|
|
137
146
|
programId: id,
|
|
147
|
+
mode: entry.state.site ? 'shared' : 'program',
|
|
138
148
|
name: m.name || null,
|
|
139
149
|
framework: m.framework || null,
|
|
140
150
|
routes: (m.routes || []).length,
|
package/transmute/lib/solana.js
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
BPF_LOADER_UPGRADEABLE, ERR_KV_MISSING, encodeInvoke, parseLogs, decodeResponse, programDataPda,
|
|
10
10
|
assetPda, encodeAssetInit, encodeAssetWrite, encodeAssetClose, decodeAsset, ASSET_FIXED_HEADER,
|
|
11
11
|
ASSET_MAX_INITIAL, kvPda, decodeKv, MANIFEST_PATH,
|
|
12
|
+
sitePda, assetPdaFor, kvPdaFor, encodeSiteInit, encodeVmInvoke, encodeVmAssetInit, encodeVmAssetWrite, encodeVmAssetClose,
|
|
13
|
+
decodeSite, CODE_PATH,
|
|
12
14
|
} from './wire.js';
|
|
13
15
|
|
|
14
16
|
export const WRITE_CHUNK = 900;
|
|
@@ -158,10 +160,11 @@ export async function upgradeProgram(connection, { payer, authority = payer, pro
|
|
|
158
160
|
|
|
159
161
|
// ---------------------------------------------------------------- assets
|
|
160
162
|
|
|
161
|
-
function assetKeys(authority, programId, pda) {
|
|
163
|
+
function assetKeys(authority, programId, pda, site = null) {
|
|
162
164
|
return [
|
|
163
165
|
{ pubkey: authority.publicKey, isSigner: true, isWritable: true },
|
|
164
|
-
|
|
166
|
+
// proof of authority: the program's ProgramData (compiled site) or the site account (shared runtime)
|
|
167
|
+
{ pubkey: site ? sitePda(programId, site)[0] : programDataPda(programId), isSigner: false, isWritable: false },
|
|
165
168
|
{ pubkey: pda, isSigner: false, isWritable: true },
|
|
166
169
|
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
|
167
170
|
];
|
|
@@ -176,55 +179,59 @@ export async function assetRent(connection, len, contentType = '') {
|
|
|
176
179
|
* Upload one asset (create/re-init + chunked writes). Skips the upload when
|
|
177
180
|
* the on-chain bytes already match.
|
|
178
181
|
*/
|
|
179
|
-
export async function putAsset(connection, { authority, programId, path, contentType, data, onProgress, force = false }) {
|
|
182
|
+
export async function putAsset(connection, { authority, programId, site = null, path, contentType, data, onProgress, force = false }) {
|
|
180
183
|
const pid = new PublicKey(programId);
|
|
181
|
-
const [pda] =
|
|
184
|
+
const [pda] = assetPdaFor(pid, site, path);
|
|
185
|
+
const initData = site ? encodeVmAssetInit(site, path, data.length, contentType) : encodeAssetInit(path, data.length, contentType);
|
|
186
|
+
// The shared-runtime write carries the 32-byte site id; keep the tx ≤ 1232 B.
|
|
187
|
+
const chunk = site ? WRITE_CHUNK - 32 : WRITE_CHUNK;
|
|
188
|
+
const writeData = (off) => site ? encodeVmAssetWrite(site, path, off, data.subarray(off, off + chunk)) : encodeAssetWrite(path, off, data.subarray(off, off + chunk));
|
|
182
189
|
const existing = await connection.getAccountInfo(pda);
|
|
183
190
|
if (existing && !force) {
|
|
184
191
|
const cur = decodeAsset(existing.data);
|
|
185
192
|
if (cur && cur.complete && cur.contentType === contentType && cur.data.equals(data)) return { pda, skipped: true };
|
|
186
193
|
}
|
|
187
|
-
const keys = assetKeys(authority, pid, pda);
|
|
188
|
-
await sendTx(connection, [new TransactionInstruction({ programId: pid, keys, data:
|
|
194
|
+
const keys = assetKeys(authority, pid, pda, site);
|
|
195
|
+
await sendTx(connection, [new TransactionInstruction({ programId: pid, keys, data: initData })], [authority], { label: `init ${path}` });
|
|
189
196
|
const header = ASSET_FIXED_HEADER + Buffer.byteLength(contentType.slice(0, 120));
|
|
190
197
|
// The first `ASSET_MAX_INITIAL - header` bytes fit in the created account;
|
|
191
198
|
// later chunks grow it ≤10 KB per tx. Both are just sequential offsets.
|
|
192
199
|
const chunks = [];
|
|
193
|
-
for (let off = 0; off < data.length; off +=
|
|
200
|
+
for (let off = 0; off < data.length; off += chunk) chunks.push(off);
|
|
194
201
|
// Writes past the initial allocation resize the account, and a resize's
|
|
195
202
|
// budget is measured from the length at instruction entry; keep those in
|
|
196
203
|
// order so no two growing writes race each other.
|
|
197
204
|
const initialCap = Math.max(0, ASSET_MAX_INITIAL - header);
|
|
198
|
-
const inPlace = chunks.filter((o) => o +
|
|
199
|
-
const growing = chunks.filter((o) => o +
|
|
200
|
-
const write = (off) => sendTx(connection, [new TransactionInstruction({ programId: pid, keys, data:
|
|
205
|
+
const inPlace = chunks.filter((o) => o + chunk <= initialCap);
|
|
206
|
+
const growing = chunks.filter((o) => o + chunk > initialCap);
|
|
207
|
+
const write = (off) => sendTx(connection, [new TransactionInstruction({ programId: pid, keys, data: writeData(off) })], [authority], { label: `write ${path}@${off}` });
|
|
201
208
|
let done = 0;
|
|
202
209
|
await pool(inPlace, async (off) => { await write(off); onProgress?.(++done, chunks.length); }, { concurrency: 8 });
|
|
203
210
|
for (const off of growing) { await write(off); onProgress?.(++done, chunks.length); }
|
|
204
211
|
return { pda, skipped: false, txs: 1 + chunks.length };
|
|
205
212
|
}
|
|
206
213
|
|
|
207
|
-
export async function closeAsset(connection, { authority, programId, path }) {
|
|
214
|
+
export async function closeAsset(connection, { authority, programId, site = null, path }) {
|
|
208
215
|
const pid = new PublicKey(programId);
|
|
209
|
-
const [pda] =
|
|
210
|
-
return sendTx(connection, [new TransactionInstruction({ programId: pid, keys: assetKeys(authority, pid, pda), data: encodeAssetClose(path) })], [authority], { label: `close ${path}` });
|
|
216
|
+
const [pda] = assetPdaFor(pid, site, path);
|
|
217
|
+
return sendTx(connection, [new TransactionInstruction({ programId: pid, keys: assetKeys(authority, pid, pda, site), data: site ? encodeVmAssetClose(site, path) : encodeAssetClose(path) })], [authority], { label: `close ${path}` });
|
|
211
218
|
}
|
|
212
219
|
|
|
213
|
-
export async function readAsset(connection, programId, path) {
|
|
214
|
-
const [pda] =
|
|
220
|
+
export async function readAsset(connection, programId, path, site = null) {
|
|
221
|
+
const [pda] = assetPdaFor(new PublicKey(programId), site, path);
|
|
215
222
|
const info = await connection.getAccountInfo(pda);
|
|
216
223
|
if (!info) return null;
|
|
217
224
|
return decodeAsset(info.data);
|
|
218
225
|
}
|
|
219
226
|
|
|
220
|
-
export async function readManifest(connection, programId) {
|
|
221
|
-
const a = await readAsset(connection, programId, MANIFEST_PATH);
|
|
227
|
+
export async function readManifest(connection, programId, site = null) {
|
|
228
|
+
const a = await readAsset(connection, programId, MANIFEST_PATH, site);
|
|
222
229
|
if (!a || !a.complete) return null;
|
|
223
230
|
return JSON.parse(a.data.toString('utf8'));
|
|
224
231
|
}
|
|
225
232
|
|
|
226
|
-
export async function readKv(connection, programId, key) {
|
|
227
|
-
const [pda] =
|
|
233
|
+
export async function readKv(connection, programId, key, site = null) {
|
|
234
|
+
const [pda] = kvPdaFor(new PublicKey(programId), site, key);
|
|
228
235
|
const info = await connection.getAccountInfo(pda);
|
|
229
236
|
return info ? decodeKv(info.data) : null;
|
|
230
237
|
}
|
|
@@ -242,9 +249,15 @@ export async function readKv(connection, programId, key) {
|
|
|
242
249
|
*
|
|
243
250
|
* @returns {{status:number, headers:object, body:Buffer, signature?:string, logs:string[], unitsConsumed?:number, accounts:string[]}}
|
|
244
251
|
*/
|
|
245
|
-
export async function invoke(connection, { programId, payer, event, mutate = false, computeUnits = DEFAULT_CU, heap = DEFAULT_HEAP, maxDiscovery = 6 }) {
|
|
252
|
+
export async function invoke(connection, { programId, site = null, payer, event, mutate = false, computeUnits = DEFAULT_CU, heap = DEFAULT_HEAP, maxDiscovery = 6 }) {
|
|
246
253
|
const pid = new PublicKey(programId);
|
|
247
254
|
const payerKey = payer.publicKey ?? new PublicKey(payer);
|
|
255
|
+
// Shared runtime: the instruction names the site and hands the program its
|
|
256
|
+
// site account and code account after payer + system program.
|
|
257
|
+
const siteKeys = site ? [
|
|
258
|
+
{ pubkey: sitePda(pid, site)[0], isSigner: false, isWritable: false },
|
|
259
|
+
{ pubkey: assetPdaFor(pid, site, CODE_PATH)[0], isSigner: false, isWritable: false },
|
|
260
|
+
] : [];
|
|
248
261
|
if (mutate) {
|
|
249
262
|
// Identical bytes inside one blockhash window dedupe as AlreadyProcessed;
|
|
250
263
|
// a per-request nonce header (ignored on chain) makes every write unique
|
|
@@ -252,12 +265,13 @@ export async function invoke(connection, { programId, payer, event, mutate = fal
|
|
|
252
265
|
const nonce = Math.random().toString(16).slice(2, 10) + Date.now().toString(16);
|
|
253
266
|
event = { ...event, headers: typeof event.headers === 'string' ? event.headers + `x-zoo-nonce:${nonce}\n` : { ...(event.headers || {}), 'x-zoo-nonce': nonce } };
|
|
254
267
|
}
|
|
255
|
-
const data = encodeInvoke(event);
|
|
268
|
+
const data = site ? encodeVmInvoke(site, event) : encodeInvoke(event);
|
|
256
269
|
const extra = [];
|
|
257
270
|
for (let round = 0; ; round++) {
|
|
258
271
|
const keys = [
|
|
259
272
|
{ pubkey: payerKey, isSigner: true, isWritable: true },
|
|
260
273
|
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
|
274
|
+
...siteKeys,
|
|
261
275
|
...extra.map((k) => ({ pubkey: k, isSigner: false, isWritable: true })),
|
|
262
276
|
];
|
|
263
277
|
const ix = new TransactionInstruction({ programId: pid, keys, data });
|
|
@@ -339,3 +353,35 @@ export async function waitForProgram(connection, programId, { timeoutMs = 30_000
|
|
|
339
353
|
await new Promise((r) => setTimeout(r, 400));
|
|
340
354
|
}
|
|
341
355
|
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------- shared runtime sites
|
|
358
|
+
|
|
359
|
+
/** Create the site account (`["site", siteId]`) under the runtime, owned by `authority`. Idempotent. */
|
|
360
|
+
export async function initSite(connection, { authority, runtime, site }) {
|
|
361
|
+
const pid = new PublicKey(runtime);
|
|
362
|
+
const [pda] = sitePda(pid, site);
|
|
363
|
+
const info = await connection.getAccountInfo(pda);
|
|
364
|
+
if (info) {
|
|
365
|
+
const s = decodeSite(info.data);
|
|
366
|
+
if (s && !s.authority.equals(authority.publicKey)) throw new Error(`site ${new PublicKey(site).toBase58()} belongs to ${s.authority.toBase58()}`);
|
|
367
|
+
return { pda, created: false };
|
|
368
|
+
}
|
|
369
|
+
await sendTx(connection, [new TransactionInstruction({
|
|
370
|
+
programId: pid,
|
|
371
|
+
keys: [
|
|
372
|
+
{ pubkey: authority.publicKey, isSigner: true, isWritable: true },
|
|
373
|
+
{ pubkey: pda, isSigner: false, isWritable: true },
|
|
374
|
+
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
|
375
|
+
],
|
|
376
|
+
data: encodeSiteInit(site),
|
|
377
|
+
})], [authority], { label: 'site init' });
|
|
378
|
+
return { pda, created: true };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function getSiteInfo(connection, runtime, site) {
|
|
382
|
+
const [pda] = sitePda(new PublicKey(runtime), site);
|
|
383
|
+
const info = await connection.getAccountInfo(pda);
|
|
384
|
+
if (!info) return { exists: false, pda };
|
|
385
|
+
const s = decodeSite(info.data);
|
|
386
|
+
return { exists: !!s, pda, authority: s?.authority || null };
|
|
387
|
+
}
|
package/transmute/lib/wire.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { PublicKey } from '@solana/web3.js';
|
|
9
9
|
|
|
10
|
-
export const TAG = { INVOKE: 0, ASSET_INIT: 1, ASSET_WRITE: 2, ASSET_CLOSE: 3 };
|
|
10
|
+
export const TAG = { INVOKE: 0, ASSET_INIT: 1, ASSET_WRITE: 2, ASSET_CLOSE: 3, SITE_INIT: 16, SITE_SET_AUTHORITY: 17, VM_INVOKE: 18, VM_ASSET_INIT: 19, VM_ASSET_WRITE: 20, VM_ASSET_CLOSE: 21 };
|
|
11
|
+
/** Path of a site's bytecode module under the shared runtime. */
|
|
12
|
+
export const CODE_PATH = '/.zoo/code.bin';
|
|
11
13
|
export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'];
|
|
12
14
|
export const ERR_KV_MISSING = 0x4b560001;
|
|
13
15
|
export const ERR_NOT_AUTHORITY = 0x4b560002;
|
|
@@ -165,3 +167,42 @@ export function decodeKv(data) {
|
|
|
165
167
|
if (len === 0) return null;
|
|
166
168
|
try { return JSON.parse(data.subarray(KV_HEADER, KV_HEADER + len).toString('utf8')); } catch { return null; }
|
|
167
169
|
}
|
|
170
|
+
|
|
171
|
+
// ---- shared runtime (zoo-vm): sites are accounts namespaced by a 32-byte site id
|
|
172
|
+
|
|
173
|
+
export function siteIdBytes(site) { return site instanceof PublicKey ? site.toBuffer() : new PublicKey(site).toBuffer(); }
|
|
174
|
+
|
|
175
|
+
export function sitePda(runtime, site) {
|
|
176
|
+
return PublicKey.findProgramAddressSync([Buffer.from('site'), siteIdBytes(site)], new PublicKey(runtime));
|
|
177
|
+
}
|
|
178
|
+
export function kvPdaNs(runtime, site, key) {
|
|
179
|
+
return PublicKey.findProgramAddressSync([Buffer.from('kv'), siteIdBytes(site), kvHash(key)], new PublicKey(runtime));
|
|
180
|
+
}
|
|
181
|
+
export function assetPdaNs(runtime, site, path) {
|
|
182
|
+
return PublicKey.findProgramAddressSync([Buffer.from('asset'), siteIdBytes(site), assetHash(path)], new PublicKey(runtime));
|
|
183
|
+
}
|
|
184
|
+
/** Any-mode asset PDA: program-flavour when `site` is null. */
|
|
185
|
+
export function assetPdaFor(programId, site, path) {
|
|
186
|
+
return site ? assetPdaNs(programId, site, path) : assetPda(programId, path);
|
|
187
|
+
}
|
|
188
|
+
export function kvPdaFor(programId, site, key) {
|
|
189
|
+
return site ? kvPdaNs(programId, site, key) : kvPda(programId, key);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function encodeSiteInit(site) { return Buffer.concat([Buffer.from([TAG.SITE_INIT]), siteIdBytes(site)]); }
|
|
193
|
+
export function encodeSiteSetAuthority(site, newAuthority) { return Buffer.concat([Buffer.from([TAG.SITE_SET_AUTHORITY]), siteIdBytes(site), new PublicKey(newAuthority).toBuffer()]); }
|
|
194
|
+
/** The VM invoke: same bridge event, prefixed by the site id. */
|
|
195
|
+
export function encodeVmInvoke(site, ev) {
|
|
196
|
+
const inner = encodeInvoke(ev); // [TAG.INVOKE][...]
|
|
197
|
+
return Buffer.concat([Buffer.from([TAG.VM_INVOKE]), siteIdBytes(site), inner.subarray(1)]);
|
|
198
|
+
}
|
|
199
|
+
export function encodeVmAssetInit(site, path, totalLen, contentType) { return Buffer.concat([Buffer.from([TAG.VM_ASSET_INIT]), siteIdBytes(site), encodeAssetInit(path, totalLen, contentType).subarray(1)]); }
|
|
200
|
+
export function encodeVmAssetWrite(site, path, offset, bytes) { return Buffer.concat([Buffer.from([TAG.VM_ASSET_WRITE]), siteIdBytes(site), encodeAssetWrite(path, offset, bytes).subarray(1)]); }
|
|
201
|
+
export function encodeVmAssetClose(site, path) { return Buffer.concat([Buffer.from([TAG.VM_ASSET_CLOSE]), siteIdBytes(site), encodeAssetClose(path).subarray(1)]); }
|
|
202
|
+
|
|
203
|
+
export const SITE_LEN = 66;
|
|
204
|
+
/** Decode a site account: { authority, bump } or null. */
|
|
205
|
+
export function decodeSite(data) {
|
|
206
|
+
if (!data || data.length < SITE_LEN || data[0] !== 1) return null;
|
|
207
|
+
return { bump: data[1], authority: new PublicKey(data.subarray(2, 34)) };
|
|
208
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//! Host-side VM harness: `vmrun <module.bin> <route> <method> <path> [query] [body] [headers]`
|
|
2
|
+
//! prints the bridge response as JSON. No chain: KV calls report as missing.
|
|
3
|
+
use std::io::Read;
|
|
4
|
+
use zoo_host::{vm, wire::Req, Ctx};
|
|
5
|
+
|
|
6
|
+
fn main() {
|
|
7
|
+
let args: Vec<String> = std::env::args().collect();
|
|
8
|
+
let mut code = Vec::new();
|
|
9
|
+
std::fs::File::open(&args[1]).unwrap().read_to_end(&mut code).unwrap();
|
|
10
|
+
let route: usize = args[2].parse().unwrap();
|
|
11
|
+
let method = zoo_host::wire::METHODS.iter().position(|m| *m == args[3]).unwrap_or(0) as u8;
|
|
12
|
+
let req = Req {
|
|
13
|
+
route: route as u8,
|
|
14
|
+
method,
|
|
15
|
+
path: args.get(4).cloned().unwrap_or_else(|| "/".into()),
|
|
16
|
+
query: args.get(5).cloned().unwrap_or_default(),
|
|
17
|
+
headers: args.get(7).cloned().unwrap_or_default().replace("\\n", "\n"),
|
|
18
|
+
body: args.get(6).cloned().unwrap_or_default().into_bytes(),
|
|
19
|
+
};
|
|
20
|
+
let program_id = pinocchio::Address::new_from_array([7u8; 32]);
|
|
21
|
+
let mut accounts: [pinocchio::AccountView; 0] = [];
|
|
22
|
+
let mut cx = Ctx::new(&program_id, &mut accounts, req, &[]);
|
|
23
|
+
let out = vm::invoke_module(&mut cx, &code, route);
|
|
24
|
+
let status_body = match out {
|
|
25
|
+
Ok(()) if cx.sent => (cx.res.status, String::from_utf8_lossy(&cx.res.body).into_owned(), cx.res.headers.clone()),
|
|
26
|
+
Ok(()) => (504, "no response".into(), vec![]),
|
|
27
|
+
Err(e) => {
|
|
28
|
+
let r = vm::error_resp(&e);
|
|
29
|
+
(r.status, String::from_utf8_lossy(&r.body).into_owned(), r.headers.clone())
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
let hdrs: Vec<String> = status_body.2.iter().map(|(k, v)| format!("\"{}\":\"{}\"", k, v.replace('"', "\\\""))).collect();
|
|
33
|
+
println!("{{\"status\":{},\"headers\":{{{}}},\"body\":{}}}", status_body.0, hdrs.join(","), serde_free(&status_body.1));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fn serde_free(s: &str) -> String {
|
|
37
|
+
let mut o = String::from("\"");
|
|
38
|
+
for c in s.chars() {
|
|
39
|
+
match c { '"' => o.push_str("\\\""), '\\' => o.push_str("\\\\"), '\n' => o.push_str("\\n"), _ => o.push(c) }
|
|
40
|
+
}
|
|
41
|
+
o.push('"');
|
|
42
|
+
o
|
|
43
|
+
}
|