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
14 changes: 13 additions & 1 deletion packages/core/sdk/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@

import { describe, expect, it } from "@effect/vitest";
import { Schema } from "effect";
import { HttpClientRequest } from "effect/unstable/http";
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";

import { createPluginAtomClient } from "./client";
import { applyPluginAtomClientRequestTransform, createPluginAtomClient } from "./client";

const FooGroup = HttpApiGroup.make("foo").add(
HttpApiEndpoint.get("ping", "/ping", { success: Schema.String }),
Expand Down Expand Up @@ -65,4 +66,15 @@ describe("createPluginAtomClient", () => {
expect(ping).toBeTruthy();
expect(set).toBeTruthy();
});

it("can apply dynamic server connection URL and auth to plugin requests", () => {
const request = HttpClientRequest.get("/graphql/sources");
const transformed = applyPluginAtomClientRequestTransform(request, {
baseUrl: () => "https://executor.example/api",
authorizationHeader: () => "Bearer key_123",
});

expect(transformed.url).toBe("https://executor.example/api/graphql/sources");
expect(transformed.headers.authorization).toBe("Bearer key_123");
});
});
54 changes: 46 additions & 8 deletions packages/core/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,32 @@ export interface CreatePluginAtomClientOptions {
* when forwarding to the Effect handler) — same convention as the
* core `ExecutorApiClient`. */
readonly baseUrl?: string | (() => string);
/** Optional dynamic Authorization header for hosts whose active
* Executor Server Connection requires Basic or Bearer auth. */
readonly authorizationHeader?: string | null | (() => string | null);
}

export interface PluginAtomClientRequestTransformOptions {
readonly baseUrl?: () => string;
readonly authorizationHeader?: string | null | (() => string | null);
}

/** @internal */
export const applyPluginAtomClientRequestTransform = (
request: HttpClientRequest.HttpClientRequest,
options: PluginAtomClientRequestTransformOptions,
): HttpClientRequest.HttpClientRequest => {
let next = options.baseUrl ? HttpClientRequest.prependUrl(request, options.baseUrl()) : request;
const authorization =
typeof options.authorizationHeader === "function"
? options.authorizationHeader()
: options.authorizationHeader;
if (authorization) {
next = HttpClientRequest.setHeader(next, "authorization", authorization);
}
return next;
};

