everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
192 lines (190 loc) • 8.93 kB
JavaScript
import { DeployFailed, HealthTimeout, HostingAdapter, HostingOperationFailed, InstanceNotFound } from "./adapter.mjs";
import { Clock, Context, Duration, Effect, Layer } from "effect";
import * as Alchemy from "alchemy";
import { AlchemyContext, AlchemyContextLive, AuthProviders, Stack, Stage } from "alchemy";
import { AdoptPolicy } from "alchemy/AdoptPolicy";
import { ArtifactStore, createArtifactStore } from "alchemy/Artifacts";
import * as Deploy from "alchemy/Deploy";
import * as Docker from "alchemy/Docker";
import * as Interaction from "alchemy/Interaction";
import * as State from "alchemy/State";
import { PlatformServices } from "alchemy/Util/PlatformServices";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
//#region src/services/hosting/docker.ts
/**
* Docker dev-substrate `HostingAdapter` (ADR 0035).
*
* Alchemy is the control plane, embedded programmatically: each instance is
* one Alchemy stack holding a `Docker.Container` resource, evaluated
* in-process through `alchemy/Deploy`'s `deploy()` — the same plan/apply and
* state-ledger semantics as `alchemy deploy` without a CLI subprocess. The
* choice was deliberate: `Deploy.deploy` is a public programmatic API and
* alchemy's own test harness (`lib/Test/Core.js`) drives stacks exactly this
* way, so idempotency comes from Alchemy's plan diffing (an unchanged
* descriptor yields a no-op apply and never recreates the container) and the
* ledger is Alchemy's local file state store under `stateDir`.
* Stop/start/inspect go through the same provider's `Docker` service, so
* every mutation flows through the one control plane — the adapter never
* shells `docker` itself. Docker is the development/CI substrate only;
* Railway and Fly adapters implement the same seam.
*
* The host port is deliberately ephemeral (`external: 0`): Docker binds a
* free host port at container creation, the provider reports the resolved
* mapping in the resource output, and the instance URL derives from it. The
* binding is stable for the container's lifetime (stop/start preserves it);
* only a re-create — i.e. a descriptor change — rebinds it.
*/
const DEFAULT_CONTAINER_PORT = 3e3;
const DEFAULT_HEALTH_PATH = "/health";
const DEFAULT_HEALTH_TIMEOUT = "90 seconds";
const DEFAULT_HEALTH_INTERVAL = "500 millis";
const DEFAULT_STAGE = "local";
const containerName = (instance) => `bos-hosting-${instance.replaceAll(/[^a-zA-Z0-9_.-]/g, "-").toLowerCase()}`;
const stackName = (instance) => `BosHosting-${containerName(instance)}`;
const describeHostError = (error) => error instanceof Error ? error.message : String(error);
const isNotFound = (error) => error instanceof Error && "reason" in error && error.reason?._tag === "NotFound";
const alchemyContextLayer = (options) => Layer.effect(AlchemyContext, Layer.build(AlchemyContextLive).pipe(Effect.map((context) => Context.get(context, AlchemyContext)), Effect.orDie, Effect.map((context) => AlchemyContext.of({
...context,
dev: false,
adopt: options.adopt ?? false,
dotAlchemy: options.stateDir ?? context.dotAlchemy ?? ".alchemy"
}))));
const platformLayer = Layer.mergeAll(PlatformServices, FetchHttpClient.layer);
const runtimeLayer = (options) => Layer.mergeAll(alchemyContextLayer(options), Interaction.layerNonInteractive(), Layer.succeed(ArtifactStore, createArtifactStore()), Layer.succeed(AdoptPolicy, options.adopt ?? false), Layer.succeed(AuthProviders, {}), State.localState()).pipe(Layer.provideMerge(platformLayer));
const dockerServiceLayer = (options) => Docker.providers().pipe(Layer.provide(Layer.succeed(Stack, {
name: stackName("shared"),
stage: options.stage ?? DEFAULT_STAGE,
resources: {},
bindings: {},
actions: {}
})), Layer.provide(Layer.succeed(Stage, options.stage ?? DEFAULT_STAGE)));
const dockerHostingAdapterLayer = (options = {}) => Layer.effect(HostingAdapter, Effect.gen(function* () {
const http = yield* HttpClient.HttpClient;
const docker = yield* Docker.Docker;
const containerPort = options.containerPort ?? DEFAULT_CONTAINER_PORT;
const healthPath = options.healthPath ?? DEFAULT_HEALTH_PATH;
const stage = options.stage ?? DEFAULT_STAGE;
const runtime = runtimeLayer(options);
const hostPortOf = (info) => {
const hostPort = (info.NetworkSettings.Ports?.[`${containerPort}/tcp`])?.[0]?.HostPort;
return hostPort === void 0 || hostPort === "" ? void 0 : Number(hostPort);
};
const hostUrl = (hostPort) => `http://127.0.0.1:${hostPort}`;
const inspectInstance = (instance) => docker.container.inspect(containerName(instance));
const waitForHealth = Effect.fn("DockerHostingAdapter.waitForHealth")(function* (instance, url) {
const timeoutMs = Duration.toMillis(Duration.fromInputUnsafe(options.healthTimeout ?? DEFAULT_HEALTH_TIMEOUT));
const interval = Duration.fromInputUnsafe(options.healthInterval ?? DEFAULT_HEALTH_INTERVAL);
const deadline = (yield* Clock.currentTimeMillis) + timeoutMs;
let attempts = 0;
while (true) {
attempts += 1;
if (yield* http.execute(HttpClientRequest.get(`${url}${healthPath}`)).pipe(Effect.map((response) => response.status >= 200 && response.status < 300), Effect.orElseSucceed(() => false))) return url;
if ((yield* Clock.currentTimeMillis) >= deadline) return yield* new HealthTimeout({
instance,
url,
attempts
});
yield* Effect.sleep(interval);
}
});
const snapshotOf = (instance, info) => {
const running = info.State.Status === "running";
const port = running ? hostPortOf(info) : void 0;
return {
instance,
running,
url: port === void 0 ? void 0 : hostUrl(port),
image: info.Config.Image
};
};
const deployDescriptor = (descriptor) => Effect.gen(function* () {
const name = containerName(descriptor.instance);
const stack = Alchemy.Stack(stackName(descriptor.instance), {
providers: Docker.providers(),
state: State.localState()
}, Effect.gen(function* () {
return { container: yield* Docker.Container("runtime", {
image: descriptor.image,
name,
environment: { ...descriptor.identityEnv },
labels: { "bos.hosting/instance": descriptor.instance },
ports: [{
external: 0,
internal: containerPort
}],
start: true,
restart: "no"
}) };
}));
yield* Deploy.deploy({
stack,
stage,
dev: false
}).pipe(Effect.provide(runtime), Effect.mapError((error) => new DeployFailed({
instance: descriptor.instance,
reason: describeHostError(error)
})));
const info = yield* inspectInstance(descriptor.instance).pipe(Effect.mapError((error) => new DeployFailed({
instance: descriptor.instance,
reason: describeHostError(error)
})));
const port = hostPortOf(info);
if (port === void 0) return yield* new DeployFailed({
instance: descriptor.instance,
reason: `container published no host port for ${containerPort}/tcp`
});
const url = hostUrl(port);
yield* waitForHealth(descriptor.instance, url);
return {
instance: descriptor.instance,
url
};
});
const current = (instance) => inspectInstance(instance).pipe(Effect.map((info) => snapshotOf(instance, info)), Effect.catchTag("PlatformError", (error) => isNotFound(error) ? Effect.succeed({
instance,
running: false,
url: void 0,
image: void 0
}) : Effect.fail(new HostingOperationFailed({
instance,
operation: "current",
reason: describeHostError(error)
}))));
const restart = (instance) => Effect.gen(function* () {
if ((yield* inspectInstance(instance)).State.Status === "running") yield* docker.container.stop(containerName(instance));
yield* docker.container.start(containerName(instance));
const after = yield* inspectInstance(instance);
const port = hostPortOf(after);
if (port === void 0) return yield* new HostingOperationFailed({
instance,
operation: "restart",
reason: `container published no host port for ${containerPort}/tcp`
});
const url = hostUrl(port);
yield* waitForHealth(instance, url);
return {
instance,
url
};
}).pipe(Effect.catchTag("PlatformError", (error) => isNotFound(error) ? Effect.fail(new InstanceNotFound({ instance })) : Effect.fail(new HostingOperationFailed({
instance,
operation: "restart",
reason: describeHostError(error)
}))));
const stop = (instance) => docker.container.stop(containerName(instance)).pipe(Effect.asVoid, Effect.catchTag("PlatformError", (error) => isNotFound(error) ? Effect.fail(new InstanceNotFound({ instance })) : Effect.fail(new HostingOperationFailed({
instance,
operation: "stop",
reason: describeHostError(error)
}))));
return HostingAdapter.of({
deploy: deployDescriptor,
current,
restart,
stop
});
})).pipe(Layer.provide(dockerServiceLayer(options)), Layer.provide(runtimeLayer(options)));
//#endregion
export { dockerHostingAdapterLayer };
//# sourceMappingURL=docker.mjs.map