Skip to content
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
9 changes: 9 additions & 0 deletions .changeset/odd-dryers-clap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@plutolang/simulator-adapter": patch
---

feat(simulator): set default port and enable direct client API invocation

Set simulator's default port to 9001 to standardize the starting point.

Enable invocation of client APIs through simulator API formatted as `http://<simulator_url>/<resource_id>/<method>`. This allows users to test client APIs directly using curl, simplifying the testing process.
4 changes: 3 additions & 1 deletion components/adapters/simulator/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,9 +23,11 @@
"@plutolang/base": "workspace:^",
"@plutolang/pluto": "workspace:^",
"@plutolang/pluto-infra": "workspace:^",
"cors": "^2.8.5"
"cors": "^2.8.5",
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.20",
"@types/node": "^20.8.4",
"@vitest/coverage-v8": "^0.34.6",
"typescript": "^5.2.2",
Expand Down
13 changes: 13 additions & 0 deletions components/adapters/simulator/src/errors.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
export class ResourceNotFound extends Error {
constructor(resource: string) {
super(`Resource not found: ${resource}`);
this.name = "ResourceNotFound";
}
}

export class MethodNotFound extends Error {
constructor(method: string) {
super(`Method not found: ${method}`);
this.name = "MethodNotFound";
}
}
207 changes: 124 additions & 83 deletions components/adapters/simulator/src/simulator.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
import fs from "fs";
import http from "http";
import path from "path";
import express from "express";
import { currentLanguage } from "@plutolang/base/utils";
import { LanguageType, arch, simulator } from "@plutolang/base";
import { ComputeClosure, AnyFunction, createClosure } from "@plutolang/base/closure";

const SIM_HANDLE_PATH = "/call";
import { MethodNotFound, ResourceNotFound } from "./errors";

export class Simulator {
private readonly projectRoot: string;
Expand DownExpand Up@@ -200,90 +200,21 @@ export class Simulator {
}

public async start(): Promise<void> {
const requestHandler = async (req: http.IncomingMessage, res: http.ServerResponse) => {
if (!req.url?.startsWith(SIM_HANDLE_PATH)) {
res.writeHead(404);
res.end();
return;
const expressApp = this.createExpress();
for (let port = 9001; ; port++) {
const server = await tryListen(expressApp, port);
if (server === undefined) {
continue;
}

let body = "";
req.on("data", (chunk) => {
body += chunk;
});

req.on("end", () => {
const request: simulator.ServerRequest = JSON.parse(body);
const { resourceId, op, args } = request;
if (process.env.DEBUG) {
console.info(`Simulator: receive a request: ${resourceId}.${op}(${args})`);
}

// find the resource
const resource = this.resources.get(resourceId);
if (!resource) {
throw new Error(`Resource ${resourceId} not found.`);
}

let result: any;
try {
// invoke the method
result = (resource as any)[op](...args);
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
const replyError = err instanceof Error ? err : new Error(`${err}`);
res.end(
JSON.stringify({
error: {
message: replyError.message,
stack: replyError.stack,
name: replyError.name,
},
}),
"utf-8"
);
return;
}

if (!(result instanceof Promise)) {
// The called method is not async.
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ result }), "utf-8");
} else {
// The called method is async.
result
.then((result: any) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ result }), "utf-8");
})
.catch((err: any) => {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: {
message: err.message,
stack: err.stack,
name: err.name,
},
}),
"utf-8"
);
});
}
});
};
const addr = server.address();
if (addr && typeof addr === "object" && addr.port) {
this._serverUrl = `http://${addr.address}:${addr.port}`;
}
this._server = server;

const server = http.createServer(requestHandler);
await new Promise<void>((resolve) => {
server!.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (addr && typeof addr === "object" && addr.port) {
this._serverUrl = `http://${addr.address}:${addr.port}`;
}
this._server = server;
resolve();
});
});
break;
}
}

public async stop(): Promise<void> {
Expand All@@ -305,6 +236,95 @@ export class Simulator {
}
return this._serverUrl;
}

private createExpress() {
const invokeAndReply = async (
resourceId: string,
method: string,
args: any[],
res: express.Response
) => {
try {
const result = await this.invokeMethod(resourceId, method, args);
res.status(200).json({ result });
} catch (err: any) {
if (err instanceof MethodNotFound || err instanceof ResourceNotFound) {
res.status(404).json({
error: {
message: err.message,
stack: err.message,
name: err.name,
},
});
} else {
const replyError = err instanceof Error ? err : new Error(`${err}`);
res.status(500).json({
error: {
message: replyError.message,
stack: replyError.stack,
name: replyError.name,
},
});
}
}
};

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.post("/call", async (req: express.Request, res: express.Response) => {
const { resourceId, op, args } = req.body;
await invokeAndReply(resourceId, op, args, res);
});

app.post("/:resourceId/:method", async (req: express.Request, res: express.Response) => {
const { resourceId, method } = req.params;
const args = req.body;
await invokeAndReply(resourceId, method, args, res);
});

return app;
}

/**
* Invokes a method on a resource instance. The resource Id can be a partial ID. But if multiple
* resources are found for the given ID, an error is thrown.
*
* @param resourceId - The ID of the resource instance. It can be a partial ID.
* @param method - The name of the method to invoke.
* @param payload - An array of arguments to pass to the method.
* @returns A promise that resolves to the result of the method invocation.
* @throws {ResourceNotFound} If the resource instance with the given ID is not found.
* @throws {Error} If multiple resource instances are found for the given ID.
* @throws {MethodNotFound} If the method is not found on the resource instance.
*/
private async invokeMethod(resourceId: string, method: string, payload: any[]): Promise<any> {
let candidates: simulator.IResourceInstance[] = [];
if (this.resources.has(resourceId)) {
candidates = [this.resources.get(resourceId)!];
} else {
for (const [id, resource] of this.resources) {
if (id.includes(resourceId) && typeof (resource as any)[method] === "function") {
candidates.push(resource);
}
}
}

if (candidates.length === 0) {
throw new ResourceNotFound(resourceId);
}
if (candidates.length > 1) {
throw new Error(`Multiple resources were found for ${resourceId}`);
}

const methodFn = (candidates[0] as any)[method];
if (typeof methodFn !== "function") {
throw new MethodNotFound(method);
}

return methodFn.apply(candidates[0], payload);
}
}

function isValidJsModule(closurePath: string): boolean {
Expand DownExpand Up@@ -333,3 +353,24 @@ function resolvePkg(pkgName: string): string {
throw new Error(`Cannot find package ${pkgName}`);
}
}

async function tryListen(
server: express.Express,
port: number,
hostname = "0.0.0.0"
): Promise<http.Server | undefined> {
return new Promise((resolve) => {
const httpServer = server.listen(port, hostname);

httpServer.on("listening", () => {
resolve(httpServer);
});

httpServer.on("error", (e) => {
if (process.env.DEBUG) {
console.error(`Failed to listen on port ${port}: ${e}`);
}
resolve(undefined);
});
});
}
Loading