From 4c8fad8f4530b699510c2567a59e979656245416 Mon Sep 17 00:00:00 2001 From: Mouaad Aallam Date: Sun, 3 May 2026 01:03:32 +0200 Subject: [PATCH] refactor!: tighten v1 api surface --- .changeset/tidy-v1-api-surface.md | 7 + .../content/docs/architecture/execbox-core.md | 8 +- .../core/__tests__/core/jsonSchema.test.ts | 2 +- .../__tests__/core/runtimeEntrypoint.test.ts | 6 + packages/core/__tests__/core/sanitize.test.ts | 3 +- .../security/isJsonSerializable.test.ts | 2 +- packages/core/etc/execbox-core.api.md | 66 ---- packages/core/src/index.ts | 21 +- packages/quickjs/README.md | 2 - ...ox-quickjs-runner-protocol-endpoint.api.md | 21 -- .../quickjs/etc/execbox-quickjs-runner.api.md | 67 ---- packages/quickjs/package.json | 18 - .../quickjs/src/runner/guestEnvironment.ts | 214 +++++++++++ packages/quickjs/src/runner/index.ts | 339 +----------------- .../quickjs/src/runner/protocolEndpoint.ts | 4 +- packages/quickjs/src/runner/sessionErrors.ts | 140 ++++++++ packages/quickjs/tsdown.config.ts | 8 +- packages/remote/etc/execbox-remote.api.md | 9 - packages/remote/src/index.ts | 6 +- packages/remote/src/types.ts | 24 +- .../test-support/createLoopbackTransport.ts | 11 +- scripts/test-dist-smoke.ts | 6 + scripts/workspace-entrypoints.ts | 16 - tsconfig.json | 4 - 24 files changed, 405 insertions(+), 599 deletions(-) create mode 100644 .changeset/tidy-v1-api-surface.md delete mode 100644 packages/quickjs/etc/execbox-quickjs-runner-protocol-endpoint.api.md delete mode 100644 packages/quickjs/etc/execbox-quickjs-runner.api.md create mode 100644 packages/quickjs/src/runner/guestEnvironment.ts create mode 100644 packages/quickjs/src/runner/sessionErrors.ts diff --git a/.changeset/tidy-v1-api-surface.md b/.changeset/tidy-v1-api-surface.md new file mode 100644 index 0000000..198901e --- /dev/null +++ b/.changeset/tidy-v1-api-surface.md @@ -0,0 +1,7 @@ +--- +"@execbox/core": minor +"@execbox/quickjs": minor +"@execbox/remote": minor +--- + +Tighten the pre-1.0 public API surface by keeping low-level core helpers out of the main `@execbox/core` entrypoint, removing unsupported QuickJS runner subpath exports, and keeping runner-side remote endpoint types with `@execbox/quickjs/remote-endpoint`. diff --git a/docs/src/content/docs/architecture/execbox-core.md b/docs/src/content/docs/architecture/execbox-core.md index 8d52c9b..668cf74 100644 --- a/docs/src/content/docs/architecture/execbox-core.md +++ b/docs/src/content/docs/architecture/execbox-core.md @@ -23,7 +23,7 @@ The core package exposes three main responsibilities: - Define the stable app-facing execution contract - Provide runtime implementers with shared runner semantics through `@execbox/core/runtime` -The main public concepts are: +The main app-facing concepts are: | Concept | Purpose | | ---------------------- | ----------------------------------------------------------------------------------- | @@ -32,8 +32,10 @@ The main public concepts are: | `Executor` | Runtime-specific implementation of `execute(code, providers)` | | `ExecuteResult` | Stable success/error envelope returned by every executor | | `ToolExecutionContext` | Abort-aware metadata passed to each tool invocation | -| `ProviderManifest` | Transport-safe view of a resolved provider exposed to reusable runners | -| `ToolCallResult` | Trusted host response to a runner-emitted tool call | + +Runtime implementer concepts such as `ProviderManifest` and `ToolCallResult` +live on `@execbox/core/runtime` and `@execbox/core/protocol`, not the main +`@execbox/core` entrypoint. ## Provider Resolution Pipeline diff --git a/packages/core/__tests__/core/jsonSchema.test.ts b/packages/core/__tests__/core/jsonSchema.test.ts index f09cf51..d6c4ec0 100644 --- a/packages/core/__tests__/core/jsonSchema.test.ts +++ b/packages/core/__tests__/core/jsonSchema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { generateTypesFromJsonSchema } from "@execbox/core"; +import { generateTypesFromJsonSchema } from "../../src/typegen/jsonSchema"; describe("generateTypesFromJsonSchema", () => { it("emits namespace declarations for object schemas with required and optional fields", () => { diff --git a/packages/core/__tests__/core/runtimeEntrypoint.test.ts b/packages/core/__tests__/core/runtimeEntrypoint.test.ts index 84a4e48..2c26f92 100644 --- a/packages/core/__tests__/core/runtimeEntrypoint.test.ts +++ b/packages/core/__tests__/core/runtimeEntrypoint.test.ts @@ -12,9 +12,15 @@ describe("@execbox/core/runtime", () => { }); it("keeps executor-author helpers out of the app-facing core entrypoint", () => { + expect(core).not.toHaveProperty("assertValidIdentifier"); expect(core).not.toHaveProperty("createToolCallDispatcher"); expect(core).not.toHaveProperty("createTimeoutExecuteResult"); expect(core).not.toHaveProperty("formatConsoleLine"); + expect(core).not.toHaveProperty("generateTypesFromJsonSchema"); + expect(core).not.toHaveProperty("isJsonSerializable"); expect(core).not.toHaveProperty("normalizeThrownMessage"); + expect(core).not.toHaveProperty("sanitizeIdentifier"); + expect(core).not.toHaveProperty("sanitizeToolName"); + expect(core).not.toHaveProperty("serializePropertyName"); }); }); diff --git a/packages/core/__tests__/core/sanitize.test.ts b/packages/core/__tests__/core/sanitize.test.ts index b29db8b..86ac272 100644 --- a/packages/core/__tests__/core/sanitize.test.ts +++ b/packages/core/__tests__/core/sanitize.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { sanitizeIdentifier, sanitizeToolName } from "@execbox/core"; +import { sanitizeIdentifier } from "../../src/identifier"; +import { sanitizeToolName } from "../../src/sanitize"; describe("sanitizeToolName", () => { it("replaces punctuation and spaces with underscores", () => { diff --git a/packages/core/__tests__/security/isJsonSerializable.test.ts b/packages/core/__tests__/security/isJsonSerializable.test.ts index 9ca08bf..fdf7f99 100644 --- a/packages/core/__tests__/security/isJsonSerializable.test.ts +++ b/packages/core/__tests__/security/isJsonSerializable.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isJsonSerializable } from "@execbox/core"; +import { isJsonSerializable } from "@execbox/core/runtime"; function buildDag(depth: number): Record { let current: Record = { leaf: true }; diff --git a/packages/core/etc/execbox-core.api.md b/packages/core/etc/execbox-core.api.md index 19dede1..9378c83 100644 --- a/packages/core/etc/execbox-core.api.md +++ b/packages/core/etc/execbox-core.api.md @@ -7,9 +7,6 @@ import { ZodRawShape } from 'zod'; import { ZodTypeAny } from 'zod'; -// @public -export function assertValidIdentifier(value: string, label?: string): void; - // @public export interface ExecuteError { code: ExecuteErrorCode; @@ -79,44 +76,12 @@ export interface ExecutorRuntimeOptions { timeoutMs?: number; } -// @public -export function generateTypesFromJsonSchema(providerName: string, tools: Record): string; - // @public export function isExecuteFailure(value: unknown): value is ExecuteFailure; -// @public -export function isJsonSerializable(value: unknown, active?: Set, memo?: WeakSet): boolean; - -// @public -export function isReservedWord(value: string): boolean; - -// @public -export function isValidIdentifier(value: string): boolean; - // @public export type JsonSchema = Record; -// @public -export interface ProviderManifest { - // (undocumented) - name: string; - // (undocumented) - tools: Record; - // (undocumented) - types: string; -} - -// @public -export interface ProviderToolManifest { - // (undocumented) - description?: string; - // (undocumented) - originalName: string; - // (undocumented) - safeName: string; -} - // @public export interface ResolvedToolDescriptor { description?: string; @@ -139,34 +104,6 @@ export interface ResolvedToolProvider { // @public export function resolveProvider(provider: ToolProvider): ResolvedToolProvider; -// @public -export function sanitizeIdentifier(value: string): string; - -// @public -export function sanitizeToolName(name: string): string; - -// @public -export function serializePropertyName(name: string): string; - -// @public -export interface ToolCall { - // (undocumented) - input: unknown; - // (undocumented) - providerName: string; - // (undocumented) - safeToolName: string; -} - -// @public -export type ToolCallResult = { - ok: true; - result: unknown; -} | { - error: ExecuteError; - ok: false; -}; - // @public export interface ToolDescriptor { description?: string; @@ -193,7 +130,4 @@ export interface ToolProvider { // @public export type ToolSchema = JsonSchema | ZodTypeAny | ZodRawShape; -// @public -export type TypegenToolDescriptor = Pick & Partial>; - ``` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a0b6c2d..e7e9855 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,25 +3,9 @@ * Public API for the `@execbox/core` package. */ export type { Executor, ExecutorPoolOptions } from "./executor/executor"; -export { - assertValidIdentifier, - isReservedWord, - isValidIdentifier, - sanitizeIdentifier, - serializePropertyName, -} from "./identifier"; -export { sanitizeToolName } from "./sanitize"; -export { ExecuteFailure, isExecuteFailure, isJsonSerializable } from "./errors"; +export { ExecuteFailure, isExecuteFailure } from "./errors"; export { resolveProvider } from "./provider/resolveProvider"; -export { generateTypesFromJsonSchema } from "./typegen/jsonSchema"; -export type { - ExecutionOptions, - ExecutorRuntimeOptions, - ProviderManifest, - ProviderToolManifest, - ToolCall, - ToolCallResult, -} from "./runner"; +export type { ExecutionOptions, ExecutorRuntimeOptions } from "./runner"; export type { ExecuteError, ExecuteErrorCode, @@ -33,5 +17,4 @@ export type { ToolExecutionContext, ToolProvider, ToolSchema, - TypegenToolDescriptor, } from "./types"; diff --git a/packages/quickjs/README.md b/packages/quickjs/README.md index e275b74..6862584 100644 --- a/packages/quickjs/README.md +++ b/packages/quickjs/README.md @@ -64,8 +64,6 @@ await executor.prewarm(); ## Advanced Imports -- `@execbox/quickjs/runner` exports the reusable QuickJS runner -- `@execbox/quickjs/runner/protocol-endpoint` exports the low-level QuickJS protocol loop used by worker-hosted integrations - `@execbox/quickjs/remote-endpoint` adapts the QuickJS protocol loop to `@execbox/remote` runner ports ## Operational Notes diff --git a/packages/quickjs/etc/execbox-quickjs-runner-protocol-endpoint.api.md b/packages/quickjs/etc/execbox-quickjs-runner-protocol-endpoint.api.md deleted file mode 100644 index f24124c..0000000 --- a/packages/quickjs/etc/execbox-quickjs-runner-protocol-endpoint.api.md +++ /dev/null @@ -1,21 +0,0 @@ -## API Report File for "@execbox/quickjs" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { DispatcherMessage } from '@execbox/core/protocol'; -import { RunnerMessage } from '@execbox/core/protocol'; - -// @public -export function attachQuickJsProtocolEndpoint(port: QuickJsProtocolPort): () => void; - -// @public -export interface QuickJsProtocolPort { - // (undocumented) - onMessage(handler: (message: DispatcherMessage) => void): void | (() => void); - // (undocumented) - send(message: RunnerMessage): void; -} - -``` diff --git a/packages/quickjs/etc/execbox-quickjs-runner.api.md b/packages/quickjs/etc/execbox-quickjs-runner.api.md deleted file mode 100644 index a36cd38..0000000 --- a/packages/quickjs/etc/execbox-quickjs-runner.api.md +++ /dev/null @@ -1,67 +0,0 @@ -## API Report File for "@execbox/quickjs" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { ExecuteResult } from '@execbox/core'; -import { ExecutorPoolOptions } from '@execbox/core'; -import { ExecutorRuntimeOptions } from '@execbox/core'; -import { ProviderManifest } from '@execbox/core'; -import { QuickJSWASMModule } from 'quickjs-emscripten'; -import { ToolCall } from '@execbox/core'; -import { ToolCallResult } from '@execbox/core'; - -// @public -export type QuickJsExecutorHost = "inline" | "worker"; - -// @public -export type QuickJsExecutorOptions = QuickJsInlineExecutorOptions | QuickJsWorkerExecutorOptions; - -// @public -export type QuickJsHostedMode = "pooled" | "ephemeral"; - -// @public -export interface QuickJsInlineExecutorOptions extends ExecutorRuntimeOptions { - host?: "inline"; - loadModule?: () => Promise | unknown; -} - -// @public -export type QuickJsSessionOptions = ExecutorRuntimeOptions & Pick & { - module?: QuickJSWASMModule; -}; - -// @public -export interface QuickJsSessionRequest { - abortController?: AbortController; - code: string; - onStarted?: () => void; - onToolCall: (call: ToolCall) => Promise | ToolCallResult; - providers: ProviderManifest[]; - signal?: AbortSignal; -} - -// @public -export type QuickJsSessionToolCall = ToolCall; - -// @public -export interface QuickJsWorkerExecutorOptions extends ExecutorRuntimeOptions { - cancelGraceMs?: number; - host: "worker"; - mode?: QuickJsHostedMode; - pool?: ExecutorPoolOptions; - workerResourceLimits?: WorkerResourceLimits; -} - -// @public -export function runQuickJsSession(request: QuickJsSessionRequest, options?: QuickJsSessionOptions): Promise; - -// @public -export interface WorkerResourceLimits { - maxOldGenerationSizeMb?: number; - maxYoungGenerationSizeMb?: number; - stackSizeMb?: number; -} - -``` diff --git a/packages/quickjs/package.json b/packages/quickjs/package.json index ff12413..98289a7 100644 --- a/packages/quickjs/package.json +++ b/packages/quickjs/package.json @@ -20,24 +20,6 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./runner": { - "source": "./src/runner/index.ts", - "types": { - "import": "./dist/runner/index.d.ts", - "require": "./dist/runner/index.d.cts" - }, - "import": "./dist/runner/index.js", - "require": "./dist/runner/index.cjs" - }, - "./runner/protocol-endpoint": { - "source": "./src/runner/protocolEndpoint.ts", - "types": { - "import": "./dist/runner/protocolEndpoint.d.ts", - "require": "./dist/runner/protocolEndpoint.d.cts" - }, - "import": "./dist/runner/protocolEndpoint.js", - "require": "./dist/runner/protocolEndpoint.cjs" - }, "./remote-endpoint": { "source": "./src/remoteEndpoint.ts", "types": { diff --git a/packages/quickjs/src/runner/guestEnvironment.ts b/packages/quickjs/src/runner/guestEnvironment.ts new file mode 100644 index 0000000..045b283 --- /dev/null +++ b/packages/quickjs/src/runner/guestEnvironment.ts @@ -0,0 +1,214 @@ +import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; + +import { + ExecuteFailure, + formatConsoleLine, + getExecutionTimeoutMessage, + isExecuteFailure, + normalizeThrownMessage, +} from "@execbox/core/runtime"; +import type { ProviderManifest, ToolCallResult } from "@execbox/core/runtime"; + +import { + createGuestErrorHandle, + fromGuestHandle, + toGuestHandle, +} from "../quickjsBridge.ts"; +import type { QuickJsSessionRequest } from "./index.ts"; + +/** + * Installs the bounded console capture surface into a QuickJS context. + */ +export function injectConsole(context: QuickJSContext, logs: string[]): void { + const consoleHandle = context.newObject(); + + try { + for (const methodName of ["log", "info", "warn", "error"]) { + const methodHandle = context.newFunction(methodName, (...args) => { + logs.push(formatConsoleLine(args.map((arg) => context.dump(arg)))); + return context.undefined; + }); + + context.setProp(consoleHandle, methodName, methodHandle); + methodHandle.dispose(); + } + + context.setProp(context.global, "console", consoleHandle); + } finally { + consoleHandle.dispose(); + } +} + +/** + * Installs provider namespaces as guest-visible async tool proxies. + */ +export function injectProviders( + context: QuickJSContext, + providers: ProviderManifest[], + signal: AbortSignal, + trustedHostErrorKey: string, + onToolCall: QuickJsSessionRequest["onToolCall"], +): void { + for (const provider of providers) { + const providerHandle = context.newObject(); + + try { + for (const [safeToolName] of Object.entries(provider.tools)) { + const toolHandle = createToolHandle( + context, + provider.name, + safeToolName, + signal, + trustedHostErrorKey, + onToolCall, + ); + context.setProp(providerHandle, safeToolName, toolHandle); + toolHandle.dispose(); + } + + context.setProp(context.global, provider.name, providerHandle); + } finally { + providerHandle.dispose(); + } + } +} + +function createToolHandle( + context: QuickJSContext, + providerName: string, + safeToolName: string, + signal: AbortSignal, + trustedHostErrorKey: string, + onToolCall: QuickJsSessionRequest["onToolCall"], +): QuickJSHandle { + return context.newFunction(safeToolName, (...args) => { + const deferred = context.newPromise(); + const disposeDeferred = () => { + if (context.alive && deferred.alive) { + deferred.dispose(); + } + }; + let input: unknown; + + try { + input = + args[0] === undefined ? undefined : fromGuestHandle(context, args[0]); + } catch (error) { + const executeError = isExecuteFailure(error) + ? error + : new ExecuteFailure( + "serialization_error", + "Guest code passed a non-serializable tool input", + ); + const errorHandle = createGuestErrorHandle( + context, + executeError.code, + executeError.message, + trustedHostErrorKey, + ); + + try { + deferred.reject(errorHandle); + return deferred.handle; + } finally { + errorHandle.dispose(); + queueMicrotask(disposeDeferred); + } + } + const onAbort = () => { + signal.removeEventListener("abort", onAbort); + if (!context.alive || !deferred.alive) { + disposeDeferred(); + return; + } + + const errorHandle = createGuestErrorHandle( + context, + "timeout", + getExecutionTimeoutMessage(), + trustedHostErrorKey, + ); + + try { + deferred.reject(errorHandle); + } finally { + errorHandle.dispose(); + disposeDeferred(); + } + }; + + signal.addEventListener("abort", onAbort, { once: true }); + + let responsePromise: Promise; + + try { + if (signal.aborted) { + throw new ExecuteFailure("timeout", getExecutionTimeoutMessage()); + } + + responsePromise = Promise.resolve( + onToolCall({ + input, + providerName, + safeToolName, + }), + ); + } catch (error) { + responsePromise = Promise.reject(error); + } + + void responsePromise + .then((response) => { + signal.removeEventListener("abort", onAbort); + if (!context.alive || !deferred.alive) { + disposeDeferred(); + return; + } + + let resultHandle: QuickJSHandle | undefined; + + try { + if (!response.ok) { + const errorHandle = createGuestErrorHandle( + context, + response.error.code, + response.error.message, + trustedHostErrorKey, + ); + deferred.reject(errorHandle); + errorHandle.dispose(); + return; + } + + resultHandle = toGuestHandle(context, response.result); + deferred.resolve(resultHandle); + } finally { + resultHandle?.dispose(); + disposeDeferred(); + } + }) + .catch((error) => { + signal.removeEventListener("abort", onAbort); + if (!context.alive || !deferred.alive) { + disposeDeferred(); + return; + } + + const errorHandle = createGuestErrorHandle( + context, + isExecuteFailure(error) ? error.code : "internal_error", + normalizeThrownMessage(error), + trustedHostErrorKey, + ); + + try { + deferred.reject(errorHandle); + } finally { + errorHandle.dispose(); + disposeDeferred(); + } + }); + + return deferred.handle; + }); +} diff --git a/packages/quickjs/src/runner/index.ts b/packages/quickjs/src/runner/index.ts index 5a14ca2..07e0fac 100644 --- a/packages/quickjs/src/runner/index.ts +++ b/packages/quickjs/src/runner/index.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * Public API for the `@execbox/quickjs/runner` entrypoint. + * Internal reusable QuickJS session runner. */ import { randomUUID } from "node:crypto"; @@ -10,38 +10,29 @@ import { memoizePromiseFactory, newQuickJSWASMModule, shouldInterruptAfterDeadline, - type QuickJSContext, - type QuickJSHandle, - type QuickJSRuntime, type QuickJSWASMModule, } from "quickjs-emscripten"; import { - ExecuteFailure, - formatConsoleLine, - getExecutionTimeoutMessage, - isExecuteFailure, - isKnownExecuteErrorCode, normalizeCode, - normalizeThrownMessage, resolveExecutorRuntimeOptions, truncateLogs, } from "@execbox/core/runtime"; +import type { ExecuteResult, ExecutorRuntimeOptions } from "@execbox/core"; import type { - ExecuteError, - ExecuteResult, - ExecutorRuntimeOptions, ProviderManifest, ToolCall, ToolCallResult, -} from "@execbox/core"; +} from "@execbox/core/runtime"; -import { - createGuestErrorHandle, - fromGuestHandle, - toGuestHandle, -} from "../quickjsBridge.ts"; +import { fromGuestHandle } from "../quickjsBridge.ts"; import type { QuickJsInlineExecutorOptions } from "../types.ts"; +import { injectConsole, injectProviders } from "./guestEnvironment.ts"; +import { + errorFromGuestHandle, + toExecuteError, + waitForPromiseSettlement, +} from "./sessionErrors.ts"; export type { QuickJsExecutorHost, @@ -93,316 +84,6 @@ export type QuickJsSessionOptions = ExecutorRuntimeOptions & module?: QuickJSWASMModule; }; -/** - * Converts unexpected executor failures into stable public result errors. - */ -function toExecuteError(error: unknown, deadline: number): ExecuteError { - if (isExecuteFailure(error)) { - return { - code: error.code, - message: error.message, - }; - } - - const message = normalizeThrownMessage(error); - - if (Date.now() > deadline || message.includes("interrupted")) { - return { - code: "timeout", - message: getExecutionTimeoutMessage(), - }; - } - - if (message.toLowerCase().includes("out of memory")) { - return { - code: "memory_limit", - message, - }; - } - - return { - code: "runtime_error", - message, - }; -} - -function errorFromGuestHandle( - context: QuickJSContext, - handle: QuickJSHandle, - trustedHostErrorKey: string, -): ExecuteError { - const codeHandle = context.getProp(handle, "code"); - const messageHandle = context.getProp(handle, "message"); - const trustedMarkerHandle = context.getProp(handle, trustedHostErrorKey); - - try { - const code = - context.typeof(codeHandle) === "string" - ? context.getString(codeHandle) - : undefined; - const trustedHostError = context.typeof(trustedMarkerHandle) === "boolean"; - const message = - context.typeof(messageHandle) === "string" - ? context.getString(messageHandle) - : normalizeThrownMessage(context.dump(handle)); - - if (trustedHostError && isKnownExecuteErrorCode(code)) { - return { - code, - message, - }; - } - - return { - code: "runtime_error", - message, - }; - } finally { - codeHandle.dispose(); - messageHandle.dispose(); - trustedMarkerHandle.dispose(); - } -} - -async function waitForPromiseSettlement( - runtime: QuickJSRuntime, - promise: Promise, - deadline: number, - trustedHostErrorKey: string, -): Promise { - let settled = false; - let rejection: unknown; - - promise.then( - () => { - settled = true; - }, - (error) => { - settled = true; - rejection = error; - }, - ); - - while (!settled) { - if (Date.now() > deadline) { - throw new ExecuteFailure("timeout", getExecutionTimeoutMessage()); - } - - const pendingJobsResult = runtime.executePendingJobs(-1); - if (isFail(pendingJobsResult)) { - const pendingError = pendingJobsResult.error; - - try { - const executeError = errorFromGuestHandle( - pendingError.context, - pendingError, - trustedHostErrorKey, - ); - throw new ExecuteFailure(executeError.code, executeError.message); - } finally { - pendingError.dispose(); - } - } - - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - if (rejection !== undefined) { - throw rejection; - } -} - -function injectConsole(context: QuickJSContext, logs: string[]): void { - const consoleHandle = context.newObject(); - - try { - for (const methodName of ["log", "info", "warn", "error"]) { - const methodHandle = context.newFunction(methodName, (...args) => { - logs.push(formatConsoleLine(args.map((arg) => context.dump(arg)))); - return context.undefined; - }); - - context.setProp(consoleHandle, methodName, methodHandle); - methodHandle.dispose(); - } - - context.setProp(context.global, "console", consoleHandle); - } finally { - consoleHandle.dispose(); - } -} - -function injectProviders( - context: QuickJSContext, - providers: ProviderManifest[], - signal: AbortSignal, - trustedHostErrorKey: string, - onToolCall: QuickJsSessionRequest["onToolCall"], -): void { - for (const provider of providers) { - const providerHandle = context.newObject(); - - try { - for (const [safeToolName] of Object.entries(provider.tools)) { - const toolHandle = createToolHandle( - context, - provider.name, - safeToolName, - signal, - trustedHostErrorKey, - onToolCall, - ); - context.setProp(providerHandle, safeToolName, toolHandle); - toolHandle.dispose(); - } - - context.setProp(context.global, provider.name, providerHandle); - } finally { - providerHandle.dispose(); - } - } -} - -function createToolHandle( - context: QuickJSContext, - providerName: string, - safeToolName: string, - signal: AbortSignal, - trustedHostErrorKey: string, - onToolCall: QuickJsSessionRequest["onToolCall"], -): QuickJSHandle { - return context.newFunction(safeToolName, (...args) => { - const deferred = context.newPromise(); - const disposeDeferred = () => { - if (context.alive && deferred.alive) { - deferred.dispose(); - } - }; - let input: unknown; - - try { - input = - args[0] === undefined ? undefined : fromGuestHandle(context, args[0]); - } catch (error) { - const executeError = isExecuteFailure(error) - ? error - : new ExecuteFailure( - "serialization_error", - "Guest code passed a non-serializable tool input", - ); - const errorHandle = createGuestErrorHandle( - context, - executeError.code, - executeError.message, - trustedHostErrorKey, - ); - - try { - deferred.reject(errorHandle); - return deferred.handle; - } finally { - errorHandle.dispose(); - queueMicrotask(disposeDeferred); - } - } - const onAbort = () => { - signal.removeEventListener("abort", onAbort); - if (!context.alive || !deferred.alive) { - disposeDeferred(); - return; - } - - const errorHandle = createGuestErrorHandle( - context, - "timeout", - getExecutionTimeoutMessage(), - trustedHostErrorKey, - ); - - try { - deferred.reject(errorHandle); - } finally { - errorHandle.dispose(); - disposeDeferred(); - } - }; - - signal.addEventListener("abort", onAbort, { once: true }); - - let responsePromise: Promise; - - try { - if (signal.aborted) { - throw new ExecuteFailure("timeout", getExecutionTimeoutMessage()); - } - - responsePromise = Promise.resolve( - onToolCall({ - input, - providerName, - safeToolName, - }), - ); - } catch (error) { - responsePromise = Promise.reject(error); - } - - void responsePromise - .then((response) => { - signal.removeEventListener("abort", onAbort); - if (!context.alive || !deferred.alive) { - disposeDeferred(); - return; - } - - let resultHandle: QuickJSHandle | undefined; - - try { - if (!response.ok) { - const errorHandle = createGuestErrorHandle( - context, - response.error.code, - response.error.message, - trustedHostErrorKey, - ); - deferred.reject(errorHandle); - errorHandle.dispose(); - return; - } - - resultHandle = toGuestHandle(context, response.result); - deferred.resolve(resultHandle); - } finally { - resultHandle?.dispose(); - disposeDeferred(); - } - }) - .catch((error) => { - signal.removeEventListener("abort", onAbort); - if (!context.alive || !deferred.alive) { - disposeDeferred(); - return; - } - - const errorHandle = createGuestErrorHandle( - context, - isExecuteFailure(error) ? error.code : "internal_error", - normalizeThrownMessage(error), - trustedHostErrorKey, - ); - - try { - deferred.reject(errorHandle); - } finally { - errorHandle.dispose(); - disposeDeferred(); - } - }); - - return deferred.handle; - }); -} - /** * Runs one QuickJS-backed execution session using a transport-neutral tool callback. */ diff --git a/packages/quickjs/src/runner/protocolEndpoint.ts b/packages/quickjs/src/runner/protocolEndpoint.ts index fd9bbba..d7fcb96 100644 --- a/packages/quickjs/src/runner/protocolEndpoint.ts +++ b/packages/quickjs/src/runner/protocolEndpoint.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * Public API for the `@execbox/quickjs/runner/protocol-endpoint` entrypoint. + * Internal QuickJS protocol endpoint used by hosted and remote adapters. */ import { randomUUID } from "node:crypto"; @@ -9,8 +9,8 @@ import type { DispatcherMessage, ExecuteMessage, RunnerMessage, + ToolCallResult, } from "@execbox/core/protocol"; -import type { ToolCallResult } from "@execbox/core"; import { runQuickJsSession } from "./index.ts"; diff --git a/packages/quickjs/src/runner/sessionErrors.ts b/packages/quickjs/src/runner/sessionErrors.ts new file mode 100644 index 0000000..75d8e5a --- /dev/null +++ b/packages/quickjs/src/runner/sessionErrors.ts @@ -0,0 +1,140 @@ +import { + isFail, + type QuickJSContext, + type QuickJSHandle, + type QuickJSRuntime, +} from "quickjs-emscripten"; + +import { + ExecuteFailure, + getExecutionTimeoutMessage, + isExecuteFailure, + isKnownExecuteErrorCode, + normalizeThrownMessage, +} from "@execbox/core/runtime"; +import type { ExecuteError } from "@execbox/core"; + +/** + * Converts unexpected executor failures into stable public result errors. + */ +export function toExecuteError(error: unknown, deadline: number): ExecuteError { + if (isExecuteFailure(error)) { + return { + code: error.code, + message: error.message, + }; + } + + const message = normalizeThrownMessage(error); + + if (Date.now() > deadline || message.includes("interrupted")) { + return { + code: "timeout", + message: getExecutionTimeoutMessage(), + }; + } + + if (message.toLowerCase().includes("out of memory")) { + return { + code: "memory_limit", + message, + }; + } + + return { + code: "runtime_error", + message, + }; +} + +/** + * Converts a guest-side error handle into the public execbox error shape. + */ +export function errorFromGuestHandle( + context: QuickJSContext, + handle: QuickJSHandle, + trustedHostErrorKey: string, +): ExecuteError { + const codeHandle = context.getProp(handle, "code"); + const messageHandle = context.getProp(handle, "message"); + const trustedMarkerHandle = context.getProp(handle, trustedHostErrorKey); + + try { + const code = + context.typeof(codeHandle) === "string" + ? context.getString(codeHandle) + : undefined; + const trustedHostError = context.typeof(trustedMarkerHandle) === "boolean"; + const message = + context.typeof(messageHandle) === "string" + ? context.getString(messageHandle) + : normalizeThrownMessage(context.dump(handle)); + + if (trustedHostError && isKnownExecuteErrorCode(code)) { + return { + code, + message, + }; + } + + return { + code: "runtime_error", + message, + }; + } finally { + codeHandle.dispose(); + messageHandle.dispose(); + trustedMarkerHandle.dispose(); + } +} + +/** + * Advances QuickJS pending jobs until the resolved promise settles or times out. + */ +export async function waitForPromiseSettlement( + runtime: QuickJSRuntime, + promise: Promise, + deadline: number, + trustedHostErrorKey: string, +): Promise { + let settled = false; + let rejection: unknown; + + promise.then( + () => { + settled = true; + }, + (error) => { + settled = true; + rejection = error; + }, + ); + + while (!settled) { + if (Date.now() > deadline) { + throw new ExecuteFailure("timeout", getExecutionTimeoutMessage()); + } + + const pendingJobsResult = runtime.executePendingJobs(-1); + if (isFail(pendingJobsResult)) { + const pendingError = pendingJobsResult.error; + + try { + const executeError = errorFromGuestHandle( + pendingError.context, + pendingError, + trustedHostErrorKey, + ); + throw new ExecuteFailure(executeError.code, executeError.message); + } finally { + pendingError.dispose(); + } + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + if (rejection !== undefined) { + throw rejection; + } +} diff --git a/packages/quickjs/tsdown.config.ts b/packages/quickjs/tsdown.config.ts index 7dc6c46..4f67d0d 100644 --- a/packages/quickjs/tsdown.config.ts +++ b/packages/quickjs/tsdown.config.ts @@ -1,11 +1,5 @@ import { definePackageBuildConfig } from "../../scripts/tsdown-config.ts"; export default definePackageBuildConfig({ - entry: [ - "src/index.ts", - "src/remoteEndpoint.ts", - "src/runner/index.ts", - "src/runner/protocolEndpoint.ts", - "src/workerEntry.ts", - ], + entry: ["src/index.ts", "src/remoteEndpoint.ts", "src/workerEntry.ts"], }); diff --git a/packages/remote/etc/execbox-remote.api.md b/packages/remote/etc/execbox-remote.api.md index c15fbeb..d30cba1 100644 --- a/packages/remote/etc/execbox-remote.api.md +++ b/packages/remote/etc/execbox-remote.api.md @@ -10,7 +10,6 @@ import { Executor } from '@execbox/core'; import { ExecutorRuntimeOptions } from '@execbox/core'; import { HostTransport } from '@execbox/core/protocol'; import { ResolvedToolProvider } from '@execbox/core'; -import { TransportCloseReason } from '@execbox/core/protocol'; // @public export class RemoteExecutor implements Executor { @@ -24,14 +23,6 @@ export interface RemoteExecutorOptions extends ExecutorRuntimeOptions { connectTransport: RemoteTransportFactory; } -// @public -export interface RemoteRunnerPort { - onClose?(handler: (reason?: TransportCloseReason) => void): void | (() => void); - onError?(handler: (error: Error) => void): void | (() => void); - onMessage(handler: (message: unknown) => void): void | (() => void); - send(message: unknown): void | Promise; -} - // @public export type RemoteTransportFactory = () => HostTransport | Promise; diff --git a/packages/remote/src/index.ts b/packages/remote/src/index.ts index 3f4d703..5388120 100644 --- a/packages/remote/src/index.ts +++ b/packages/remote/src/index.ts @@ -3,8 +3,4 @@ * Public API for the `@execbox/remote` package. */ export { RemoteExecutor } from "./remoteExecutor"; -export type { - RemoteExecutorOptions, - RemoteRunnerPort, - RemoteTransportFactory, -} from "./types"; +export type { RemoteExecutorOptions, RemoteTransportFactory } from "./types"; diff --git a/packages/remote/src/types.ts b/packages/remote/src/types.ts index 5f0915b..15c1ba0 100644 --- a/packages/remote/src/types.ts +++ b/packages/remote/src/types.ts @@ -1,8 +1,5 @@ import type { ExecutorRuntimeOptions } from "@execbox/core"; -import type { - HostTransport, - TransportCloseReason, -} from "@execbox/core/protocol"; +import type { HostTransport } from "@execbox/core/protocol"; /** * Factory that creates a fresh transport connection for one remote execution. @@ -11,25 +8,6 @@ export type RemoteTransportFactory = () => | HostTransport | Promise; -/** - * Minimal runner-side port for transport-backed QuickJS execution. - */ -export interface RemoteRunnerPort { - /** Registers a close callback for transport shutdown notifications. */ - onClose?( - handler: (reason?: TransportCloseReason) => void, - ): void | (() => void); - - /** Registers an error callback for transport-level failures. */ - onError?(handler: (error: Error) => void): void | (() => void); - - /** Registers a handler for inbound runner messages. */ - onMessage(handler: (message: unknown) => void): void | (() => void); - - /** Sends a transport message to the attached host session. */ - send(message: unknown): void | Promise; -} - /** * Options for constructing a {@link RemoteExecutor}. */ diff --git a/packages/remote/test-support/createLoopbackTransport.ts b/packages/remote/test-support/createLoopbackTransport.ts index c70ba24..9e0e4cb 100644 --- a/packages/remote/test-support/createLoopbackTransport.ts +++ b/packages/remote/test-support/createLoopbackTransport.ts @@ -1,4 +1,4 @@ -import { attachQuickJsProtocolEndpoint } from "@execbox/quickjs/runner/protocol-endpoint"; +import { attachQuickJsRemoteEndpoint } from "@execbox/quickjs/remote-endpoint"; import type { DispatcherMessage, HostTransport, @@ -25,15 +25,16 @@ export function createLoopbackTransport(): HostTransport { } }; - attachQuickJsProtocolEndpoint({ + attachQuickJsRemoteEndpoint({ onMessage(handler) { - runnerHandlers.add(handler); - return () => runnerHandlers.delete(handler); + const runnerHandler = handler as RunnerMessageHandler; + runnerHandlers.add(runnerHandler); + return () => runnerHandlers.delete(runnerHandler); }, send(message) { queueMicrotask(() => { for (const handler of messageHandlers) { - handler(message); + handler(message as RunnerMessage); } }); }, diff --git a/scripts/test-dist-smoke.ts b/scripts/test-dist-smoke.ts index 0d3a523..d2c165f 100644 --- a/scripts/test-dist-smoke.ts +++ b/scripts/test-dist-smoke.ts @@ -31,10 +31,16 @@ const quickjsRemoteEndpoint = await import( ); assert.equal(typeof core.resolveProvider, "function"); +assert.equal(core.assertValidIdentifier, undefined); assert.equal(core.createToolCallDispatcher, undefined); assert.equal(typeof coreMcp.createMcpToolProvider, "function"); assert.equal(typeof coreMcp.openMcpToolProvider, "function"); assert.equal(typeof coreMcp.codeMcpServer, "function"); +assert.equal(core.generateTypesFromJsonSchema, undefined); +assert.equal(core.isJsonSerializable, undefined); +assert.equal(core.sanitizeIdentifier, undefined); +assert.equal(core.sanitizeToolName, undefined); +assert.equal(core.serializePropertyName, undefined); assert.equal(typeof coreProtocol.runHostTransportSession, "function"); assert.equal(typeof coreProtocol.createResourcePool, "function"); assert.equal(typeof coreProtocol.getNodeTransportExecArgv, "function"); diff --git a/scripts/workspace-entrypoints.ts b/scripts/workspace-entrypoints.ts index 75aa5ac..9926874 100644 --- a/scripts/workspace-entrypoints.ts +++ b/scripts/workspace-entrypoints.ts @@ -63,22 +63,6 @@ export const workspaceEntrypoints = [ packageName: "@execbox/quickjs", sourcePath: "packages/quickjs/src/remoteEndpoint.ts", }, - { - apiReportFileName: "execbox-quickjs-runner.api.md", - declarationPath: "dist/runner/index.d.ts", - exportPath: "./runner", - packageDir: "packages/quickjs", - packageName: "@execbox/quickjs", - sourcePath: "packages/quickjs/src/runner/index.ts", - }, - { - apiReportFileName: "execbox-quickjs-runner-protocol-endpoint.api.md", - declarationPath: "dist/runner/protocolEndpoint.d.ts", - exportPath: "./runner/protocol-endpoint", - packageDir: "packages/quickjs", - packageName: "@execbox/quickjs", - sourcePath: "packages/quickjs/src/runner/protocolEndpoint.ts", - }, { apiReportFileName: "execbox-remote.api.md", declarationPath: "dist/index.d.ts", diff --git a/tsconfig.json b/tsconfig.json index 468111f..dcebd1a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,10 +11,6 @@ "@execbox/quickjs/remote-endpoint": [ "./packages/quickjs/src/remoteEndpoint.ts" ], - "@execbox/quickjs/runner": ["./packages/quickjs/src/runner/index.ts"], - "@execbox/quickjs/runner/protocol-endpoint": [ - "./packages/quickjs/src/runner/protocolEndpoint.ts" - ], "@execbox/remote": ["./packages/remote/src/index.ts"] }, "types": ["node", "vitest/globals"]