the-moby-effect
Version:
Moby/Docker API client built using effect-ts
250 lines (223 loc) • 10.2 kB
text/typescript
import type * as Scope from "effect/Scope";
import type * as HttpClient from "effect/unstable/http/HttpClient";
import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
import * as Effect from "effect/Effect";
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Socket from "effect/unstable/socket/Socket";
import type * as http from "node:http";
import type * as https from "node:https";
import type * as net from "node:net";
import type * as stream from "node:stream";
import type * as MobyConnection from "../../MobyConnection.js";
import type * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
import type * as ssh2 from "ssh2";
import * as internalAgnostic from "./agnostic.js";
import * as internalConnection from "./connection.js";
/**
* Helper interface to expose the underlying socket from the effect HttpClient
* response. Useful for hijacking the response stream. This is a hack, and it
* will only work when using the NodeJS http layer.
*
* @since 1.0.0
* @category NodeJS
* @internal
*/
// oxlint-disable-next-line import/namespace
export interface IExposeSocketOnEffectClientResponseHack extends HttpClientResponse.HttpClientResponse {
original?: IExposeSocketOnEffectClientResponseHack | undefined;
source?:
| {
socket?: net.Socket | undefined;
}
| undefined;
}
/** @internal */
export const makeNodeSshAgent = (
httpLazy: typeof http,
ssh2Lazy: typeof ssh2,
connectionOptions: MobyConnection.SshConnectionOptions
): http.Agent =>
new (class extends httpLazy.Agent {
// The ssh client that will be connecting to the server
private readonly sshClient: ssh2.Client;
// How to connect to the remote server and where the moby socket is located.
private readonly sshConfig: MobyConnection.SshConnectionOptions;
// Internal state to know if we are connected or not
private sshConnection: "unopened" | "connecting" | "open-failed" | "ready" = "unopened";
private openFailedError: Error | null = null;
public constructor(
ssh2ConnectConfig: MobyConnection.SshConnectionOptions,
agentOptions?: http.AgentOptions | undefined
) {
super(agentOptions);
this.sshConfig = ssh2ConnectConfig;
this.sshClient = new ssh2Lazy.Client();
}
/**
* When the agent is destroyed, we also want to close the ssh connection
* if it is still open.
*
* @since 1.0.0
* @see https://nodejs.org/api/http.html#agentdestroy
*/
public override destroy(): void {
this.sshClient.end();
this.sshClient.destroy();
super.destroy();
}
/**
* When creating a new connection, first start by trying to connect to
* the remote server over ssh. Then, if that was successful, we send a
* forwardOurStreamLocal request which is an OpenSSH extension that
* opens a connection to the unix domain socket at socketPath on the
* remote server and forwards traffic.
*
* @since 1.0.0
* @see https://nodejs.org/api/http.html#agentcreateconnectionoptions-callback
*/
public override createConnection(
_options: http.ClientRequestArgs,
callback: (error: Error | null, socket: stream.Duplex | undefined) => void
) {
const onError = (error: Error & ssh2.ClientErrorExtensions): void => {
/**
* Indicates 'client-socket' for socket-level errors and
* 'client-ssh' for SSH disconnection messages. If the error is
* at the client-ssh level, we want to close the ssh connection
* and this agent cannot be used anymore.
*/
// if (error.level === "client-ssh") this.destroy();
callback(error, undefined);
};
// No connection yet, start connecting
if (this.sshConnection === "unopened") {
this.sshConnection = "connecting";
const unableToOpen = (error: Error & ssh2.ClientErrorExtensions) => {
this.sshConnection = "open-failed";
this.openFailedError = error;
onError(error);
};
this.sshClient
.once("ready", () => {
this.sshConnection = "ready";
this.sshClient.off("error", unableToOpen);
this.createConnection(_options, callback);
})
.once("error", unableToOpen)
.connect(this.sshConfig);
}
// Already tried to connect but failed
else if (this.sshConnection === "open-failed") {
callback(this.openFailedError, undefined);
}
// Another connection attempt is already in progress, wait for it
else if (this.sshConnection === "connecting") {
this.sshClient
.once("ready", () => {
this.sshClient.off("error", onError);
this.createConnection(_options, callback);
})
.once("error", onError);
}
// We are connected to the ssh server, now forward our stream local
else if (this.sshConnection === "ready") {
this.sshClient
.openssh_forwardOutStreamLocal(
this.sshConfig.remoteSocketPath,
(error: Error | undefined, stream: ssh2.ClientChannel) => {
this.sshClient.off("error", onError);
if (error) return callback(error, void 0 as unknown as stream.Duplex);
else return callback(null, stream);
}
)
.once("error", onError);
}
// Should never happen
else {
return Function.absurd<undefined>(this.sshConnection);
}
}
})(connectionOptions);
/** @internal */
export const getNodeAgent = (
connectionOptions: MobyConnection.MobyConnectionOptions
): Effect.Effect<NodeHttpClient.HttpAgent["Service"], never, Scope.Scope> =>
Function.pipe(
Effect.all(
{
httpLazy: Effect.promise(() => import("node:http")),
httpsLazy: Effect.promise(() => import("node:https")),
},
{ concurrency: 2 }
),
Effect.flatMap(({ httpLazy, httpsLazy }) => {
const AcquireNodeHttpAgent = internalConnection.MobyConnectionOptions.$match({
http: (options) => Effect.succeed(new httpLazy.Agent({ host: options.host, port: options.port })),
socket: (options) =>
Effect.succeed(new httpLazy.Agent({ socketPath: options.socketPath } as http.AgentOptions)),
ssh: (options) =>
Effect.map(
Effect.promise(() => import("ssh2")),
(ssh2Lazy) => makeNodeSshAgent(httpLazy, ssh2Lazy, options)
),
https: (options) =>
Effect.succeed(
new httpsLazy.Agent({
ca: options.ca,
key: options.key,
cert: options.cert,
host: options.host,
port: options.port,
passphrase: options.passphrase,
})
),
});
const releaseNodeHttpAgent = (agent: http.Agent) => Effect.sync(() => agent.destroy());
const resource = Effect.acquireRelease(AcquireNodeHttpAgent(connectionOptions), releaseNodeHttpAgent);
return Effect.map(resource, (agent) => ({
http: agent as http.Agent,
https: agent as https.Agent,
}));
})
);
/** @internal */
export const getWebsocketConstructor = (
connectionOptions: MobyConnection.MobyConnectionOptions
): Layer.Layer<Socket.WebSocketConstructor, never, NodeHttpClient.HttpAgent> =>
Layer.effect(
Socket.WebSocketConstructor,
Effect.gen(function* () {
const ws = yield* Effect.promise(() => import("ws"));
const nodeHttpClientLazy = yield* Effect.promise(() => import("@effect/platform-node/NodeHttpClient"));
const agent = yield* nodeHttpClientLazy.HttpAgent;
return (url, protocols) =>
new ws.WebSocket(
`ws://0.0.0.0${internalAgnostic.makeVersionPath(connectionOptions)}${url}`,
protocols,
{ agent: agent.http }
) as unknown as globalThis.WebSocket;
})
);
/** @internal */
export const makeNodeHttpClientLayer = (
connectionOptions: MobyConnection.MobyConnectionOptions
): Layer.Layer<HttpClient.HttpClient | Socket.WebSocketConstructor, never, never> => {
const httpAgentLive = Function.pipe(
Effect.promise(() => import("@effect/platform-node/NodeHttpClient")),
Effect.map((nodeHttpClientLazy) => Layer.effect(nodeHttpClientLazy.HttpAgent, getNodeAgent(connectionOptions))),
Layer.unwrap
);
const websocketConstructorLive = getWebsocketConstructor(connectionOptions);
const nodeHttpClientLive = Function.pipe(
Effect.promise(() => import("@effect/platform-node/NodeHttpClient")),
Effect.map((nodeHttpClientLazy) => nodeHttpClientLazy.layerNodeHttpNoAgent),
Layer.unwrap
);
const agnosticHttpClientLayer = internalAgnostic.makeAgnosticHttpClientLayer(connectionOptions);
return agnosticHttpClientLayer.pipe(
Layer.merge(websocketConstructorLive),
Layer.provide(nodeHttpClientLive),
Layer.provide(httpAgentLive)
);
};