/**
* Build a typed reactive client for a plugin's HttpApiGroup.
*
Expand All @@ -211,19 +235,33 @@ export const createPluginAtomClient = <
group: G,
options: CreatePluginAtomClientOptions = {},
) => {
const { baseUrl = "/api" } = options;
const { baseUrl = "/api", authorizationHeader } = options;
const pluginId = group.identifier;
const bundle = HttpApi.make(`plugin-${pluginId}`).add(group);
const getBaseUrl = typeof baseUrl === "function" ? baseUrl : null;
const staticBaseUrl = typeof baseUrl === "function" ? undefined : baseUrl;
const getAuthorizationHeader =
typeof authorizationHeader === "function" ? authorizationHeader : null;
const hasAuthorization = authorizationHeader !== undefined && authorizationHeader !== null;
const transformClient =
getBaseUrl || hasAuthorization
? HttpClient.mapRequest((request) =>
applyPluginAtomClientRequestTransform(request, {
...(getBaseUrl ? { baseUrl: getBaseUrl } : {}),
...(getAuthorizationHeader
? { authorizationHeader: getAuthorizationHeader }
: authorizationHeader !== undefined
? { authorizationHeader }
: {}),
}),
)
: undefined;

return AtomHttpApi.Service<`Plugin_${G["identifier"]}Client`>()(`Plugin_${pluginId}Client`, {
api: bundle,
httpClient: FetchHttpClient.layer,
...(typeof baseUrl === "function"
? {
transformClient: HttpClient.mapRequest((request) =>
HttpClientRequest.prependUrl(request, baseUrl()),
),
}
: { baseUrl }),
...(staticBaseUrl !== undefined ? { baseUrl: staticBaseUrl } : {}),
...(transformClient ? { transformClient } : {}),
});
};

Expand Down
14 changes: 14 additions & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,20 @@ export {
type HostedHttpClientOptions,
} from "./hosted-http-client";

export {
DEFAULT_EXECUTOR_SERVER_ORIGIN,
DEFAULT_EXECUTOR_SERVER_USERNAME,
apiBaseUrlForServerOrigin,
getExecutorServerAuthorizationHeader,
normalizeExecutorServerConnection,
normalizeExecutorServerOrigin,
originFromApiBaseUrl,
type ExecutorServerAuth,
type ExecutorServerConnection,
type ExecutorServerConnectionInput,
type ExecutorServerConnectionKind,
} from "./server-connection";

export {
OAuthDiscoveryError,
OAuthAuthorizationServerMetadataSchema,
Expand Down
92 changes: 92 additions & 0 deletions packages/core/sdk/src/server-connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "@effect/vitest";

import {
apiBaseUrlForServerOrigin,
getExecutorServerAuthorizationHeader,
normalizeExecutorServerConnection,
normalizeExecutorServerOrigin,
originFromApiBaseUrl,
parseExecutorLocalServerManifest,
serializeExecutorLocalServerManifest,
} from "./server-connection";

describe("Executor server connection", () => {
it("normalizes server origins and API base URLs", () => {
expect(normalizeExecutorServerOrigin("localhost:4788/")).toBe("http://localhost:4788");
expect(normalizeExecutorServerOrigin("http://localhost:4788/api")).toBe(
"http://localhost:4788",
);
expect(apiBaseUrlForServerOrigin("http://localhost:4788")).toBe("http://localhost:4788/api");
expect(originFromApiBaseUrl("http://localhost:4788/api")).toBe("http://localhost:4788");
});

it("builds a stable connection from an explicit server origin", () => {
const connection = normalizeExecutorServerConnection({
origin: "https://executor.example",
displayName: "Remote Executor",
});

expect(connection).toMatchObject({
kind: "http",
key: "http:https://executor.example",
origin: "https://executor.example",
apiBaseUrl: "https://executor.example/api",
displayName: "Remote Executor",
});
});

it("builds authorization headers from server auth", () => {
expect(
getExecutorServerAuthorizationHeader(
normalizeExecutorServerConnection({
origin: "http://127.0.0.1:4789",
auth: {
kind: "basic",
username: "executor",
password: "secret",
},
}),
),
).toBe("Basic ZXhlY3V0b3I6c2VjcmV0");

expect(
getExecutorServerAuthorizationHeader(
normalizeExecutorServerConnection({
origin: "https://executor.example",
auth: {
kind: "bearer",
token: "remote-token",
},
}),
),
).toBe("Bearer remote-token");
});

it("round-trips local server owner manifests", () => {
const manifest = {
version: 1 as const,
kind: "desktop-sidecar" as const,
pid: 1234,
startedAt: "2026-05-28T00:00:00.000Z",
dataDir: "/Users/rhys/.executor",
scopeDir: "/Users/rhys/.executor",
connection: normalizeExecutorServerConnection({
kind: "desktop-sidecar",
key: "desktop-sidecar",
origin: "http://127.0.0.1:4789",
auth: { kind: "basic", username: "executor", password: "secret" },
}),
owner: {
client: "desktop" as const,
version: "1.2.3",
executablePath: "/Applications/Executor.app/Contents/MacOS/Executor",
},
};

expect(
parseExecutorLocalServerManifest(serializeExecutorLocalServerManifest(manifest)),
).toEqual(manifest);
expect(parseExecutorLocalServerManifest("{")).toBeNull();
expect(parseExecutorLocalServerManifest(JSON.stringify({ ...manifest, pid: -1 }))).toBeNull();
});
});
Loading
Loading