From bdea514f681221e317c8a71fc278bd9aa4ae1f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=A7=E6=BF=AF?= Date: Fri, 26 Jul 2024 15:44:04 +0800 Subject: [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:////`. This allows users to test client APIs directly using curl, simplifying the testing process. --- .changeset/odd-dryers-clap.md | 9 + components/adapters/simulator/package.json | 4 +- components/adapters/simulator/src/errors.ts | 13 ++ .../adapters/simulator/src/simulator.ts | 207 +++++++++++------- pnpm-lock.yaml | 76 ++++--- 5 files changed, 191 insertions(+), 118 deletions(-) create mode 100644 .changeset/odd-dryers-clap.md create mode 100644 components/adapters/simulator/src/errors.ts diff --git a/.changeset/odd-dryers-clap.md b/.changeset/odd-dryers-clap.md new file mode 100644 index 00000000..c95763f2 --- /dev/null +++ b/.changeset/odd-dryers-clap.md @@ -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:////`. This allows users to test client APIs directly using curl, simplifying the testing process. diff --git a/components/adapters/simulator/package.json b/components/adapters/simulator/package.json index e8cda4d4..47fac84d 100644 --- a/components/adapters/simulator/package.json +++ b/components/adapters/simulator/package.json @@ -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", diff --git a/components/adapters/simulator/src/errors.ts b/components/adapters/simulator/src/errors.ts new file mode 100644 index 00000000..606fbb58 --- /dev/null +++ b/components/adapters/simulator/src/errors.ts @@ -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"; + } +} diff --git a/components/adapters/simulator/src/simulator.ts b/components/adapters/simulator/src/simulator.ts index 089c8224..b0c7c62b 100644 --- a/components/adapters/simulator/src/simulator.ts +++ b/components/adapters/simulator/src/simulator.ts @@ -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; @@ -200,90 +200,21 @@ export class Simulator { } public async start(): Promise { - 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((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 { @@ -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 { + 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 { @@ -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 { + 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); + }); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb3e9b7d..d2f62a6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,7 +196,13 @@ importers: cors: specifier: ^2.8.5 version: 2.8.5 + express: + specifier: ^4.18.2 + version: 4.18.2 devDependencies: + '@types/express': + specifier: ^4.17.20 + version: 4.17.20 '@types/node': specifier: ^20.8.4 version: 20.10.4 @@ -362,17 +368,17 @@ importers: dependencies: '@plutolang/pluto': specifier: latest - version: 0.4.13 + version: 0.4.16 openai: specifier: ^4.13.0 version: 4.13.0 devDependencies: '@plutolang/base': specifier: latest - version: 0.4.6 + version: 0.4.9 '@plutolang/pluto-infra': specifier: latest - version: 0.4.22 + version: 0.4.25 '@pulumi/pulumi': specifier: ^3.88.0 version: 3.88.0 @@ -387,17 +393,17 @@ importers: dependencies: '@plutolang/pluto': specifier: latest - version: 0.4.13 + version: 0.4.16 '@slack/web-api': specifier: ^6.9.0 version: 6.9.0 devDependencies: '@plutolang/base': specifier: latest - version: 0.4.6 + version: 0.4.9 '@plutolang/pluto-infra': specifier: latest - version: 0.4.22 + version: 0.4.25 '@pulumi/pulumi': specifier: ^3.88.0 version: 3.88.0 @@ -412,10 +418,10 @@ importers: devDependencies: '@plutolang/base': specifier: latest - version: 0.4.6 + version: 0.4.9 '@plutolang/pluto-infra': specifier: latest - version: 0.4.22 + version: 0.4.25 '@pulumi/pulumi': specifier: ^3.88.0 version: 3.88.0 @@ -430,10 +436,10 @@ importers: devDependencies: '@plutolang/base': specifier: latest - version: 0.4.6 + version: 0.4.9 '@plutolang/pluto-infra': specifier: latest - version: 0.4.22 + version: 0.4.25 '@pulumi/pulumi': specifier: ^3.88.0 version: 3.88.0 @@ -448,14 +454,14 @@ importers: dependencies: '@plutolang/pluto': specifier: latest - version: 0.4.13 + version: 0.4.16 devDependencies: '@plutolang/base': specifier: latest - version: 0.4.6 + version: 0.4.9 '@plutolang/pluto-infra': specifier: latest - version: 0.4.22 + version: 0.4.25 '@pulumi/pulumi': specifier: ^3.88.0 version: 3.88.0 @@ -507,10 +513,10 @@ importers: devDependencies: '@plutolang/base': specifier: latest - version: 0.4.6 + version: 0.4.9 '@plutolang/pluto-infra': specifier: latest - version: 0.4.22 + version: 0.4.25 '@pulumi/pulumi': specifier: ^3.88.0 version: 3.88.0 @@ -562,14 +568,14 @@ importers: dependencies: '@plutolang/pluto': specifier: latest - version: 0.4.13 + version: 0.4.16 devDependencies: '@plutolang/base': specifier: latest - version: 0.4.6 + version: 0.4.9 '@plutolang/pluto-infra': specifier: latest - version: 0.4.22 + version: 0.4.25 '@pulumi/pulumi': specifier: ^3.88.0 version: 3.88.0 @@ -584,10 +590,10 @@ importers: devDependencies: '@plutolang/base': specifier: latest - version: 0.4.6 + version: 0.4.9 '@plutolang/pluto-infra': specifier: latest - version: 0.4.22 + version: 0.4.25 '@pulumi/pulumi': specifier: ^3.88.0 version: 3.88.0 @@ -602,10 +608,10 @@ importers: devDependencies: '@plutolang/base': specifier: latest - version: 0.4.6 + version: 0.4.9 '@plutolang/pluto-infra': specifier: latest - version: 0.4.22 + version: 0.4.25 '@pulumi/pulumi': specifier: ^3.88.0 version: 3.88.0 @@ -2479,14 +2485,14 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@plutolang/base@0.4.6': - resolution: {integrity: sha512-zHVlmcsrg42qySAdWh6PFCt5n0KyFTPDktv3whmb/DqW1bn1iUvgLoNr7Ei8k1ztnO8js6+llWC0gurvJIEtpw==} + '@plutolang/base@0.4.9': + resolution: {integrity: sha512-1x5wAZ0dfqe68vVHBBMZpH0XVjy6UFK2huYUrpKntkWZmysflSgsxf7AK756PVzPEh3oILiWxFdEdL6+2jRCug==} - '@plutolang/pluto-infra@0.4.22': - resolution: {integrity: sha512-NyXYAfNjM/V9d2eViHf7HblZ4IXF2uEmbTrmLj3RyuK53LTvMRvbSGzdJH+tufeJ3kKAOotGVi16lTNCgYhb6g==} + '@plutolang/pluto-infra@0.4.25': + resolution: {integrity: sha512-7ziGq2oe35yXJb+npIck7iWy2ry/TzsRJg66KHFZrEnUnlsn4EVQ4/sI3W3zdzlOlU/Qv64tNqfW88F5Ez/6eQ==} - '@plutolang/pluto@0.4.13': - resolution: {integrity: sha512-u/J+oKN9L/B6vb3dx1iKhskGVXF6AzNU+mKhaWEbJPqt8IXfRzAnI+eUNfXoAgiCT2k9xbjgadg0njJRI1usiw==} + '@plutolang/pluto@0.4.16': + resolution: {integrity: sha512-KqczdJTqSkeosq5MQGe6IHQGhO9eu7zwVukN4Wjk3ltObt4uEE3Q1s+w+DxrbF4s8gXJouMyqbEdJH650lKflA==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -4270,6 +4276,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported global-dirs@3.0.1: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} @@ -5891,6 +5898,7 @@ packages: read-package-json@2.1.2: resolution: {integrity: sha512-D1KmuLQr6ZSJS0tW8hf3WGpRlwszJOXZ3E8Yd/DNRaM5d+1wVRZdHlpGBLAuovjr28LbWvjpWkBHMxpRGGjzNA==} + deprecated: This package is no longer supported. Please use @npmcli/package-json instead. read-package-tree@5.3.1: resolution: {integrity: sha512-mLUDsD5JVtlZxjSlPPx1RETkNjjvQYuweKwNVt1Sn8kP5Jh44pvYuUHCp6xSVDZWbNxVxG5lyZJ921aJH61sTw==} @@ -9120,7 +9128,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@plutolang/base@0.4.6': + '@plutolang/base@0.4.9': dependencies: fs-extra: 11.1.1 js-yaml: 4.1.0 @@ -9129,10 +9137,10 @@ snapshots: transitivePeerDependencies: - encoding - '@plutolang/pluto-infra@0.4.22': + '@plutolang/pluto-infra@0.4.25': dependencies: - '@plutolang/base': 0.4.6 - '@plutolang/pluto': 0.4.13 + '@plutolang/base': 0.4.9 + '@plutolang/pluto': 0.4.16 '@pulumi/alicloud': 3.45.0 '@pulumi/archive': 0.0.2 '@pulumi/aws': 6.34.1 @@ -9152,7 +9160,7 @@ snapshots: - encoding - supports-color - '@plutolang/pluto@0.4.13': + '@plutolang/pluto@0.4.16': dependencies: '@alicloud/credentials': 2.3.0 '@alicloud/fc-open20210406': 2.0.13 @@ -9165,7 +9173,7 @@ snapshots: '@aws-sdk/client-secrets-manager': 3.614.0 '@aws-sdk/client-sns': 3.614.0 '@aws-sdk/lib-dynamodb': 3.614.0(@aws-sdk/client-dynamodb@3.614.0) - '@plutolang/base': 0.4.6 + '@plutolang/base': 0.4.9 redis: 4.6.10 transitivePeerDependencies: - aws-crt