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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ No architecture to learn — just five words, because all the heavy lifting happ
| Feed actions to an LLM / build dynamic forms | `catalog` | Runtime JSON Schema (2020-12) for any action or provider — `catalog.action` / `catalog.actions` / `catalog.providers`. |
| Discover what's connected | `apps.list` | Read-only list of the connections you've already linked. |
| Let *your* users connect *their* accounts | `ProjectConnector` | A separate project-scoped client to connect accounts on behalf of your end-users and run actions for them. See [Connect accounts for your users](#connect-accounts-for-your-users). |
| Run the open-source server yourself | `OpenConnector` | Both call paths (`execute` and `open.<service>.<action>`) + `catalog` / `apps` / `health` against your self-hosted runtime. See [Self-hosted runtime](#self-hosted-runtime). |

Provider and action coverage comes from the gateway, not this package. Discover it at runtime with `oomol.catalog.providers()`, and see [`@oomol-lab/connector-types`](https://github.com/oomol-lab/connector-types) for the providers with precise compile-time types.

Expand Down Expand Up @@ -217,6 +218,35 @@ await user.execute("gmail.search_threads", { query: "from:ceo" });

Full runnable lifecycle — [`examples/project.ts`](./examples/project.ts).

## Self-hosted runtime

Running the open-source Connector server yourself (localhost, Docker, your own infra)? **`OpenConnector`** is the personal client for it — both call paths you know (everything except `proxy` and `using()`), pointed at your own server:

```ts
import { OpenConnector } from "@oomol-lab/connector";

const open = new OpenConnector(); // defaults to http://localhost:3000; a fresh instance needs no auth

await open.execute("hackernews.get_top_stories", {}); // path 1 — dynamic string
await open.gmail.search_threads({ query: "from:boss" }); // path 2 — namespace sugar, same registry types
await open.catalog.search("send email", { limit: 5 }); // runtime extras: search, services, health
await open.apps.list();
```

Auth is a single optional **runtime token** (`oct_…`), minted in the runtime's web console:

```ts
const open = new OpenConnector({
baseUrl: "https://connect.internal.example.com", // the server ORIGIN — not a /v1 url
runtimeToken: process.env.OOMOL_CONNECT_RUNTIME_TOKEN, // omit while the instance has no tokens
});
```

> [!NOTE]
> Connections, credentials, and OAuth setup are managed in the runtime's **web console** — that's server administration, deliberately outside this SDK. The client consumes what the console configured; connection selection has two layers (per-call `connectionName` over the client-level default — there is no `using()` scope and no `organization`). And as on the hosted client, a service id that collides with a member name (`execute` / `executeRaw` / `health` / `catalog` / `apps`) keeps working through `execute("<service>.<action>", …)` — only its namespace sugar is shadowed.

Full runnable tour — [`examples/open.ts`](./examples/open.ts).

## Why this SDK?

- **Zero runtime dependencies** — `sideEffects: false`, ships only `dist`. It's an in-process HTTP client, nothing more.
Expand All @@ -231,6 +261,7 @@ Full runnable lifecycle — [`examples/project.ts`](./examples/project.ts).
- **`oomol.apps.list()`** — read-only introspection of your connected apps.
- **`oomol.executeRaw(...)`** — like `execute`, but returns `{ data, executionId, actionId, message }`.
- **`ProjectConnector`** — a separate client (project API key) to build a SaaS platform: `connect.oauth` / `connect.apiKey` / `connect.customCredential`, `waitForConnection`, `execute` / `executeRaw` on a user's behalf, and `forUser` to scope to one user. See [Connect accounts for your users](#connect-accounts-for-your-users).
- **`OpenConnector`** — the personal client for the open-source self-hosted runtime: both call paths (`execute` and `open.<service>.<action>`), `catalog` / `apps` (+ `health`, `catalog.search` / `.services`, `apps.listByService` / `.authenticated`), authenticated by an optional runtime token. See [Self-hosted runtime](#self-hosted-runtime).

See [`examples/`](./examples) for runnable, type-checked usage of every method.

Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ OOMOL_API_KEY=api_... bun run examples/basic.ts
| [`catalog.ts`](./catalog.ts) | `catalog.providers` (incl. `{ service, q }` filter), `catalog.actions`, `catalog.action` (JSON Schema) |
| [`apps.ts`](./apps.ts) | `apps.list` (read-only); reading `id` / `service` / `status` / `connectionName` |
| [`project.ts`](./project.ts) | `ProjectConnector` (separate client, project API key): `connect.{oauth,apiKey,customCredential}`, `waitForConnection`, `execute`, `forUser` — connect accounts for your end-users and act on their behalf |
| [`open.ts`](./open.ts) | `OpenConnector` (separate client, open-source self-hosted runtime): `execute` + `open.<service>.<action>` namespace sugar, `catalog` (incl. `search` / `services`), `apps`, `health` — the personal surface against your own server |
| [`proxy.ts`](./proxy.ts) | `proxy` passthrough — typed GET/POST, `endpoint` / `query` / `headers` / `body` |
| [`scoping-and-options.ts`](./scoping-and-options.ts) | `new Connector({...})`, `using()`, per-call options, `AbortSignal`, timeout/retries, custom `fetch` |
| [`error-handling.ts`](./error-handling.ts) | `ConnectorError` fields, `err.code` discrimination, `isRetryable`, client codes |
Expand Down
62 changes: 62 additions & 0 deletions examples/open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* OpenConnector — the personal client for the open-source, self-hosted Connector runtime.
*
* It mirrors the core `Connector` surface (execute + `open.<service>.<action>` namespace sugar,
* catalog / apps / health) against the server YOU run. Connections and credentials are managed in
* the runtime's web console — the SDK only consumes them. Auth is a single optional runtime token
* (`oct_…`, minted in that console); a fresh instance answers without one.
*
* # start the runtime first, then:
* OOMOL_CONNECT_URL=http://localhost:3000 bun run examples/open.ts
*
* For precise per-action input/output types + JSDoc on BOTH call paths, install
* `@oomol-lab/connector-types` and add one side-effect import per provider (e.g.
* `import "@oomol-lab/connector-types/gmail";`). Without it, every action stays loosely typed.
*/
import { ConnectorError, OpenConnector } from "@oomol-lab/connector";

// Every field is optional: a fresh runtime needs no auth at all.
const open = new OpenConnector({
baseUrl: process.env.OOMOL_CONNECT_URL ?? "http://localhost:3000", // the server ORIGIN, not a /v1 url
runtimeToken: process.env.OOMOL_CONNECT_RUNTIME_TOKEN, // oct_... from the runtime's web console
});

async function main() {
// --- Probe the runtime -------------------------------------------------------------------------
const health = await open.health();
console.log("runtime:", health.runtime);

// --- Browse the catalog ------------------------------------------------------------------------
const services = await open.catalog.services();
console.log("services with actions:", services.length);
const hits = await open.catalog.search("top stories", { limit: 3 });
console.log("search:", hits.map((hit) => hit.id));
const action = await open.catalog.action("hackernews.get_top_stories");
console.log("input schema keys:", Object.keys(action.inputSchema));

// --- Execute actions ---------------------------------------------------------------------------
// No-auth providers (like hackernews) work with zero setup; others use the connections you made
// in the runtime's web console — select one by name with `connectionName` when you have several.
const stories = await open.execute("hackernews.get_top_stories", {}); // path 1 — dynamic string
console.log("output:", stories);
const user = await open.github.get_current_user({}, { connectionName: "work" }); // path 2 — namespace sugar
console.log("user:", user);
const raw = await open.executeRaw("github.get_current_user", {}, { connectionName: "work" });
console.log("executionId:", raw.executionId);

// --- Inspect what's connected ------------------------------------------------------------------
const apps = await open.apps.list();
console.log("connected apps:", apps.map((app) => `${app.service}:${app.connectionName}`));
const authed = await open.apps.authenticated(["github", "notion"]);
console.log("with real credentials:", authed);
}

main().catch((err) => {
if (err instanceof ConnectorError) {
// Same typed error model as the hosted clients — e.g. "unauthorized" (runtime token required),
// "connection_not_found", or "invalid_input" for an unknown action.
console.error(`[${err.code}] ${err.message}`);
} else {
throw err;
}
});
14 changes: 13 additions & 1 deletion fixtures/consumer/no-b/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// State 1 — B NOT installed (ActionRegistry empty). Everything must be loose-callable.
import { expectType, expectError, expectAssignable } from "tsd";
import { Connector } from "@oomol-lab/connector";
import { Connector, OpenConnector } from "@oomol-lab/connector";

const oomol = new Connector({ apiKey: "k" });
const open = new OpenConnector();

// Dynamic execute compiles for ANY actionId, output is the loose Record.
expectType<Promise<Record<string, any>>>(oomol.execute("anything.x", { a: 1 }));
Expand All @@ -22,3 +23,14 @@ expectError(oomol.execute(123, {}));

// using() returns a Connector.
expectType<Connector>(oomol.using({ connectionName: "work" }));

// OpenConnector mirrors both paths with the SAME loose registry seam.
expectType<Promise<Record<string, any>>>(open.execute("anything.x", { a: 1 }));
expectType<Promise<Record<string, any>>>(open.foo.bar({}));
expectAssignable<Promise<Record<string, any>>>(open.anything.whatever());
expectError(open.foo.bar(123)); // input is Record, not any
expectError(open.execute("anything.x", 123)); // execute input is Record, not any
expectError(open.execute(123, {})); // actionId must be a string
// Its namespace options are the open-runtime ones — no `organization` (single-user server).
open.foo.bar({}, { connectionName: "work" });
expectError(open.foo.bar({}, { organization: "acme" }));
13 changes: 12 additions & 1 deletion fixtures/consumer/partial-b/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// State 3 — partial registration (only gmail). Unregistered services stay loose,
// while the registered service stays precise — both in the SAME program.
import { expectType, expectError } from "tsd";
import { Connector, ProjectConnector } from "@oomol-lab/connector";
import { Connector, OpenConnector, ProjectConnector } from "@oomol-lab/connector";
import "./augment";

const oomol = new Connector({ apiKey: "k" });
const project = new ProjectConnector({ apiKey: "oo_proj_k" });
const open = new OpenConnector();

type SearchOut = { threads: Array<{ threadId: string; snippet: string }> };

Expand Down Expand Up @@ -44,3 +45,13 @@ expectError(project.catalog);
expectError(project.apps);
expectError(project.using);
expectError(project.gmail); // closed ProjectApi has no service namespaces

// OpenConnector namespaces stay in lockstep: registered precise, unregistered loose — same program.
expectType<Promise<SearchOut>>(open.gmail.search_threads({ query: "x" }));
expectError(open.gmail.search_threads({})); // still errors: missing required `query`
expectType<Promise<Record<string, any>>>(open.gmail.brand_new_action({ anything: 1 }));
expectType<Promise<Record<string, any>>>(open.notion.create_page({ title: "x" }));
// BOTH loose fallbacks of the augmented branch carry the open-runtime options — a regression to
// the default CallOptions would let the hosted-only `organization` slip through unnoticed.
expectError(open.gmail.brand_new_action({ anything: 1 }, { organization: "acme" }));
expectError(open.notion.create_page({ title: "x" }, { organization: "acme" }));
20 changes: 19 additions & 1 deletion fixtures/consumer/with-b/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// State 2 — B installed AND the action under test is registered (see ./augment.ts).
// The registered action must be precise on both paths; wrong input must error.
import { expectType, expectError } from "tsd";
import { Connector, ProjectConnector } from "@oomol-lab/connector";
import { Connector, OpenConnector, ProjectConnector } from "@oomol-lab/connector";
import "./augment";

const oomol = new Connector({ apiKey: "k" });
const project = new ProjectConnector({ apiKey: "oo_proj_k" });
const open = new OpenConnector();

type SearchOut = { threads: Array<{ threadId: string; snippet: string }> };

Expand Down Expand Up @@ -47,3 +48,20 @@ expectError(project.catalog);
expectError(project.apps);
expectError(project.using);
expectError(project.gmail); // closed ProjectApi has no service namespaces

// OpenConnector shares the registry seam — registered actions precise on BOTH paths.
expectType<Promise<SearchOut>>(open.execute("gmail.search_threads", { query: "x" }));
expectType<Promise<SearchOut>>(open.gmail.search_threads({ query: "x" }));
expectError(open.gmail.search_threads({})); // missing required `query`
expectError(open.gmail.search_threads({ query: 123 })); // wrong type
// The execute path is its own declaration — guard it as strictly as the hosted one.
expectError(open.execute("gmail.search_threads", {})); // missing required
expectError(open.execute("gmail.search_threads", { query: 123 })); // wrong type
expectError(open.execute("gmail.search_threads", { query: "x" }, { organization: "acme" }));
// Namespace options are the open-runtime ones: connectionName ok, `organization` rejected.
open.gmail.search_threads({ query: "x" }, { connectionName: "work" });
expectError(open.gmail.search_threads({ query: "x" }, { organization: "acme" }));
// The open client has no hosted-only METHODS: a non-reserved name like `proxy` or `using`
// resolves to a service NAMESPACE (an object), so calling it as a function must error.
expectError(open.proxy("github", { endpoint: "/user", method: "GET" }));
expectError(open.using({ connectionName: "work" }));
6 changes: 5 additions & 1 deletion src/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ export interface ConnectorMethods {
readonly apps: AppsApi;
}

/** The public Connector type: methods + (precise/loose) service namespaces. */
/**
* The public Connector type: methods + (precise/loose) service namespaces. A service id colliding
* with a reserved member (`execute` / `executeRaw` / `using` / `proxy` / `catalog` / `apps`)
* loses only its path-2 sugar — call it via `execute("<service>.<action>", …)`.
*/
export type Connector = ConnectorMethods & ServiceNamespaces;

interface ResolvedConfig {
Expand Down
15 changes: 14 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,25 @@ export type ConnectorErrorCode =
| "subscription_exists"
| "subscription_cleaning_up"
| "subscription_needs_recreate"
// Self-hosted runtime codes (the open-source backend an `OpenConnector` talks to):
| "unauthorized"
| "not_found"
| "internal_error"
| "invalid_json"
| "invalid_connection_name"
| "unknown_service"
| "connection_not_found"
| "action_not_allowed"
| "executor_unavailable"
| "authorization_failed"
| "oauth_token_expired"
| "oauth_refresh_unavailable"
// Client-only extension codes (NOT in the backend enum; do not overlap with it).
// Held by the `| (string & {})` open union.
| "client_invalid_request" // local precheck failure (e.g. missing apiKey, illegal header) — not sent
| "client_timeout" // request exceeded the client-side `timeoutMs`
| "client_network_error" // transport-level failure (DNS/connection/fetch threw)
| "client_wait_timeout" // `ProjectConnector.waitForConnection` exceeded its overall `maxWaitMs` (NOT a per-request timeout)
| "client_wait_timeout" // a `waitForConnection` exceeded its overall `maxWaitMs` (NOT a per-request timeout)
// Forward-compat for new backend codes:
| (string & {});

Expand Down
36 changes: 35 additions & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,35 @@ function toConnectorError(
});
}

/** Recognize a gateway envelope by its boolean `success` discriminator. */
function isEnvelope(parsed: unknown): parsed is Envelope {
return (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed) &&
typeof (parsed as { success?: unknown }).success === "boolean"
);
}

/**
* Extract the self-hosted runtime's non-envelope error body — `{ error: { code, message } }` —
* if present. Its middleware answers in this shape on every route (e.g. a 401), including the
* `/v1` envelope surface, so the transport must understand it wherever it appears.
*/
function runtimeErrorBody(parsed: unknown): { code: string; message: string } | undefined {
if (typeof parsed !== "object" || parsed === null) return undefined;
const error = (parsed as { error?: unknown }).error;
if (typeof error !== "object" || error === null) return undefined;
const { code, message } = error as { code?: unknown; message?: unknown };
return typeof code === "string" && typeof message === "string" ? { code, message } : undefined;
}

async function parseBody(res: Response): Promise<Envelope | undefined> {
const text = await res.text();
if (!text) return undefined;
let parsed: unknown;
try {
return JSON.parse(text) as Envelope;
parsed = JSON.parse(text);
} catch {
// Non-JSON body. A 2xx payload is still a success — surface the raw text as `data`
// (e.g. a plain-text "OK", or a 200 page injected by an intermediary). A non-2xx non-JSON
Expand All @@ -167,6 +191,16 @@ async function parseBody(res: Response): Promise<Envelope | undefined> {
? { success: true, data: text }
: { success: false, message: text, data: null };
}
if (isEnvelope(parsed)) return parsed;
// Non-envelope 2xx JSON (an intermediary, or a backend answering plainly): the body IS the data.
if (res.ok) return { success: true, data: parsed };
// The self-hosted runtime's middleware failure shape — map it onto the envelope's error fields.
const error = runtimeErrorBody(parsed);
if (error) return { success: false, message: error.message, errorCode: error.code, data: null };
// Unrecognized non-ok JSON (an intermediary's error page): keep any top-level `message` for the
// error text and carry the whole body as `data` for debugging.
const message = (parsed as { message?: unknown } | null)?.message;
return { success: false, message: typeof message === "string" ? message : undefined, data: parsed };
}

/**
Expand Down
19 changes: 19 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,22 @@ export type {
CustomCredentialConnectInput,
WaitForConnectionOptions,
} from "./project";

// OpenConnector — the personal client for the open-source, self-hosted runtime (the server YOU
// run). Mirrors the core `Connector` action surface: execute, catalog, apps — nothing else; the
// runtime's management API belongs to its web console, not this SDK.
export { OpenConnector } from "./open";
export type {
OpenConnectorApi,
OpenConnectorConfig,
OpenCallOptions,
OpenExecuteOptions,
OpenCatalogApi,
OpenAppsApi,
OpenHealth,
OpenActionMetadata,
OpenActionFollowUp,
OpenActionAsyncLifecycle,
OpenActionSearchResult,
OpenSearchQuery,
} from "./open";
Loading