@terrariumlabs/core 0.3.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 +21 -0
- package/README.md +57 -0
- package/bin/terrarium.mjs +135 -0
- package/fixtures/uniswap-v2-mainnet.json +19 -0
- package/package.json +74 -0
- package/src/bridge.ts +53 -0
- package/src/devbar.ts +69 -0
- package/src/engine.js +780 -0
- package/src/http.ts +217 -0
- package/src/inject.ts +44 -0
- package/src/scenario.ts +94 -0
- package/src/vite-plugin.d.ts +13 -0
- package/src/vite-plugin.js +33 -0
- package/src/worker-runtime.ts +76 -0
package/src/http.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// http.ts — answer the dapp's HTTP calls (REST APIs, subgraphs, indexers) from the chain in the Worker.
|
|
2
|
+
//
|
|
3
|
+
// A dapp rarely reads the chain alone: it asks a subgraph for the last swaps, a price API for USD values, its own
|
|
4
|
+
// backend for a leaderboard. Inside the Terrarium there is no indexer, so those calls would go to the real internet and
|
|
5
|
+
// describe a chain that is not the one in the page. A scenario declares `http` routes: which URLs to intercept and how
|
|
6
|
+
// to answer them with data computed from the chain (ctx.pub, ctx.sim, logs). The page side patches `fetch` (like a
|
|
7
|
+
// wallet extension or a Service Worker would; the dapp's code is untouched), matching requests are posted to the
|
|
8
|
+
// Worker, the route's handler runs with the scenario context, and the page builds a real `Response` from the answer.
|
|
9
|
+
// Everything else passes through to the network untouched.
|
|
10
|
+
import type { ScenarioContext } from './scenario.ts';
|
|
11
|
+
|
|
12
|
+
/** what a handler receives: a plain, serialisable view of the request */
|
|
13
|
+
export interface HttpRequest {
|
|
14
|
+
url: string;
|
|
15
|
+
method: string;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
/** the raw body (null for GET / HEAD) */
|
|
18
|
+
body: string | null;
|
|
19
|
+
/** the body parsed as JSON, or null */
|
|
20
|
+
json: any;
|
|
21
|
+
/** the query string as an object */
|
|
22
|
+
query: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
/** what a handler may return explicitly (see `reply`); anything else is serialised as JSON with status 200 */
|
|
25
|
+
export interface HttpReply { __terrariumReply: true; status: number; headers: Record<string, string>; body: string }
|
|
26
|
+
/** one top-level field of a GraphQL operation, as the `graphql` resolvers receive it */
|
|
27
|
+
export interface GraphqlQuery {
|
|
28
|
+
/** the field name (`swaps`) and the alias the client wants it under (defaults to the name) */
|
|
29
|
+
field: string; alias: string;
|
|
30
|
+
/** the field's arguments with variables substituted: `{ first: 5, orderBy: 'timestamp', where: { pair: '0x…' } }` */
|
|
31
|
+
args: Record<string, unknown>;
|
|
32
|
+
/** the names of the sub-fields the client selected (one level): `['id', 'timestamp', 'amount0In']` */
|
|
33
|
+
selection: string[];
|
|
34
|
+
variables: Record<string, unknown>;
|
|
35
|
+
operationName: string | null;
|
|
36
|
+
/** the whole operation text, if you need more than this parser gives */
|
|
37
|
+
query: string;
|
|
38
|
+
}
|
|
39
|
+
export type GraphqlResolver = (ctx: ScenarioContext, q: GraphqlQuery) => unknown;
|
|
40
|
+
|
|
41
|
+
export interface HttpRoute {
|
|
42
|
+
name?: string;
|
|
43
|
+
/** which requests: a URL prefix (`'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v2'`), a glob with `*`
|
|
44
|
+
* (`'https://api.coingecko.com/api/v3/simple/price*'`), or a RegExp. Matched against the full URL. */
|
|
45
|
+
match: string | RegExp;
|
|
46
|
+
/** restrict to one HTTP method (default: any) */
|
|
47
|
+
method?: string;
|
|
48
|
+
/** answer the request: return JSON-serialisable data (→ 200 application/json; bigints become strings), a string
|
|
49
|
+
* (→ 200 text/plain), or `reply(body, { status, headers })` for anything else */
|
|
50
|
+
handler?(ctx: ScenarioContext, req: HttpRequest): unknown;
|
|
51
|
+
/** ...or, for a GraphQL endpoint, one resolver per top-level query field. The runtime parses the operation, calls each
|
|
52
|
+
* selected field's resolver with its arguments (variables substituted) and answers `{ data: { field: result } }`;
|
|
53
|
+
* a missing resolver or a throwing one becomes a GraphQL `errors` entry, as a real server would answer.
|
|
54
|
+
* With both `handler` and `graphql`, the handler runs first as a gate: return `undefined` to let the resolvers
|
|
55
|
+
* answer, or a `reply(…, { status: 503 })` to take the whole endpoint down. */
|
|
56
|
+
graphql?: Record<string, GraphqlResolver>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const JSON_HEADERS = { 'content-type': 'application/json' };
|
|
60
|
+
export const toJson = (v: unknown) => JSON.stringify(v, (_, x) => (typeof x === 'bigint' ? x.toString() : x));
|
|
61
|
+
|
|
62
|
+
/** an explicit HTTP answer: `reply({ error: 'indexer down' }, { status: 503 })`, `reply('<xml/>', { headers: { 'content-type': 'text/xml' } })` */
|
|
63
|
+
export function reply(body: unknown, init: { status?: number; headers?: Record<string, string> } = {}): HttpReply {
|
|
64
|
+
const text = typeof body === 'string';
|
|
65
|
+
return { __terrariumReply: true, status: init.status ?? 200, headers: { ...(text ? { 'content-type': 'text/plain' } : JSON_HEADERS), ...init.headers }, body: text ? body : toJson(body) };
|
|
66
|
+
}
|
|
67
|
+
const isReply = (v: any): v is HttpReply => !!v && typeof v === 'object' && v.__terrariumReply === true;
|
|
68
|
+
|
|
69
|
+
// ---- matching: the page needs to know what to intercept without asking the Worker for every fetch -----------------
|
|
70
|
+
export type WireRoute = { index: number; name?: string; match: string | { regex: string; flags: string }; method?: string };
|
|
71
|
+
export const toWire = (routes: HttpRoute[]): WireRoute[] => routes.map((r, index) => ({ index, name: r.name, method: r.method?.toUpperCase(), match: r.match instanceof RegExp ? { regex: r.match.source, flags: r.match.flags } : r.match }));
|
|
72
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
73
|
+
/** a string is a prefix (`https://api.example.com/v1/` matches every URL under it); `*` is a wildcard; a RegExp is itself */
|
|
74
|
+
export function compileMatcher(w: WireRoute): (url: string, method: string) => boolean {
|
|
75
|
+
const re = typeof w.match === 'string'
|
|
76
|
+
? (w.match.includes('*') ? new RegExp('^' + w.match.split('*').map(escapeRe).join('.*') + '$') : null)
|
|
77
|
+
: new RegExp(w.match.regex, w.match.flags);
|
|
78
|
+
const prefix = typeof w.match === 'string' && !re ? w.match : null;
|
|
79
|
+
return (url, method) => (!w.method || w.method === method.toUpperCase()) && (prefix !== null ? url.startsWith(prefix) : re!.test(url));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- the Worker side: run a route ---------------------------------------------------------------------------------
|
|
83
|
+
export async function runRoute(ctx: ScenarioContext, route: HttpRoute, raw: { url: string; method: string; headers?: Record<string, string>; body?: string | null }): Promise<Omit<HttpReply, '__terrariumReply'>> {
|
|
84
|
+
let json: any = null; try { json = raw.body ? JSON.parse(raw.body) : null; } catch { json = null; }
|
|
85
|
+
const query: Record<string, string> = {}; try { for (const [k, v] of new URL(raw.url).searchParams) query[k] = v; } catch { /* relative or odd URL: no query */ }
|
|
86
|
+
const req: HttpRequest = { url: raw.url, method: raw.method.toUpperCase(), headers: raw.headers ?? {}, body: raw.body ?? null, json, query };
|
|
87
|
+
let result: unknown;
|
|
88
|
+
if (route.graphql && route.handler) result = await route.handler(ctx, req); // the gate
|
|
89
|
+
if (route.graphql && result === undefined) {
|
|
90
|
+
const source = req.json?.query ?? req.query.query ?? '';
|
|
91
|
+
let variables = req.json?.variables ?? {}; if (typeof variables === 'string') { try { variables = JSON.parse(variables); } catch { variables = {}; } }
|
|
92
|
+
if (!req.json?.variables && req.query.variables) { try { variables = JSON.parse(req.query.variables); } catch { variables = {}; } }
|
|
93
|
+
const operationName = req.json?.operationName ?? req.query.operationName ?? null;
|
|
94
|
+
const data: Record<string, unknown> = {}, errors: { message: string; path?: string[] }[] = [];
|
|
95
|
+
let ops: GraphqlQuery[] = [];
|
|
96
|
+
try { ops = parseGraphql(source, variables, operationName); }
|
|
97
|
+
catch (e: any) { const r = reply({ errors: [{ message: `Syntax Error: ${e?.message ?? e}` }] }, { status: 400 }); return { status: r.status, headers: r.headers, body: r.body }; }
|
|
98
|
+
for (const q of ops) {
|
|
99
|
+
const resolver = route.graphql[q.field];
|
|
100
|
+
if (!resolver) { data[q.alias] = null; errors.push({ message: `Type "Query" has no field "${q.field}"`, path: [q.alias] }); continue; }
|
|
101
|
+
try { data[q.alias] = await resolver(ctx, q); } catch (e: any) { data[q.alias] = null; errors.push({ message: String(e?.message ?? e), path: [q.alias] }); }
|
|
102
|
+
}
|
|
103
|
+
result = errors.length ? { data, errors } : { data };
|
|
104
|
+
} else if (!route.graphql && route.handler) result = await route.handler(ctx, req);
|
|
105
|
+
else if (!route.graphql) throw new Error(`http route ${route.name ?? route.match} has neither handler nor graphql`);
|
|
106
|
+
const r = isReply(result) ? result : reply(result === undefined ? null : result);
|
|
107
|
+
return { status: r.status, headers: r.headers, body: r.body };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- a small GraphQL parser: the top-level fields of one operation, their arguments and selections ----------------
|
|
111
|
+
type Tok = { t: 'punct' | 'name' | 'num' | 'str' | 'var'; v: string };
|
|
112
|
+
function tokenize(src: string): Tok[] {
|
|
113
|
+
const out: Tok[] = []; let i = 0;
|
|
114
|
+
while (i < src.length) {
|
|
115
|
+
const c = src[i];
|
|
116
|
+
if (/\s|,/.test(c)) { i++; continue; }
|
|
117
|
+
if (c === '#') { while (i < src.length && src[i] !== '\n') i++; continue; }
|
|
118
|
+
if (src.startsWith('"""', i)) { const end = src.indexOf('"""', i + 3); if (end < 0) throw new Error('unterminated block string'); out.push({ t: 'str', v: src.slice(i + 3, end) }); i = end + 3; continue; }
|
|
119
|
+
if (c === '"') { let j = i + 1, s = ''; while (j < src.length && src[j] !== '"') { if (src[j] === '\\') { s += JSON.parse(`"${src.slice(j, j + (src[j + 1] === 'u' ? 6 : 2))}"`); j += src[j + 1] === 'u' ? 6 : 2; } else s += src[j++]; } if (j >= src.length) throw new Error('unterminated string'); out.push({ t: 'str', v: s }); i = j + 1; continue; }
|
|
120
|
+
if (c === '$') { const m = /^[_A-Za-z][_0-9A-Za-z]*/.exec(src.slice(i + 1)); if (!m) throw new Error('bad variable'); out.push({ t: 'var', v: m[0] }); i += 1 + m[0].length; continue; }
|
|
121
|
+
if (src.startsWith('...', i)) { out.push({ t: 'punct', v: '...' }); i += 3; continue; }
|
|
122
|
+
if ('{}()[]:!=@|&'.includes(c)) { out.push({ t: 'punct', v: c }); i++; continue; }
|
|
123
|
+
let m = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(src.slice(i)); if (m) { out.push({ t: 'num', v: m[0] }); i += m[0].length; continue; }
|
|
124
|
+
m = /^[_A-Za-z][_0-9A-Za-z]*/.exec(src.slice(i)); if (m) { out.push({ t: 'name', v: m[0] }); i += m[0].length; continue; }
|
|
125
|
+
throw new Error(`unexpected character ${JSON.stringify(c)} at ${i}`);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** Parse a GraphQL document and return the top-level fields of the selected operation (the first one, or `operationName`).
|
|
130
|
+
* Arguments become plain values with variables substituted (and variable defaults applied); fragments are not expanded. */
|
|
131
|
+
export function parseGraphql(source: string, variables: Record<string, unknown> = {}, operationName: string | null = null): GraphqlQuery[] {
|
|
132
|
+
const toks = tokenize(source); let p = 0;
|
|
133
|
+
const peek = () => toks[p], next = () => { if (p >= toks.length) throw new Error('unexpected end of query'); return toks[p++]; };
|
|
134
|
+
const expect = (v: string) => { const t = next(); if (t.t !== 'punct' || t.v !== v) throw new Error(`expected ${v}, got ${t.v}`); };
|
|
135
|
+
const skipBalanced = (open: string, close: string) => { expect(open); let depth = 1; while (depth) { const t = next(); if (t.t === 'punct' && t.v === open) depth++; else if (t.t === 'punct' && t.v === close) depth--; } };
|
|
136
|
+
const skipDirectives = () => { while (peek()?.t === 'punct' && peek().v === '@') { next(); next(); if (peek()?.t === 'punct' && peek().v === '(') skipBalanced('(', ')'); } };
|
|
137
|
+
const value = (vars: Record<string, unknown>): unknown => {
|
|
138
|
+
const t = next();
|
|
139
|
+
if (t.t === 'var') return vars[t.v];
|
|
140
|
+
if (t.t === 'num') return Number(t.v);
|
|
141
|
+
if (t.t === 'str') return t.v;
|
|
142
|
+
if (t.t === 'name') return t.v === 'true' ? true : t.v === 'false' ? false : t.v === 'null' ? null : t.v; // enums stay strings
|
|
143
|
+
if (t.v === '[') { const a: unknown[] = []; while (!(peek().t === 'punct' && peek().v === ']')) a.push(value(vars)); next(); return a; }
|
|
144
|
+
if (t.v === '{') { const o: Record<string, unknown> = {}; while (!(peek().t === 'punct' && peek().v === '}')) { const k = next().v; expect(':'); o[k] = value(vars); } next(); return o; }
|
|
145
|
+
throw new Error(`unexpected ${t.v}`);
|
|
146
|
+
};
|
|
147
|
+
const args = (vars: Record<string, unknown>) => { const o: Record<string, unknown> = {}; if (!(peek()?.t === 'punct' && peek().v === '(')) return o; next(); while (!(peek().t === 'punct' && peek().v === ')')) { const k = next().v; expect(':'); o[k] = value(vars); } next(); return o; };
|
|
148
|
+
/** one level of field names inside { … }, skipping each field's own args, directives and nested selection */
|
|
149
|
+
const selectionNames = (): string[] => { const names: string[] = []; expect('{'); while (!(peek().t === 'punct' && peek().v === '}')) { const t = next(); if (t.t === 'punct' && t.v === '...') { if (peek().t === 'name' && peek().v === 'on') { next(); next(); } else if (peek().t === 'name') { next(); continue; } skipDirectives(); selectionNames(); continue; } let name = t.v; if (peek().t === 'punct' && peek().v === ':') { next(); name = next().v; } names.push(name); if (peek().t === 'punct' && peek().v === '(') skipBalanced('(', ')'); skipDirectives(); if (peek().t === 'punct' && peek().v === '{') selectionNames(); } next(); return names; };
|
|
150
|
+
|
|
151
|
+
// ---- definitions: pick the operation ----
|
|
152
|
+
type Op = { name: string | null; start: number; vars: Record<string, unknown> };
|
|
153
|
+
const ops: Op[] = [];
|
|
154
|
+
while (p < toks.length) {
|
|
155
|
+
const t = peek();
|
|
156
|
+
if (t.t === 'name' && t.v === 'fragment') { next(); next(); next(); next(); skipDirectives(); skipBalanced('{', '}'); continue; }
|
|
157
|
+
const vars: Record<string, unknown> = { ...variables }; let name: string | null = null;
|
|
158
|
+
if (t.t === 'name' && ['query', 'mutation', 'subscription'].includes(t.v)) {
|
|
159
|
+
next(); if (peek().t === 'name') name = next().v;
|
|
160
|
+
if (peek().t === 'punct' && peek().v === '(') { // variable definitions: $x: Type! = default
|
|
161
|
+
next();
|
|
162
|
+
while (!(peek().t === 'punct' && peek().v === ')')) {
|
|
163
|
+
const v = next(); if (v.t !== 'var') throw new Error('expected a variable definition'); expect(':');
|
|
164
|
+
if (peek().t === 'punct' && peek().v === '[') skipBalanced('[', ']'); else next(); if (peek().t === 'punct' && peek().v === '!') next();
|
|
165
|
+
if (peek().t === 'punct' && peek().v === '=') { next(); const d = value({}); if (vars[v.v] === undefined) vars[v.v] = d; }
|
|
166
|
+
skipDirectives();
|
|
167
|
+
}
|
|
168
|
+
next();
|
|
169
|
+
}
|
|
170
|
+
skipDirectives();
|
|
171
|
+
} else if (!(t.t === 'punct' && t.v === '{')) throw new Error(`unexpected ${t.v}`);
|
|
172
|
+
ops.push({ name, start: p, vars }); skipBalanced('{', '}');
|
|
173
|
+
}
|
|
174
|
+
if (!ops.length) throw new Error('no operation in query');
|
|
175
|
+
const op = operationName ? ops.find((o) => o.name === operationName) : ops[0];
|
|
176
|
+
if (!op) throw new Error(`unknown operation "${operationName}"`);
|
|
177
|
+
// ---- its top-level fields ----
|
|
178
|
+
p = op.start; expect('{');
|
|
179
|
+
const fields: GraphqlQuery[] = [];
|
|
180
|
+
while (!(peek().t === 'punct' && peek().v === '}')) {
|
|
181
|
+
const t = next();
|
|
182
|
+
if (t.t === 'punct' && t.v === '...') { if (peek().t === 'name' && peek().v === 'on') { next(); next(); } else if (peek().t === 'name') { next(); continue; } skipDirectives(); skipBalanced('{', '}'); continue; } // top-level fragments are not expanded
|
|
183
|
+
let field = t.v, alias = t.v;
|
|
184
|
+
if (peek().t === 'punct' && peek().v === ':') { next(); field = next().v; }
|
|
185
|
+
const a = args(op.vars); skipDirectives();
|
|
186
|
+
const selection = peek()?.t === 'punct' && peek().v === '{' ? selectionNames() : [];
|
|
187
|
+
fields.push({ field, alias, args: a, selection, variables: op.vars, operationName: op.name, query: source });
|
|
188
|
+
}
|
|
189
|
+
return fields;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- the page side: patch fetch, forward matching requests to the Worker --------------------------------------------
|
|
193
|
+
type Provider = { request(a: { method: string; params?: unknown[] }): Promise<any> };
|
|
194
|
+
/** Replace `globalThis.fetch` with one that answers the scenario's routes from the Worker and passes everything else
|
|
195
|
+
* through. `routes` resolves to the Worker's route list (it can take a moment: the first fetches wait for it).
|
|
196
|
+
* Returns a function that restores the original fetch. */
|
|
197
|
+
export function installHttpInterceptor(provider: Provider, routes: Promise<WireRoute[]>, scope: any = globalThis): () => void {
|
|
198
|
+
const realFetch: typeof fetch = scope.fetch.bind(scope);
|
|
199
|
+
const compiled = routes.then((rs) => rs.map((w) => ({ ...w, test: compileMatcher(w) })), () => []);
|
|
200
|
+
scope.fetch = async (input: any, init?: RequestInit) => {
|
|
201
|
+
const list = await compiled;
|
|
202
|
+
if (!list.length) return realFetch(input, init);
|
|
203
|
+
const req = input instanceof Request ? input.clone() : new Request(input, init);
|
|
204
|
+
const route = list.find((r) => r.test(req.url, req.method));
|
|
205
|
+
if (!route) return realFetch(input, init);
|
|
206
|
+
const body = req.method === 'GET' || req.method === 'HEAD' ? null : await req.text();
|
|
207
|
+
const headers: Record<string, string> = {}; req.headers.forEach((v, k) => { headers[k] = v; });
|
|
208
|
+
try {
|
|
209
|
+
const r = await provider.request({ method: 'terrarium_http', params: [route.index, { url: req.url, method: req.method, headers, body }] });
|
|
210
|
+
return new Response(r.body, { status: r.status, headers: r.headers });
|
|
211
|
+
} catch (e: any) { // a bug in the scenario's handler: surface it as the failed API call it would be, and say so
|
|
212
|
+
console.warn(`[terrarium] http route ${route.name ?? route.index} failed:`, e?.message ?? e);
|
|
213
|
+
return new Response(toJson({ error: String(e?.message ?? e) }), { status: 500, headers: JSON_HEADERS });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
return () => { scope.fetch = realFetch; };
|
|
217
|
+
}
|
package/src/inject.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// inject.ts — main-thread side, shared by the dev entry and the standalone bundle: wire the Worker up as an EIP-6963
|
|
2
|
+
// wallet (so the dapp's own connect modal lists "Terrarium Wallet") and mount the dev bar. Nothing here is imported by
|
|
3
|
+
// the dapp; this is the analogue of a browser extension's injected script.
|
|
4
|
+
import { createWorkerProvider } from './bridge.ts';
|
|
5
|
+
import { mountDevBar } from './devbar.ts';
|
|
6
|
+
import { installHttpInterceptor, type WireRoute } from './http.ts';
|
|
7
|
+
|
|
8
|
+
const ICON = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#1F6F5C"/><path d="M6 25.5c2-6 5.5-8.5 10-8.5s8 2.5 10 8.5" fill="none" stroke="#E8C547" stroke-width="2.5" stroke-linecap="round"/><path d="M16 17.5V9" stroke="#E8C547" stroke-width="2.5" stroke-linecap="round"/><path d="M16 12c0-4.5 3-7 7-7 0 4.5-3 7-7 7Z M16 14.5c0-4.5-3-7-7-7 0 4.5 3 7 7 7Z" fill="#E8C547"/></svg>');
|
|
9
|
+
|
|
10
|
+
export interface StartOptions {
|
|
11
|
+
/** mount the dev bar (default true); false for a host that draws its own controls */
|
|
12
|
+
devBar?: boolean;
|
|
13
|
+
}
|
|
14
|
+
let current: { worker: Worker; announce: () => void } | null = null;
|
|
15
|
+
|
|
16
|
+
export function startTerrarium(worker: Worker, opts: StartOptions = {}) {
|
|
17
|
+
if (current) stopTerrarium();
|
|
18
|
+
const provider = createWorkerProvider(worker);
|
|
19
|
+
// the scenario's HTTP routes (subgraphs, APIs answered from the chain): the Worker announces them before it boots;
|
|
20
|
+
// the RPC is the fallback. Until either arrives, the dapp's fetches wait; nothing else about them changes.
|
|
21
|
+
const routes = new Promise<WireRoute[]>((res) => { provider.on('httpRoutes', (r) => res(r as WireRoute[])); provider.request({ method: 'terrarium_httpRoutes' }).then((r) => res(r as WireRoute[]), () => res([])); });
|
|
22
|
+
installHttpInterceptor(provider, routes);
|
|
23
|
+
const detail = Object.freeze({ info: { uuid: '7e44a1c0-5f0b-4c1e-9b7a-a1b2c3d4e5f6', name: 'Terrarium Wallet', icon: ICON, rdns: 'dev.terrarium' }, provider });
|
|
24
|
+
const announce = () => window.dispatchEvent(new CustomEvent('eip6963:announceProvider', { detail }));
|
|
25
|
+
window.addEventListener('eip6963:requestProvider', announce);
|
|
26
|
+
announce();
|
|
27
|
+
current = { worker, announce };
|
|
28
|
+
// the wallet's own global (like window.ethereum) — for tests and the console, never for the dapp
|
|
29
|
+
(window as any).terrarium = { provider, request: (method: string, params: unknown[] = []) => provider.request({ method, params }) };
|
|
30
|
+
if (opts.devBar !== false) { const mount = () => mountDevBar(provider); if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount); }
|
|
31
|
+
return provider;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Undo startTerrarium: stop announcing, terminate the Worker, remove the dev bar and window.terrarium. Used by hosts that
|
|
35
|
+
* mount and unmount (React StrictMode runs effects twice; a Storybook story unmounts). */
|
|
36
|
+
export function stopTerrarium() {
|
|
37
|
+
if (!current) return;
|
|
38
|
+
window.removeEventListener('eip6963:requestProvider', current.announce);
|
|
39
|
+
current.worker.terminate();
|
|
40
|
+
current = null;
|
|
41
|
+
document.getElementById('terrarium-devbar')?.remove();
|
|
42
|
+
document.body?.style.removeProperty('padding-bottom');
|
|
43
|
+
delete (window as any).terrarium;
|
|
44
|
+
}
|
package/src/scenario.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// scenario.ts — the declarative entry point: what runs inside the Worker when the Terrarium boots.
|
|
2
|
+
//
|
|
3
|
+
// export default defineScenario({
|
|
4
|
+
// chainId: 31337, seed: 1337, persist: 'my-dapp',
|
|
5
|
+
// async setup(ctx) { await ctx.install(uniswap); if (ctx.fresh) { /* deploy + seed with ctx.wallet(...) */ } },
|
|
6
|
+
// actors: [{ every: 5000, run: (ctx) => ... }, { on: { address, topics }, run: (ctx, log) => ... }],
|
|
7
|
+
// status: (ctx) => ({ addresses: ctx.state }),
|
|
8
|
+
// });
|
|
9
|
+
import type { Account, Address, Chain, Hex, PublicClient, TransactionReceipt, Transport, WalletClient } from 'viem';
|
|
10
|
+
import type { HttpRoute } from './http.ts';
|
|
11
|
+
export { reply } from './http.ts';
|
|
12
|
+
export type { HttpRoute, HttpRequest, HttpReply, GraphqlQuery, GraphqlResolver } from './http.ts';
|
|
13
|
+
|
|
14
|
+
/** Runtime bytecode of deployed contracts, installed at fixed addresses (`terrarium fetch-code` produces these). */
|
|
15
|
+
export interface Fixture { contracts: Record<string, { address: string; code: string }> } // plain strings: JSON imports fit as-is
|
|
16
|
+
export interface LogFilter { address?: Address | Address[]; topics?: (Hex | Hex[] | null)[] }
|
|
17
|
+
|
|
18
|
+
export interface ScenarioContext {
|
|
19
|
+
/** the engine itself: sim.deal, sim.setState, sim.sendAs, sim.onLog, sim.snapshot ... */
|
|
20
|
+
sim: any;
|
|
21
|
+
chainId: number;
|
|
22
|
+
/** the 10 Anvil test accounts, each with 10,000 ETH; the wallet exposes all of them */
|
|
23
|
+
accounts: Address[];
|
|
24
|
+
/** raw JSON-RPC against the chain (cheatcodes included) */
|
|
25
|
+
rpc(method: string, params?: unknown[]): Promise<any>;
|
|
26
|
+
/** viem clients wired to the chain */
|
|
27
|
+
pub: PublicClient;
|
|
28
|
+
wallet(account: Address): WalletClient<Transport, Chain, Account>;
|
|
29
|
+
wait(hash: Promise<Hex> | Hex): Promise<TransactionReceipt>;
|
|
30
|
+
/** a deadline from the CHAIN clock (never Date.now(): the dev bar can shift time) */
|
|
31
|
+
deadline(seconds?: number): bigint;
|
|
32
|
+
/** seeded PRNG: reproducible actors */
|
|
33
|
+
random(): number;
|
|
34
|
+
/** true when the chain has no blocks yet (first boot, or after a reset): deploy and seed only then */
|
|
35
|
+
fresh: boolean;
|
|
36
|
+
/** true when nothing was persisted yet (first boot or after a reset), even if a fixture was restored: seed the user once */
|
|
37
|
+
firstBoot: boolean;
|
|
38
|
+
codeAt(address: Address): Promise<Hex>;
|
|
39
|
+
/** put a fixture's bytecode at its addresses (skips contracts already present, so it is safe on every boot) */
|
|
40
|
+
install(fixture: Fixture): Promise<void>;
|
|
41
|
+
/** a bag for whatever setup() discovers (addresses...) that actors and status() need later */
|
|
42
|
+
state: Record<string, any>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Actor {
|
|
46
|
+
name?: string;
|
|
47
|
+
/** run every N ms */
|
|
48
|
+
every?: number;
|
|
49
|
+
/** ...or run when a matching log is mined (a function, if the filter depends on setup() results) */
|
|
50
|
+
on?: LogFilter | ((ctx: ScenarioContext) => LogFilter);
|
|
51
|
+
run(ctx: ScenarioContext, log?: any): Promise<unknown> | unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ScenarioConfig {
|
|
55
|
+
chainId?: number;
|
|
56
|
+
/** seed for ctx.random() and the actors; omit for a fresh seed per boot */
|
|
57
|
+
seed?: number;
|
|
58
|
+
/** IndexedDB key the chain persists under; false = in-memory only */
|
|
59
|
+
persist?: string | false;
|
|
60
|
+
hardfork?: string;
|
|
61
|
+
/** 'merkle' (default): real stateRoot in every header. 'simple': flat maps, placeholder root. */
|
|
62
|
+
state?: 'merkle' | 'simple';
|
|
63
|
+
/** fork a live chain: state is read lazily from `url` at `blockNumber` (and recorded). `offline: true` forbids the
|
|
64
|
+
* network: reads the fixture cannot answer throw and are listed in sim.offlineMisses. */
|
|
65
|
+
fork?: { url?: string; blockNumber: number; offline?: boolean };
|
|
66
|
+
/** the chain clock. 'wall' (default): the wall clock. 'recording': the wall clock re-based to the restored fixture's last
|
|
67
|
+
* block, so oracles with staleness checks keep working however long ago the fixture was recorded. A number: fixed seconds
|
|
68
|
+
* (blocks then advance one second at a time, plus whatever evm_increaseTime adds). */
|
|
69
|
+
clock?: 'wall' | 'recording' | number;
|
|
70
|
+
/** a recorded dump (sim.dumpState(), e.g. from a fork recording) used as the baseline when nothing is persisted yet */
|
|
71
|
+
restore?: Record<string, any> | (() => Promise<Record<string, any>>);
|
|
72
|
+
/** extra buttons for the dev bar: each calls one of your `methods` (or any RPC method) with fixed params */
|
|
73
|
+
controls?: { label: string; method: string; params?: unknown[]; title?: string }[];
|
|
74
|
+
/** 'exact' (geth-style estimation, default) or 'fast' (block gas limit, no estimation) */
|
|
75
|
+
gasEstimation?: 'exact' | 'fast';
|
|
76
|
+
/** how the wallet misbehaves, from the start (all changeable at runtime via terrarium_setWallet) */
|
|
77
|
+
wallet?: { rejectNext?: number; latencyMs?: number; receiptLagMs?: number };
|
|
78
|
+
/** runs on EVERY boot; use ctx.fresh to deploy/seed once, ctx.install for fixtures (idempotent) */
|
|
79
|
+
setup?(ctx: ScenarioContext): Promise<unknown> | unknown;
|
|
80
|
+
/** background actors: other users, keepers, arbitrageurs. Toggled together (dev bar / terrarium_actors), off by default. */
|
|
81
|
+
actors?: Actor[];
|
|
82
|
+
/** what the dev bar calls the actors toggle, e.g. "Pond life" */
|
|
83
|
+
actorsLabel?: string;
|
|
84
|
+
/** extra fields for terrarium_status (addresses, whatever the dev bar / tests want to know) */
|
|
85
|
+
status?(ctx: ScenarioContext): Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
86
|
+
/** extra RPC methods, reachable through the provider: methods: { terrarium_faucet: (ctx, to) => ... } */
|
|
87
|
+
methods?: Record<string, (ctx: ScenarioContext, ...args: any[]) => unknown>;
|
|
88
|
+
/** HTTP calls of the dapp to answer from the chain (subgraphs, price APIs, your backend): the page's `fetch` is
|
|
89
|
+
* intercepted for matching URLs, the handler runs here in the Worker with `ctx`, everything else goes to the network.
|
|
90
|
+
* http: [{ match: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v2', graphql: { swaps: (ctx, q) => … } }] */
|
|
91
|
+
http?: HttpRoute[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function defineScenario(config: ScenarioConfig): ScenarioConfig { return config; }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
export interface TerrariumPluginOptions {
|
|
4
|
+
/** the scenario module (default export of defineScenario), relative to the Vite root. Default: terrarium.scenario.ts */
|
|
5
|
+
scenario?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Injects the Terrarium (chain in a Worker + EIP-6963 wallet + dev bar) into index.html as a separate module script.
|
|
9
|
+
* The dapp's source is untouched: remove the plugin, or set VITE_TERRARIUM=off, and nothing of it is built. */
|
|
10
|
+
export function terrarium(opts?: TerrariumPluginOptions): Plugin;
|
|
11
|
+
|
|
12
|
+
/** the Worker entry: the user's scenario + the runtime (also used by the CLI's standalone build) */
|
|
13
|
+
export function workerEntry(scenarioImport: string): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The Vite plugin. Plain JavaScript on purpose: Vite loads it in Node straight from the consumer's node_modules, where
|
|
2
|
+
// Node refuses to strip types from .ts files (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). Types live in vite-plugin.d.ts.
|
|
3
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { loadEnv } from 'vite';
|
|
6
|
+
|
|
7
|
+
/** Injects the Terrarium (chain in a Worker + EIP-6963 wallet + dev bar) into index.html as a separate module script.
|
|
8
|
+
* The dapp's source is untouched: remove the plugin, or set VITE_TERRARIUM=off, and nothing of it is built.
|
|
9
|
+
* @param {import('./vite-plugin.d.ts').TerrariumPluginOptions} [opts]
|
|
10
|
+
* @returns {import('vite').Plugin} */
|
|
11
|
+
export function terrarium(opts = {}) {
|
|
12
|
+
let enabled = true;
|
|
13
|
+
return {
|
|
14
|
+
name: 'terrarium',
|
|
15
|
+
configResolved(config) {
|
|
16
|
+
enabled = loadEnv(config.mode, config.envDir ?? config.root, 'VITE_').VITE_TERRARIUM !== 'off'; // loadEnv merges .env files and the process environment
|
|
17
|
+
if (!enabled) { config.logger.info('[terrarium] off — plain dapp build'); return; }
|
|
18
|
+
const scenario = '/' + relative(config.root, resolve(config.root, opts.scenario ?? 'terrarium.scenario.ts')).split(sep).join('/');
|
|
19
|
+
const dir = resolve(config.root, '.terrarium'); mkdirSync(dir, { recursive: true });
|
|
20
|
+
writeFileSync(join(dir, 'worker.ts'), workerEntry(scenario));
|
|
21
|
+
writeFileSync(join(dir, 'inject.ts'), `import { startTerrarium } from '@terrariumlabs/core/inject';\nstartTerrarium(new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }));\n`);
|
|
22
|
+
config.logger.info(`[terrarium] injecting the simulated chain (scenario ${scenario})`);
|
|
23
|
+
},
|
|
24
|
+
transformIndexHtml: {
|
|
25
|
+
order: 'pre',
|
|
26
|
+
handler(html) { return enabled ? { html, tags: [{ tag: 'script', attrs: { type: 'module', src: '/.terrarium/inject.ts' }, injectTo: 'body' }] } : html; },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** the Worker entry: the user's scenario + the runtime (also used by the CLI's standalone build)
|
|
32
|
+
* @param {string} scenarioImport */
|
|
33
|
+
export const workerEntry = (scenarioImport) => `import scenario from '${scenarioImport}';\nimport { runScenario } from '@terrariumlabs/core/worker';\nrunScenario(scenario);\n`;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// worker-runtime.ts — runs a scenario inside the Worker: boot the chain, run setup(), wire the actors, expose the
|
|
2
|
+
// generic terrarium_* controls, and serve the provider to the page over postMessage.
|
|
3
|
+
import { createPublicClient, createWalletClient, custom, defineChain, toHex, type Address, type Hex } from 'viem';
|
|
4
|
+
// @ts-ignore — the engine is plain ESM JavaScript
|
|
5
|
+
import { createTerrarium, indexedDBStorage } from './engine.js';
|
|
6
|
+
import { serveProvider } from './bridge.ts';
|
|
7
|
+
import { runRoute, toWire } from './http.ts';
|
|
8
|
+
import type { ScenarioConfig, ScenarioContext } from './scenario.ts';
|
|
9
|
+
|
|
10
|
+
export async function runScenario(config: ScenarioConfig) {
|
|
11
|
+
const chainId = config.chainId ?? 31337;
|
|
12
|
+
// the page learns what to intercept before the chain boots, so the dapp's first fetches are not held up by setup()
|
|
13
|
+
const httpRoutes = toWire(config.http ?? []);
|
|
14
|
+
if (typeof (globalThis as any).postMessage === 'function') (globalThis as any).postMessage({ event: 'httpRoutes', payload: httpRoutes });
|
|
15
|
+
let httpHits = 0;
|
|
16
|
+
const key = config.persist === false ? null : (config.persist ?? 'default');
|
|
17
|
+
const storage = key ? indexedDBStorage('terrarium') : null;
|
|
18
|
+
const firstBoot = storage ? (await storage.getItem(key!)) === null : true;
|
|
19
|
+
const restore = typeof config.restore === 'function' ? await config.restore() : config.restore;
|
|
20
|
+
const bootWall = Math.floor(Date.now() / 1000);
|
|
21
|
+
const anchor = config.clock === 'recording' ? Number(BigInt(restore?.chain?.blocks?.at(-1)?.timestamp ?? bootWall)) : null;
|
|
22
|
+
const clock = typeof config.clock === 'number' ? () => config.clock as number : anchor !== null ? () => anchor + (Math.floor(Date.now() / 1000) - bootWall) : undefined;
|
|
23
|
+
const sim: any = await createTerrarium({ chainId, seed: config.seed, hardfork: config.hardfork, state: config.state, gasEstimation: config.gasEstimation, wallet: config.wallet, fork: config.fork, restore, clock, persist: storage ? { storage, key } : undefined });
|
|
24
|
+
const chain = defineChain({ id: chainId, name: 'Terrarium', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: [] } } });
|
|
25
|
+
const pub = createPublicClient({ chain, transport: custom(sim.provider), pollingInterval: 20 });
|
|
26
|
+
const rpc = (method: string, params: unknown[] = []) => sim.provider.request({ method, params });
|
|
27
|
+
const ctx: ScenarioContext = {
|
|
28
|
+
sim, chainId, rpc, pub,
|
|
29
|
+
accounts: sim.accounts.map((a: any) => a.address as Address),
|
|
30
|
+
wallet: (account) => createWalletClient({ chain, transport: custom(sim.provider), account }),
|
|
31
|
+
wait: async (h) => pub.waitForTransactionReceipt({ hash: await h }),
|
|
32
|
+
deadline: (seconds = 3600) => sim.now() + BigInt(seconds),
|
|
33
|
+
random: () => sim.random(),
|
|
34
|
+
fresh: sim.blockNumber === 0n,
|
|
35
|
+
firstBoot,
|
|
36
|
+
codeAt: async (a) => (await rpc('eth_getCode', [a, 'latest'])) as Hex,
|
|
37
|
+
install: async (fixture) => { for (const c of Object.values(fixture.contracts)) if ((await ctx.codeAt(c.address as Address)) === '0x') await rpc('anvil_setCode', [c.address, c.code]); },
|
|
38
|
+
state: {},
|
|
39
|
+
};
|
|
40
|
+
await config.setup?.(ctx);
|
|
41
|
+
if (ctx.fresh && storage) await sim.flush();
|
|
42
|
+
|
|
43
|
+
// ---- actors: toggled together, persisted, off by default ----------------------------------------------------
|
|
44
|
+
const actorsKey = `${key}:actors`;
|
|
45
|
+
let timers: ReturnType<typeof setInterval>[] = [], unsubs: (() => void)[] = [];
|
|
46
|
+
const actors = {
|
|
47
|
+
enabled: storage ? (await storage.getItem(actorsKey)) === 'on' : false,
|
|
48
|
+
async toggle(on: boolean) {
|
|
49
|
+
actors.enabled = on; await storage?.setItem(actorsKey, on ? 'on' : 'off');
|
|
50
|
+
timers.forEach(clearInterval); timers = []; unsubs.forEach((u) => u()); unsubs = [];
|
|
51
|
+
if (!on) return;
|
|
52
|
+
for (const a of config.actors ?? []) {
|
|
53
|
+
const safe = (log?: any) => Promise.resolve().then(() => a.run(ctx, log)).catch((e) => console.warn(`[terrarium] actor ${a.name ?? ''} failed:`, e?.message ?? e));
|
|
54
|
+
if (a.every) timers.push(setInterval(() => safe(), a.every));
|
|
55
|
+
if (a.on) unsubs.push(sim.onLog(typeof a.on === 'function' ? a.on(ctx) : a.on, (log: any) => safe(log)));
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
if (actors.enabled) await actors.toggle(true);
|
|
60
|
+
|
|
61
|
+
// ---- generic controls, reachable through the provider like any RPC method -----------------------------------
|
|
62
|
+
sim.addMethod('terrarium_actors', async (on?: boolean) => { await actors.toggle(on ?? !actors.enabled); return actors.enabled; });
|
|
63
|
+
sim.addMethod('terrarium_status', async () => ({ chainId, engine: sim.engine, block: toHex(sim.blockNumber), accounts: ctx.accounts, actors: actors.enabled, actorsLabel: config.actorsLabel ?? 'Actors', hasActors: (config.actors?.length ?? 0) > 0, wallet: { ...sim.wallet }, controls: config.controls ?? [], restoredFromPersistence: sim.restoredFromPersistence, localBlocks: Number(sim.blockNumber) - (config.fork ? config.fork.blockNumber + 1 : 0), http: { routes: httpRoutes.length, hits: httpHits }, fork: config.fork ? { blockNumber: config.fork.blockNumber, offline: !!config.fork.offline, misses: sim.offlineMisses.length } : null, ...(await config.status?.(ctx)) }));
|
|
64
|
+
sim.addMethod('terrarium_reset', async () => { await actors.toggle(false); sim.stop(); await storage?.clear(); return true; });
|
|
65
|
+
for (const [name, fn] of Object.entries(config.methods ?? {})) sim.addMethod(name, (...args: any[]) => fn(ctx, ...args));
|
|
66
|
+
|
|
67
|
+
// ---- HTTP routes: the page's fetch forwards matching requests here; the handler answers from the chain --------------
|
|
68
|
+
sim.addMethod('terrarium_httpRoutes', () => httpRoutes);
|
|
69
|
+
sim.addMethod('terrarium_http', async (index: number, raw: { url: string; method: string; headers?: Record<string, string>; body?: string | null }) => {
|
|
70
|
+
const route = (config.http ?? [])[index]; if (!route) throw new Error(`no http route ${index}`);
|
|
71
|
+
httpHits++; return runRoute(ctx, route, raw);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
serveProvider(sim.provider);
|
|
75
|
+
return sim;
|
|
76
|
+
}
|