@bluexlab/maos-ts
Advanced tools
Comparing version 0.0.2 to 0.0.3
@@ -0,4 +1,10 @@ | ||
import { ResultAsync } from 'neverthrow'; | ||
import { Level } from 'pino'; | ||
interface MaosConfig { | ||
apiKey?: string; | ||
coreUrl?: string; | ||
logLevel?: Level; | ||
invocationPollInterval?: number; | ||
concurrentInvocationNum?: number; | ||
} | ||
@@ -8,2 +14,13 @@ interface MaosResponse { | ||
} | ||
interface InvocationMetaType extends Record<string, string> { | ||
kind: string; | ||
} | ||
interface Invocation { | ||
id: string; | ||
meta: InvocationMetaType; | ||
payload: Record<string, unknown>; | ||
} | ||
type HandlerInputType = Record<string, unknown>; | ||
type HandlerResultType = ResultAsync<Record<string, unknown>, Error>; | ||
type HandlerType = (input: HandlerInputType) => HandlerResultType; | ||
@@ -13,7 +30,25 @@ declare class Maos { | ||
private coreUrl; | ||
private readonly minInvocationInterval; | ||
private invocationPollInterval; | ||
private concurrentInvocationNum; | ||
private logLevel; | ||
private handlers; | ||
private shouldRun; | ||
private logger; | ||
private abortController; | ||
private abortSignal; | ||
constructor(config?: MaosConfig); | ||
private fetchWithAuth; | ||
getConfig(): Promise<MaosResponse>; | ||
private postWithAuth; | ||
getConfig(): ResultAsync<MaosResponse, Error>; | ||
private getNextInvocation; | ||
private respondToInvocation; | ||
on(methodName: string, handler: HandlerType): Maos; | ||
private getHandler; | ||
private handleInvocation; | ||
private worker; | ||
shutdown(): void; | ||
run(): Promise<void>; | ||
} | ||
export { Maos, type MaosConfig, type MaosResponse }; | ||
export { type HandlerInputType, type HandlerResultType, type HandlerType, type Invocation, type InvocationMetaType, Maos, type MaosConfig, type MaosResponse }; |
// src/maos.ts | ||
import process from "process"; | ||
import process from "node:process"; | ||
import pino from "pino"; | ||
import { Result, ResultAsync as ResultAsync2, err, errAsync, ok, okAsync } from "neverthrow"; | ||
// src/lib/errorHandler.ts | ||
function formatMaosError(error) { | ||
return error instanceof Error ? error.message : String(error); | ||
} | ||
// src/lib/utils.ts | ||
import { ResultAsync } from "neverthrow"; | ||
function isInvocation(obj) { | ||
return "id" in obj && "payload" in obj; | ||
} | ||
function sleep(ms, signal) { | ||
return ResultAsync.fromPromise(new Promise((resolve, reject) => { | ||
let timeoutId = null; | ||
const onAbort = () => { | ||
if (timeoutId) { | ||
clearTimeout(timeoutId); | ||
} | ||
signal?.removeEventListener("abort", onAbort); | ||
reject(new Error("Aborted")); | ||
}; | ||
timeoutId = setTimeout(() => { | ||
signal?.removeEventListener("abort", onAbort); | ||
resolve(); | ||
}, ms); | ||
if (signal) { | ||
if (signal.aborted) { | ||
clearTimeout(timeoutId); | ||
reject(new Error("Aborted")); | ||
} else { | ||
signal.addEventListener("abort", onAbort); | ||
} | ||
} | ||
}), (err2) => err2); | ||
} | ||
function joinUrl(base, ...parts) { | ||
const url = new URL(base); | ||
parts.forEach((part) => { | ||
url.pathname = new URL(part, url).pathname; | ||
}); | ||
return url.toString(); | ||
} | ||
function jsonValueConverter(key, value) { | ||
return typeof value === "bigint" ? value.toString() : value; | ||
} | ||
// src/maos.ts | ||
var NotFoundError = class extends Error { | ||
constructor() { | ||
super("Not found"); | ||
this.name = "NotFoundError"; | ||
} | ||
}; | ||
var Maos = class { | ||
// configs | ||
apiKey; | ||
coreUrl; | ||
minInvocationInterval = 500; | ||
// ms | ||
invocationPollInterval; | ||
concurrentInvocationNum; | ||
logLevel; | ||
// internal state | ||
handlers; | ||
shouldRun = true; | ||
logger; | ||
abortController; | ||
abortSignal; | ||
constructor(config = {}) { | ||
this.apiKey = config.apiKey || process.env.APIKEY || ""; | ||
this.coreUrl = config.coreUrl || process.env.CORE_URL || ""; | ||
this.logLevel = config.logLevel || process.env.LOG_LEVEL || "info"; | ||
this.invocationPollInterval = config.invocationPollInterval || Number.parseInt(process.env.INVOCATION_POLL_INTERVAL || "0") || 10 * 1e3; | ||
this.concurrentInvocationNum = config.concurrentInvocationNum || Number.parseInt(process.env.CONCURRENT_INVOCATION_NUM || "1") || 1; | ||
if (!this.apiKey) { | ||
@@ -15,5 +85,9 @@ throw new Error("API key is required. Please provide it in the constructor or set the APIKEY environment variable."); | ||
} | ||
this.handlers = /* @__PURE__ */ new Map(); | ||
this.logger = pino({ level: this.logLevel }); | ||
this.abortController = new AbortController(); | ||
this.abortSignal = this.abortController.signal; | ||
} | ||
async fetchWithAuth(endpoint, options = {}) { | ||
const url = `${this.coreUrl}${endpoint}`; | ||
fetchWithAuth(endpoint, options = {}, allowAbort = false) { | ||
const url = joinUrl(this.coreUrl, endpoint); | ||
const headers = new Headers({ | ||
@@ -24,15 +98,115 @@ "Authorization": `Bearer ${this.apiKey}`, | ||
}); | ||
const response = await fetch(url, { ...options, headers }); | ||
if (!response.ok) { | ||
throw new Error(`HTTP error! status: ${response.status}`); | ||
const fetchPromise = fetch(url, { ...options, headers, signal: allowAbort ? this.abortSignal : void 0 }); | ||
if (!fetchPromise) | ||
return errAsync(new NotFoundError()); | ||
return ResultAsync2.fromPromise(fetchPromise, (err2) => err2).andThen((res) => { | ||
if (!res) | ||
return err(new Error("No response")); | ||
if (!res.ok) { | ||
return err( | ||
res.status === 404 ? new NotFoundError() : new Error(`HTTP error! status: ${res.status}, statusText: ${res.statusText}`) | ||
); | ||
} | ||
return ok(res); | ||
}).andThen((res) => ResultAsync2.fromPromise(res.text(), (err2) => err2).map((body) => ({ res, body }))).andThen(({ res, body }) => { | ||
this.logger.info(`Response status: ${res.status}, ${res.statusText} body: ${body}`); | ||
if (!body) | ||
return ok({}); | ||
return ok(JSON.parse(body)); | ||
}); | ||
} | ||
postWithAuth(endpoint, payload, allowAbort = false) { | ||
return this.fetchWithAuth(endpoint, { | ||
method: "POST", | ||
body: JSON.stringify(payload, jsonValueConverter) | ||
}, allowAbort); | ||
} | ||
getConfig() { | ||
return this.fetchWithAuth("/v1/config").mapErr((err2) => err2 instanceof Error ? err2 : new Error(String(err2))); | ||
} | ||
getNextInvocation() { | ||
return this.fetchWithAuth("/v1/invocations/next", void 0, true).mapErr((err2) => { | ||
if (err2 instanceof NotFoundError) | ||
return err2; | ||
return new Error(`Failed to get next invocation: ${formatMaosError(err2)}`); | ||
}); | ||
} | ||
respondToInvocation(invokeId, result) { | ||
return result.andThen((res) => this.postWithAuth(`/v1/invocations/${invokeId}/response`, { result: res })).orElse((err2) => this.postWithAuth(`/v1/invocations/${invokeId}/error`, { | ||
errors: { message: formatMaosError(err2) } | ||
})).mapErr((err2) => { | ||
this.logger.error(`Failed to respond to invocation with id ${invokeId}: ${formatMaosError(err2)}`); | ||
return err2; | ||
}); | ||
} | ||
// Register a handler for a method name | ||
on(methodName, handler) { | ||
if (this.handlers.has(methodName)) { | ||
throw new Error(`Handler already registered with method name: ${methodName}`); | ||
} | ||
return await response.json(); | ||
this.handlers.set(methodName, handler); | ||
return this; | ||
} | ||
async getConfig() { | ||
try { | ||
return await this.fetchWithAuth("/config"); | ||
} catch (error) { | ||
throw new Error(`Failed to get config: ${error instanceof Error ? error.message : error}`); | ||
getHandler(methodName) { | ||
const handler = this.handlers.get(methodName); | ||
if (!handler) { | ||
return err(new Error(`Handler not found for method name: ${methodName}`)); | ||
} | ||
return ok(handler); | ||
} | ||
handleInvocation() { | ||
return this.getNextInvocation().andThen((response) => { | ||
const id = response.id; | ||
return ok(response).andThen((inv) => { | ||
if (!isInvocation(inv)) | ||
return err(new Error(`Invocation response is not of valid type: ${JSON.stringify(inv, null, 2)}`)); | ||
const { meta, payload } = inv; | ||
return ok({ meta, payload }); | ||
}).andThen(({ meta, payload }) => { | ||
const kind = meta.kind; | ||
return this.getHandler(kind).map((handler) => ({ payload, handler })); | ||
}).andThen( | ||
// trigger the handler within a throwable context | ||
// even though the handler is expected not to throw, we can still catch any errors thrown | ||
Result.fromThrowable(({ payload, handler }) => handler(payload)) | ||
).asyncAndThen( | ||
(result) => result.andThen((res) => this.respondToInvocation(String(id), okAsync(res))) | ||
).mapErr(async (err2) => { | ||
this.respondToInvocation(String(id), errAsync(err2 instanceof Error ? err2 : new Error(String(err2)))); | ||
return err2; | ||
}); | ||
}); | ||
} | ||
async worker() { | ||
let lastRun = Date.now(); | ||
while (this.shouldRun) { | ||
await this.handleInvocation().mapErr((err2) => { | ||
if (!(err2 instanceof NotFoundError)) { | ||
this.logger.error(`Error during worker run: ${formatMaosError(err2)}`); | ||
this.shouldRun = false; | ||
} | ||
}); | ||
const timeSinceLastRun = Date.now() - lastRun; | ||
if (timeSinceLastRun < this.minInvocationInterval) { | ||
await sleep(this.invocationPollInterval, this.abortSignal).mapErr((err2) => { | ||
if (err2 instanceof Error && err2.message === "Aborted") { | ||
this.logger.info("Worker sleep aborted"); | ||
this.shouldRun = false; | ||
} else { | ||
this.logger.error(`Error during worker sleep: ${formatMaosError(err2)}`); | ||
} | ||
}); | ||
} | ||
lastRun = Date.now(); | ||
} | ||
} | ||
shutdown() { | ||
this.abortController.abort(); | ||
this.shouldRun = false; | ||
} | ||
async run() { | ||
process.on("SIGTERM", () => this.shutdown()); | ||
process.on("SIGINT", () => this.shutdown()); | ||
await Promise.all(Array.from({ length: this.concurrentInvocationNum }, () => this.worker())); | ||
} | ||
}; | ||
@@ -39,0 +213,0 @@ export { |
{ | ||
"name": "@bluexlab/maos-ts", | ||
"type": "module", | ||
"version": "0.0.2", | ||
"version": "0.0.3", | ||
"packageManager": "pnpm@9.5.0", | ||
"description": "TypeScript binding of MAOS", | ||
@@ -27,3 +28,16 @@ "author": "Kevin Chung <kevin@bluextrade.com>", | ||
}, | ||
"scripts": { | ||
"lint": "eslint --cache .", | ||
"lint:fix": "pnpm run lint --fix", | ||
"build": "tsup", | ||
"dev": "tsup --watch", | ||
"test": "vitest", | ||
"typecheck": "tsc --noEmit", | ||
"format": "prettier --cache --write .", | ||
"release": "bumpp && pnpm publish", | ||
"prepublishOnly": "pnpm run build" | ||
}, | ||
"dependencies": { | ||
"neverthrow": "^7.0.0", | ||
"pino": "^9.3.2", | ||
"process": "^0.11.10" | ||
@@ -41,13 +55,3 @@ }, | ||
"vitest": "^2.0.2" | ||
}, | ||
"scripts": { | ||
"lint": "eslint --cache .", | ||
"lint:fix": "pnpm run lint --fix", | ||
"build": "tsup", | ||
"dev": "tsup --watch", | ||
"test": "vitest", | ||
"typecheck": "tsc --noEmit", | ||
"format": "prettier --cache --write .", | ||
"release": "bumpp && pnpm publish" | ||
} | ||
} | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
22444
459
3
6
4
+ Addedneverthrow@^7.0.0
+ Addedpino@^9.3.2
+ Addedatomic-sleep@1.0.0(transitive)
+ Addedfast-redact@3.5.0(transitive)
+ Addedneverthrow@7.2.0(transitive)
+ Addedon-exit-leak-free@2.1.2(transitive)
+ Addedpino@9.6.0(transitive)
+ Addedpino-abstract-transport@2.0.0(transitive)
+ Addedpino-std-serializers@7.0.0(transitive)
+ Addedprocess-warning@4.0.1(transitive)
+ Addedquick-format-unescaped@4.0.4(transitive)
+ Addedreal-require@0.2.0(transitive)
+ Addedsafe-stable-stringify@2.5.0(transitive)
+ Addedsonic-boom@4.2.0(transitive)
+ Addedsplit2@4.2.0(transitive)
+ Addedthread-stream@3.1.0(transitive)