From 125e6ac2fe44d1808c5cbb97b12b561ff8a5a7fb Mon Sep 17 00:00:00 2001 From: Oren Levi Date: Thu, 8 May 2025 14:22:05 +0300 Subject: [PATCH 1/3] add combinator runner binary --- examples/vite-project/package.json | 3 ++- package.json | 3 ++- src/bin/runner.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 src/bin/runner.ts diff --git a/examples/vite-project/package.json b/examples/vite-project/package.json index 88ecdfe..3fbea77 100644 --- a/examples/vite-project/package.json +++ b/examples/vite-project/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "combinator": "combinator-proxy" + "combinator-proxy": "combinator-proxy", + "combinator": "combinator" }, "devDependencies": { "typescript": "~5.7.2", diff --git a/package.json b/package.json index 7bd3e99..f6135b4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dist" ], "bin": { - "combinator-proxy": "dist/proxy.js" + "combinator-proxy": "dist/proxy.js", + "combinator": "dist/runner.js" }, "scripts": { "format:check": "prettier . --check", diff --git a/src/bin/runner.ts b/src/bin/runner.ts new file mode 100644 index 0000000..4132bba --- /dev/null +++ b/src/bin/runner.ts @@ -0,0 +1 @@ +console.log("runner here!\n"); From a1fc1f49df4e806d875949caeee9e8c7bec55bac Mon Sep 17 00:00:00 2001 From: Oren Levi Date: Thu, 8 May 2025 15:35:15 +0300 Subject: [PATCH 2/3] add runner schema --- examples/vite-project/combinator.json | 9 +++++ src/bin/runner.ts | 21 +++++++++- src/lib/proxy/cli.spec.ts | 2 +- src/lib/proxy/cli.ts | 2 +- src/lib/runner/cli.spec.ts | 56 +++++++++++++++++++++++++++ src/lib/runner/cli.ts | 23 +++++++++++ src/lib/runner/config/parse.ts | 24 ++++++++++++ src/lib/runner/config/schema.ts | 18 +++++++++ 8 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 examples/vite-project/combinator.json create mode 100644 src/lib/runner/cli.spec.ts create mode 100644 src/lib/runner/cli.ts create mode 100644 src/lib/runner/config/parse.ts create mode 100644 src/lib/runner/config/schema.ts diff --git a/examples/vite-project/combinator.json b/examples/vite-project/combinator.json new file mode 100644 index 0000000..c6405d9 --- /dev/null +++ b/examples/vite-project/combinator.json @@ -0,0 +1,9 @@ +{ + "apps": [ + { + "name": "vite", + "cmd": "vite --port=${port} --strictPort", + "path": "/vite" + } + ] +} diff --git a/src/bin/runner.ts b/src/bin/runner.ts index 4132bba..690a04d 100644 --- a/src/bin/runner.ts +++ b/src/bin/runner.ts @@ -1 +1,20 @@ -console.log("runner here!\n"); +import fs from "node:fs"; +import { parseArgs } from "@lib/runner/cli"; +import { parseRunnerConfig } from "@lib/runner/config/parse"; +import { panic } from "@lib/utils/panic"; +import type { RunnerConfig } from "@lib/runner/config/schema"; + +const proxyRoutingPath = ".combinator/routing.json"; + +const { port, file } = parseArgs(); + +console.log(`port=${port} file=${file}`); + +const content = fs.readFileSync(file, { encoding: "utf8" }); +const result = parseRunnerConfig(content); +if (result instanceof Error) { + panic(result.message); +} +const config: RunnerConfig = result; + +console.log(config); diff --git a/src/lib/proxy/cli.spec.ts b/src/lib/proxy/cli.spec.ts index 6f35148..eb564d1 100644 --- a/src/lib/proxy/cli.spec.ts +++ b/src/lib/proxy/cli.spec.ts @@ -50,7 +50,7 @@ describe("parseArgs", () => { test("should default to `combinator-proxy.json` file when no fie argument is specified", () => { process.argv = ["path/to/node", "path/to/js-executable"]; expect(parseArgs()).toMatchObject({ - file: "combinator-proxy.json", + file: "./combinator-proxy.json", }); }); }); diff --git a/src/lib/proxy/cli.ts b/src/lib/proxy/cli.ts index e443b6b..2d93347 100644 --- a/src/lib/proxy/cli.ts +++ b/src/lib/proxy/cli.ts @@ -9,7 +9,7 @@ export function parseArgs() { alias: "f", type: "string", description: "path to config file", - default: "combinator-proxy.json", + default: "./combinator-proxy.json", }) .option("port", { alias: "p", diff --git a/src/lib/runner/cli.spec.ts b/src/lib/runner/cli.spec.ts new file mode 100644 index 0000000..2eee200 --- /dev/null +++ b/src/lib/runner/cli.spec.ts @@ -0,0 +1,56 @@ +import { test, expect, describe, beforeAll, afterAll } from "vitest"; +import { parseArgs } from "./cli"; + +describe("parseArgs", () => { + let originalArgv: string[]; + + beforeAll(() => { + originalArgv = process.argv; + }); + + afterAll(() => { + process.argv = originalArgv; + }); + + test("should accept numeric port argument", () => { + process.argv = ["path/to/node", "path/to/js-executable", "--port=1234"]; + expect(parseArgs()).toMatchObject({ + port: 1234, + }); + }); + + test("should accept numeric port argument as shorthand `p`", () => { + process.argv = ["path/to/node", "path/to/js-executable", "-p", "1234"]; + expect(parseArgs()).toMatchObject({ + port: 1234, + }); + }); + + test("should default to port 8080 when no port is specified", () => { + process.argv = ["path/to/node", "path/to/js-executable"]; + expect(parseArgs()).toMatchObject({ + port: 8080, + }); + }); + + test("should accept path to config file argument", () => { + process.argv = ["path/to/node", "path/to/js-executable", "--file=/path/to/file"]; + expect(parseArgs()).toMatchObject({ + file: "/path/to/file", + }); + }); + + test("should accept path to config file as shorthand `f`", () => { + process.argv = ["path/to/node", "path/to/js-executable", "-f", "/path/to/file"]; + expect(parseArgs()).toMatchObject({ + file: "/path/to/file", + }); + }); + + test("should default to `combinator.json` file when no fie argument is specified", () => { + process.argv = ["path/to/node", "path/to/js-executable"]; + expect(parseArgs()).toMatchObject({ + file: "./combinator.json", + }); + }); +}); diff --git a/src/lib/runner/cli.ts b/src/lib/runner/cli.ts new file mode 100644 index 0000000..4a5d9c8 --- /dev/null +++ b/src/lib/runner/cli.ts @@ -0,0 +1,23 @@ +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +export function parseArgs() { + const args = yargs(hideBin(process.argv)) + .scriptName("combinator") + .usage("$0 [args]") + .option("file", { + alias: "f", + type: "string", + description: "path to config file", + default: "./combinator.json", + }) + .option("port", { + alias: "p", + type: "number", + description: "port to use", + default: 8080, + }) + .parseSync(); + + return args; +} diff --git a/src/lib/runner/config/parse.ts b/src/lib/runner/config/parse.ts new file mode 100644 index 0000000..566c195 --- /dev/null +++ b/src/lib/runner/config/parse.ts @@ -0,0 +1,24 @@ +import { ZodError } from "zod"; +import { type RunnerConfig, schema } from "./schema"; + +/** + * Parses and validates raw string content to config object. + * + * Errors are formatted for stdout/stderr. + */ +export function parseRunnerConfig(strData: string): RunnerConfig | Error { + try { + const result = schema.parse(JSON.parse(strData)); + delete result.$schema; + return result; + } catch (error) { + if (error instanceof ZodError) { + const issues = error.issues.map((issue) => `- ${issue.message} "${issue.path.join(".")}"`); + return new Error(`invalid config:\n${issues.join("\n")}`); + } else if (error instanceof SyntaxError) { + return new Error(`invalid config: not a valid json:\n${error.message}\nconfig: ${strData}`); + } else { + return new Error(`unexpected error: ${error}`); + } + } +} diff --git a/src/lib/runner/config/schema.ts b/src/lib/runner/config/schema.ts new file mode 100644 index 0000000..8147fe9 --- /dev/null +++ b/src/lib/runner/config/schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +const app = z + .object({ + name: z.string().describe("app name, used in logs and commands"), + cmd: z.string().describe("command that starts the app"), + path: z.string().describe("exposed path that serves the app"), + }) + .strict(); + +export const schema = z + .object({ + $schema: z.string().optional(), + apps: z.array(app), + }) + .strict(); + +export type RunnerConfig = z.infer; From 21e1e1924d5a1e5ba4fe5f4da17cf8338662701f Mon Sep 17 00:00:00 2001 From: Oren Levi Date: Thu, 8 May 2025 17:34:53 +0300 Subject: [PATCH 3/3] wip - basic working example --- .gitignore | 3 ++ examples/vite-project/vite.config.ts | 5 +++ package.json | 1 + pnpm-lock.yaml | 61 ++++++++++++++++++++++++++++ src/bin/runner.ts | 51 +++++++++++++++++++++++ src/lib/runner/get-random-port.ts | 19 +++++++++ src/lib/utils/get-dirname.ts | 7 ++++ 7 files changed, 147 insertions(+) create mode 100644 examples/vite-project/vite.config.ts create mode 100644 src/lib/runner/get-random-port.ts create mode 100644 src/lib/utils/get-dirname.ts diff --git a/.gitignore b/.gitignore index aadfdf1..e658feb 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ examples/**/package-lock.json examples/.npm-local/* .DS_Store + +# combinator +.combinator diff --git a/examples/vite-project/vite.config.ts b/examples/vite-project/vite.config.ts new file mode 100644 index 0000000..a37e108 --- /dev/null +++ b/examples/vite-project/vite.config.ts @@ -0,0 +1,5 @@ +import type { UserConfig } from 'vite' + +export default { + base: "/vite", +} satisfies UserConfig diff --git a/package.json b/package.json index f6135b4..bbfd0c9 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "zx": "^8.5.3" }, "dependencies": { + "concurrently": "^9.1.2", "express": "^5.1.0", "http-proxy-middleware": "^3.0.5", "testcontainers": "^10.25.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af66368..a7189d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + concurrently: + specifier: ^9.1.2 + version: 9.1.2 express: specifier: ^5.1.0 version: 5.1.0 @@ -628,6 +631,10 @@ packages: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -654,6 +661,11 @@ packages: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} + concurrently@9.1.2: + resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==} + engines: {node: '>=18'} + hasBin: true + content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -890,6 +902,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1246,6 +1262,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.2: + resolution: {integrity: sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -1321,6 +1341,14 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + tar-fs@2.1.2: resolution: {integrity: sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==} @@ -1374,6 +1402,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2038,6 +2070,11 @@ snapshots: loupe: 3.1.3 pathval: 2.0.0 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + check-error@2.1.1: {} chownr@1.1.4: {} @@ -2066,6 +2103,16 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 + concurrently@9.1.2: + dependencies: + chalk: 4.1.2 + lodash: 4.17.21 + rxjs: 7.8.2 + shell-quote: 1.8.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -2339,6 +2386,8 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -2716,6 +2765,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.2: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -2808,6 +2859,14 @@ snapshots: dependencies: ansi-regex: 6.1.0 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + tar-fs@2.1.2: dependencies: chownr: 1.1.4 @@ -2887,6 +2946,8 @@ snapshots: toidentifier@1.0.1: {} + tree-kill@1.2.2: {} + tslib@2.8.1: {} tweetnacl@0.14.5: {} diff --git a/src/bin/runner.ts b/src/bin/runner.ts index 690a04d..85f827e 100644 --- a/src/bin/runner.ts +++ b/src/bin/runner.ts @@ -1,8 +1,14 @@ import fs from "node:fs"; +import path from "node:path"; + import { parseArgs } from "@lib/runner/cli"; import { parseRunnerConfig } from "@lib/runner/config/parse"; import { panic } from "@lib/utils/panic"; import type { RunnerConfig } from "@lib/runner/config/schema"; +import { getRandomPort } from "@lib/runner/get-random-port"; +import { ProxyConfig } from "@lib/proxy/config/schema"; +import concurrently from "concurrently"; +import { getDirname } from "@lib/utils/get-dirname"; const proxyRoutingPath = ".combinator/routing.json"; @@ -18,3 +24,48 @@ if (result instanceof Error) { const config: RunnerConfig = result; console.log(config); + +const appsWithPort = await Promise.all( + config.apps.map(async (app) => { + const port = await getRandomPort(); + return { + name: app.name, + path: app.path, + port, + command: app.cmd.replace("${port}", port.toFixed(0)), + }; + }), +); + +console.log(appsWithPort); +const proxyConfig: ProxyConfig = { + routes: appsWithPort.map((app) => ({ + path: app.path, + target: `http://localhost:${app.port}${app.path}`, + })), +}; + +try { + const dirname = path.dirname(proxyRoutingPath); + fs.rmSync(dirname, { recursive: true, force: true }); + fs.mkdirSync(dirname); + fs.writeFileSync(proxyRoutingPath, JSON.stringify(proxyConfig, null, 2)); +} catch (error) { + panic(error as string); +} + +const __dirname = getDirname(import.meta.url); +console.log(__dirname); + +concurrently( + [ + { + command: `${__dirname}/proxy.js --port=${port} --file=${proxyRoutingPath}`, + name: "@proxy", + }, + ...appsWithPort, + ], + { + killOthers: "failure", + }, +); diff --git a/src/lib/runner/get-random-port.ts b/src/lib/runner/get-random-port.ts new file mode 100644 index 0000000..5d15872 --- /dev/null +++ b/src/lib/runner/get-random-port.ts @@ -0,0 +1,19 @@ +import net from "node:net"; + +export async function getRandomPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + + server.listen(0, () => { + const address = server.address(); + server.close(() => { + if (address instanceof Object) { + return resolve(address.port); + } + reject(`typeof server.address is ${typeof address} not net.AddressInfo: ${address}`); + }); + }); + }); +} diff --git a/src/lib/utils/get-dirname.ts b/src/lib/utils/get-dirname.ts new file mode 100644 index 0000000..31a3ac1 --- /dev/null +++ b/src/lib/utils/get-dirname.ts @@ -0,0 +1,7 @@ +import { fileURLToPath } from "url"; +import { dirname } from "path"; + +export function getDirname(fileURL: string): string { + const filePath = fileURLToPath(fileURL); + return dirname(filePath); +}