Skip to content
This repository has been archived by the owner on Jun 13, 2024. It is now read-only.

feat: support custom fallback routes #14

Merged
merged 2 commits into from
Apr 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/big-apricots-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@viem/anvil": patch
---

Added support for custom fallback routes.
8 changes: 4 additions & 4 deletions packages/anvil.js/src/anvil/createAnvil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ export function createAnvil(options: CreateAnvilOptions = {}): Anvil {

status = "starting";

// rome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
// rome-ignore lint/suspicious/noAsyncPromiseExecutor: this is fine ...
return new Promise<void>(async (resolve, reject) => {
let log: string | undefined = undefined;

Expand Down Expand Up @@ -433,9 +433,9 @@ export function createAnvil(options: CreateAnvilOptions = {}): Anvil {
emitter.emit("exit", code ?? undefined, signal ?? undefined);
});

// rome-ignore lint/style/noNonNullAssertion: <explanation>
// rome-ignore lint/style/noNonNullAssertion: this is guaranteed to be defined
anvil.pipeStdout!(stdout);
// rome-ignore lint/style/noNonNullAssertion: <explanation>
// rome-ignore lint/style/noNonNullAssertion: this is guaranteed to be defined
anvil.pipeStderr!(stderr);
});
}
Expand Down Expand Up @@ -473,7 +473,7 @@ export function createAnvil(options: CreateAnvilOptions = {}): Anvil {
return {
start,
stop,
// rome-ignore lint/suspicious/noExplicitAny: <explanation>
// rome-ignore lint/suspicious/noExplicitAny: typed via the return type
on: (event: string, listener: any) => {
emitter.on(event, listener);

Expand Down
8 changes: 3 additions & 5 deletions packages/anvil.js/src/pool/createPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect, test, afterEach, beforeEach } from "vitest";
import { createPool, type Pool } from "./createPool.js";
import { createAnvilClients } from "../../tests/utils/utils.js";

let pool: Pool<number>;
let pool: Pool;
beforeEach(() => {
pool = createPool();
});
Expand Down Expand Up @@ -74,12 +74,10 @@ test("can close instances", async () => {
expect(pool.has(3)).toBe(false);
});

test("throws when trying to close an instance that doesn't exist", async () => {
test("doesn't throw when trying to close an instance that doesn't exist", async () => {
await pool.start(1);
await expect(pool.stop(1)).resolves.toBe(undefined);
await expect(pool.stop(2)).rejects.toThrowErrorMatchingInlineSnapshot(
'"Anvil instance with id \\"2\\" doesn\'t exist"',
);
await expect(pool.stop(2)).resolves.toBe(undefined);
});

test("can use the same ids after closing instances", async () => {
Expand Down
6 changes: 2 additions & 4 deletions packages/anvil.js/src/pool/createPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ export type Pool<TKey = number> = {
*
* @param id The id of the instance.
* @returns A promise that resolves when the instance has stopped.
* @throws If an instance with the given id already exists.
* @throws If the instance didn't stop gracefully.
*/
stop: (id: TKey) => Promise<void>;
Expand Down Expand Up @@ -90,7 +89,7 @@ export function createPool<TKey = number>({
throw new Error(`Anvil instance limit of ${instanceLimit} reached`);
}

// rome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
// rome-ignore lint/suspicious/noAsyncPromiseExecutor: this is fine ...
const anvil = new Promise<Anvil>(async (resolve, reject) => {
try {
const opts = {
Expand Down Expand Up @@ -118,9 +117,8 @@ export function createPool<TKey = number>({

async function stop(id: TKey) {
const anvil = instances.get(id);

if (anvil === undefined) {
throw new Error(`Anvil instance with id "${id}" doesn't exist`);
return;
}

instances.delete(id);
Expand Down
207 changes: 162 additions & 45 deletions packages/anvil.js/src/proxy/createProxy.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,59 @@
import httpProxy from "http-proxy";
import { IncomingMessage, createServer } from "node:http";
import { parseRequest } from "./parseRequest.js";
import { IncomingMessage, ServerResponse, createServer } from "node:http";
import { parseRequest, type InstanceRequestContext } from "./parseRequest.js";
import { type Pool } from "../pool/createPool.js";
import type { Awaitable } from "vitest";
import type { CreateAnvilOptions } from "../anvil/createAnvil.js";

export type AnvilProxyOptions =
| CreateAnvilOptions
| ((id: number, request: IncomingMessage) => Awaitable<CreateAnvilOptions>);
// rome-ignore lint/nursery/noBannedTypes: this is fine ...
export type ProxyResponseSuccess<TResponse extends object = {}> = {
success: true;
} & TResponse;

export type ProxyResponseFailure = {
success: false;
reason: string;
};

export type ProxyResponse<TResponse extends object> =
| ProxyResponseSuccess<TResponse>
| ProxyResponseFailure;

export type ProxyRequestHandler = (
req: IncomingMessage,
res: ServerResponse,
context: ProxyRequestContext,
) => Awaitable<void>;

export type ProxyRequestContext = {
pool: Pool;
options?: AnvilProxyOptions | undefined;
// rome-ignore lint/nursery/noBannedTypes: this is fine ...
} & (InstanceRequestContext | {});

/**
* A function callback to dynamically derive the options based on the request.
*/
export type AnvilProxyOptionsFn = (
id: number,
request: IncomingMessage,
) => Awaitable<CreateAnvilOptions>;

export type AnvilProxyOptions = CreateAnvilOptions | AnvilProxyOptionsFn;

export type CreateProxyOptions = {
/**
* The pool of anvil instances.
*/
pool: Pool<number>;
pool: Pool;
/**
* The options to pass to each anvil instance.
*/
options?: AnvilProxyOptions | undefined;
/**
* A function callback to handle custom proxy requests.
*/
fallback?: ProxyRequestHandler | undefined;
};

/**
Expand All @@ -28,7 +64,7 @@ export type CreateProxyOptions = {
* import { createProxy, createPool } from "@viem/anvil";
*
* const server = const createProxy({
* pool: createPool<number>(),
* pool: createPool(),
* options: {
* forkUrl: "https://eth-mainnet.alchemyapi.io/v2/<API_KEY>",
* blockNumber: 12345678,
Expand All @@ -40,69 +76,150 @@ export type CreateProxyOptions = {
* });
* ```
*/
export function createProxy({ pool, options }: CreateProxyOptions) {
export function createProxy({ pool, options, fallback }: CreateProxyOptions) {
const proxy = httpProxy.createProxyServer({
ignorePath: true,
ws: true,
});

const server = createServer(async (req, res) => {
const { id, path } = parseRequest(req.url);

if (id === undefined) {
res.writeHead(404).end("Missing instance id in request");
} else if (path === "/logs") {
const anvil = await pool.get(id);

if (anvil !== undefined) {
const output = JSON.stringify((await anvil.logs) ?? []);
res.writeHead(200).end(output);
} else {
res.writeHead(404).end(`Anvil instance doesn't exists.`);
try {
const context = parseRequest(req.url);

if (context !== undefined) {
switch (context.path) {
case "/": {
const anvil =
(await pool.get(context.id)) ??
(await pool.start(
context.id,
typeof options === "function"
? await options(context.id, req)
: options,
));

return proxy.web(req, res, {
target: `http://${anvil.host}:${anvil.port}`,
});
}

case "/start": {
if (pool.has(context.id)) {
return sendFailure(res, {
code: 404,
reason: "Anvil instance already exists",
});
}

await pool.start(
context.id,
typeof options === "function"
? await options(context.id, req)
: options,
);

return sendSuccess(res);
}

case "/stop": {
const success = await pool
.stop(context.id)
.then(() => true)
.catch(() => false);

return sendResponse(res, 200, { success });
}

case "/logs": {
const anvil = await pool.get(context.id);

if (anvil !== undefined) {
const logs = (await anvil.logs) ?? [];
return sendSuccess(res, { logs });
}

return sendFailure(res, {
code: 404,
reason: `Anvil instance doesn't exists`,
});
}
}
}
} else if (path === "/shutdown") {
if (pool.has(id)) {
const output = JSON.stringify({ success: await pool.stop(id) });
res.writeHead(200).end(output);
} else {
res.writeHead(404).end(`Anvil instance doesn't exists.`);

if (fallback !== undefined) {
return await fallback(req, res, { ...context, pool, options });
}
} else if (path === "/") {
const anvil =
(await pool.get(id)) ??
(await pool.start(
id,
typeof options === "function" ? await options(id, req) : options,
));

proxy.web(req, res, {
target: `http://${anvil.host}:${anvil.port}`,
return sendFailure(res, {
code: 404,
reason: "Unsupported request",
});
} catch (error) {
console.error(error);

return sendFailure(res, {
code: 500,
reason: "Internal server error",
});
} else {
res.writeHead(404).end("Invalid request");
}
});

server.on("upgrade", async (req, socket, head) => {
const { id, path } = parseRequest(req.url);
const context = parseRequest(req.url);

if (id === undefined) {
socket.destroy(new Error("Anvil instance doesn't exists."));
} else if (path === "/") {
if (context?.path === "/") {
const anvil =
(await pool.get(id)) ??
(await pool.get(context.id)) ??
(await pool.start(
id,
typeof options === "function" ? await options(id, req) : options,
context.id,
typeof options === "function"
? await options(context.id, req)
: options,
));

proxy.ws(req, socket, head, {
target: `ws://${anvil.host}:${anvil.port}`,
});
} else {
socket.destroy(new Error("Invalid request"));
socket.destroy(new Error("Unsupported request"));
}
});

return server;
}

function sendFailure(
res: ServerResponse,
{
reason = "Unsupported request",
code = 400,
}: { reason?: string; code?: number } = {},
) {
sendResponse(res, code, {
reason,
success: false,
});
}

function sendSuccess(
res: ServerResponse,
output?: { success?: never; [key: string]: unknown },
) {
sendResponse(res, 200, {
...output,
success: true,
});
}

function sendResponse(
res: ServerResponse,
code = 200,
output?: { success?: boolean; [key: string]: unknown },
) {
const json = JSON.stringify({
...output,
success: output?.success ?? code === 200,
});

res.writeHead(200).end(json);
}
33 changes: 33 additions & 0 deletions packages/anvil.js/src/proxy/fetchLogs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { beforeAll, afterEach, expect, test } from "vitest";
import { startProxy } from "./startProxy.js";
import getPort from "get-port";
import { createPool } from "../pool/createPool.js";
import { fetchLogs } from "./fetchLogs.js";

const pool = createPool();
afterEach(async () => {
await pool.empty();
});

let port: number;
beforeAll(async () => {
port = await getPort();
return await startProxy({ port, pool });
});

test("can fetch logs from anvil instance", async () => {
await pool.start(1);
await expect(fetchLogs(`http://localhost:${port}`, 1)).resolves.toMatchObject(
expect.arrayContaining([
expect.stringMatching(
/test test test test test test test test test test test junk/,
),
]),
);
});

test("throws if trying to fetch logs from non-existent anvil instance", async () => {
await expect(fetchLogs(`http://localhost:${port}`, 123)).rejects.toThrow(
"Anvil instance doesn't exist",
);
});
Loading