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

Commit

Permalink
feat: support custom fallback routes
Browse files Browse the repository at this point in the history
  • Loading branch information
fubhy committed Apr 26, 2023
1 parent 46574a8 commit 89ad472
Show file tree
Hide file tree
Showing 9 changed files with 256 additions and 69 deletions.
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
4 changes: 1 addition & 3 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 @@ -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",
);
});
11 changes: 10 additions & 1 deletion packages/anvil.js/src/proxy/fetchLogs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { ProxyResponse } from "./createProxy.js";

/**
* Fetches logs for anvil instances.
*
* @param url URL to anvil proxy
* @param id ID of test worker
* @returns Logs of anvil instance
Expand All @@ -12,5 +15,11 @@ export async function fetchLogs(url: string, id: number): Promise<string[]> {
},
});

return response.json();
const result = (await response.json()) as ProxyResponse<{ logs: string[] }>;

if (result.success === false) {
throw new Error(result.reason);
}

return result.logs;
}
Loading

0 comments on commit 89ad472

Please sign in to comment.