UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

192 lines (190 loc) • 8.93 kB
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