From 8fba920b80ded35cad7255b3f1ff0dc8793c98a6 Mon Sep 17 00:00:00 2001 From: Alexander Mattoni <5110855+mattoni@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:21:16 +0000 Subject: [PATCH 1/2] add new utils to lib - get environment metadata file - connect to notifications socket --- package.json | 5 +- src/index.ts | 2 + src/metadata.ts | 38 ++++ src/notifications/notification.ts | 333 ++++++++++++++++++++++++++++++ src/notifications/socket.ts | 214 +++++++++++++++++++ src/util/result.ts | 11 + vite.config.ts | 2 +- 7 files changed, 602 insertions(+), 3 deletions(-) create mode 100644 src/metadata.ts create mode 100644 src/notifications/notification.ts create mode 100644 src/notifications/socket.ts create mode 100644 src/util/result.ts diff --git a/package.json b/package.json index cf8e51c..5f17869 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,10 @@ "name": "@cycleplatform/internal-api-client", "version": "0.1.2", "description": "A Cycle API client for the internal API inside a container on Cycle.", - "main": "./dist/index.umd.cjs", + "main": "./dist/index.cjs", "module": "./dist/index.js", "type": "module", + "sideEffects": false, "exports": { ".": { "import": { @@ -13,7 +14,7 @@ }, "require": { "types": "./dist/index.d.ts", - "default": "./dist/index.umd.cjs" + "default": "./dist/index.cjs" } } }, diff --git a/src/index.ts b/src/index.ts index 9af30af..8f109af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ import createClient from "openapi-fetch"; import { Agent, fetch as undiciFetch } from "undici"; import type { components, operations, paths } from "./generated/types"; +export * from "./metadata"; +export * from "./notifications/socket"; export type { components, operations, paths }; export function getClient({ diff --git a/src/metadata.ts b/src/metadata.ts new file mode 100644 index 0000000..311dc6e --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,38 @@ +import { readFile } from "node:fs/promises"; +import type { components } from "./generated/types"; +import { asError, type Result } from "./util/result"; + +export type EnvironmentMetadata = { + id: string; + deployments: { + tags: components["schemas"]["EnvironmentDeploymentTags"]; + } | null; + private_network: components["schemas"]["PrivateNetwork"]; + services: components["schemas"]["EnvironmentServices"]; +}; + +const ENVIRONMENT_METADATA_PATH = "/var/run/cycle/metadata/environment.json"; + +/** + * Reads the environment metadata file mounted inside this instance. + * + * See: https://cycle.io/docs/platform/container-environment-variables#environment-metadata-file + */ +export async function getEnvironmentMetadata(): Promise< + Result +> { + let raw: string; + + try { + raw = await readFile(ENVIRONMENT_METADATA_PATH, "utf8"); + } catch (err) { + return { data: null, error: asError(err) }; + } + + try { + const metadata = JSON.parse(raw) as EnvironmentMetadata; + return { data: metadata, error: null }; + } catch (err) { + return { data: null, error: asError(err) }; + } +} diff --git a/src/notifications/notification.ts b/src/notifications/notification.ts new file mode 100644 index 0000000..e71fe4d --- /dev/null +++ b/src/notifications/notification.ts @@ -0,0 +1,333 @@ +export type NotificationMessage = { + topic: Topic; + object: { + id: string; + state?: string; + state_previous?: string; + error?: string; + }; + context: { + label: string | null; + hub_id: string | null; + account_id: string | null; + environments: string[] | null; + dns_zones: string[] | null; + containers: string[] | null; + clusters: string[] | null; + }; + flags?: Record; + annotations?: { + health?: "unknown" | "healthy" | "unhealthy"; + ready?: "unknown" | "ready" | "pending"; + }; +}; + +export type Topic = + // billing credits + | "billing.credit.new" + | "billing.credit.state.changed" + | "billing.credit.error" + | "billing.credit.error_reset" + + // billing discount + | "billing.discount.state.changed" + | "billing.discount.error" + | "billing.discount.error_reset" + + // billing invoice + | "billing.invoice.new" + | "billing.invoice.updated" + | "billing.invoice.state.changed" + | "billing.invoice.error" + | "billing.invoice.error_reset" + + //billing method + | "billing.method.new" + | "billing.method.state.changed" + | "billing.method.updated" + | "billing.method.error" + | "billing.method.error_reset" + + // billing order + | "billing.order.new" + | "billing.order.state.changed" + | "billing.order.error" + | "billing.order.error_reset" + | "billing.order.updated" + + // billing service + | "billing.service.state.changed" + | "billing.service.error" + | "billing.service.error_reset" + + // container + | "container.new" + | "container.updated" + | "container.reconfigured" + | "container.state.changed" + | "container.desired_state.changed" + | "container.error" + | "container.error_reset" + + // container backup + | "container.backup.new" + | "container.backup.error" + | "container.backup.state.changed" + | "container.backup.error_reset" + + // container instance + | "container.instance.state.changed" + | "container.instance.error" + | "container.instance.error_reset" + | "container.instances.reconfigured" + | "container.instance.traffic-drain.reconfigured" + | "container.instance.migration.update" + | "container.instance.health.status.changed" + | "container.instance.readiness.status.changed" + + // dns certificate + | "dns.certificate.new" + | "dns.certificate.state.changed" + | "dns.certificate.error" + | "dns.certificate.error_reset" + + // dns zone + | "dns.zone.state.changed" + | "dns.zone.error" + | "dns.zone.error_reset" + | "dns.zone.new" + | "dns.zone.verified" + | "dns.zone.reconfigured" + | "dns.zone.certificate.ready" + + // dns zone record + | "dns.zone.record.state.changed" + | "dns.zone.records.reconfigured" + + // environments + | "environment.started" + | "environment.stopped" + | "environment.new" + | "environment.updated" + | "environment.error" + | "environment.error_reset" + | "environment.state.changed" + + // environment pods + | "environment.pod.new" + | "environment.pod.updated" + | "environment.pod.error" + | "environment.pod.error_reset" + | "environment.pod.state.changed" + + // environment scoped variables + | "environment.scoped-variable.new" + | "environment.scoped-variable.updated" + | "environment.scoped-variable.state.changed" + | "environment.scoped-variable.error" + | "environment.scoped-variable.error_reset" + + // environment services + | "environment.services.reconfigured" + | "environment.services.vpn.users.updated" + | "environment.services.lb.ips.modified" + + // deployments + | "environment.deployments.reconfigured" + + // events + | "event.pushed" + + // hub + | "hub.activity.new" + | "hub.state.changed" + | "hub.error" + | "hub.error_reset" + | "hub.updated" + + // hub locations + | "hub.location.state.changed" + | "hub.location.error" + | "hub.location.error_reset" + | "hub.location.new" + | "hub.location.updated" + + // hub api keys + | "hub.api_key.new" + | "hub.api_key.updated" + | "hub.api_key.state.changed" + | "hub.api_key.error" + | "hub.api_key.error_reset" + + // hub memberships + | "hub.membership.state.changed" + | "hub.membership.error" + | "hub.membership.updated" + | "hub.membership.new" + | "hub.membership.error_reset" + + // hub roles + | "hub.role.state.changed" + | "hub.role.error" + | "hub.role.updated" + | "hub.role.new" + | "hub.role.error_reset" + + // hub integration + | "hub.integration.state.changed" + | "hub.integration.error" + | "hub.integration.updated" + | "hub.integration.new" + | "hub.integration.error_reset" + + // images + | "image.new" + | "image.state.changed" + | "image.updated" + | "image.error" + | "image.error_reset" + + // image-sources + | "image.source.state.changed" + | "image.source.error" + | "image.source.error_reset" + | "image.source.updated" + | "image.source.new" + + // infrastructure external volumes + | "infrastructure.external-volumes.new" + | "infrastructure.external-volumes.state.changed" + | "infrastructure.external-volumes.reconfigured" + | "infrastructure.external-volumes.updated" + | "infrastructure.external-volumes.error" + | "infrastructure.external-volumes.error_reset" + + // infrastructure ips assignment + | "infrastructure.ips.assignment.state.changed" + | "infrastructure.ips.assignment.error" + | "infrastructure.ips.assignment.error_reset" + + // virtual providers + | "infrastructure.virtual-providers.iso.new" + | "infrastructure.virtual-providers.iso.updated" + | "infrastructure.virtual-providers.iso.state.changed" + | "infrastructure.virtual-providers.iso.error" + | "infrastructure.virtual-providers.iso.error_reset" + + // infrastructure ips pool + | "ips_pool.new" + | "ips_pool.state.changed" + | "ips_pool.reconfigured" + | "ips_pool.error" + | "ips_pool.error_reset" + + // infrastructure cluster + | "infrastructure.cluster.state.changed" + | "infrastructure.cluster.error" + | "infrastructure.cluster.error_reset" + | "infrastructure.cluster.new" + | "infrastructure.cluster.updated" + | "infrastructure.cluster.monitoring.reconfigured" + + // infrastructure provider + | "infrastructure.provider.state.changed" + | "infrastructure.provider.error" + | "infrastructure.provider.error_reset" + | "infrastructure.provider.new" + | "infrastructure.provider.updated" + + // infrastructure.external-volumes + | "infrastructure.external-volumes.new" + | "infrastructure.external-volumes.state.changed" + | "infrastructure.external-volumes.reconfigured" + | "infrastructure.external-volumes.updated" + | "infrastructure.external-volumes.attachments.updated" + | "infrastructure.external-volumes.error" + | "infrastructure.external-volumes.error_reset" + + // infrastructure server + | "infrastructure.server.state.changed" + | "infrastructure.server.error" + | "infrastructure.server.new" + | "infrastructure.server.reconfigured" + | "infrastructure.server.restart" + | "infrastructure.server.compute.restart" + | "infrastructure.server.error_reset" + | "infrastructure.server.evacuation.changed" + | "infrastructure.server.evacuation.started" + | "infrastructure.server.evacuation.completed" + + // infrastructure autoscale + | "infrastructure.autoscale.group.state.changed" + | "infrastructure.autoscale.group.error" + | "infrastructure.autoscale.group.error_reset" + | "infrastructure.autoscale.group.new" + | "infrastructure.autoscale.group.reconfigured" + | "infrastructure.autoscale.group.updated" + + // internal //TODO check with mattoni on this + | "internal.service.compute.connected" + + // jobs + | "job.new" + | "job.state.changed" + | "job.error" + + // pipeline + | "pipeline.state.changed" + | "pipeline.error" + | "pipeline.error_reset" + | "pipeline.updated" + | "pipeline.new" + + // pipeline key + | "pipeline.key.state.changed" + | "pipeline.key.error" + | "pipeline.key.error_reset" + | "pipeline.key.updated" + | "pipeline.key.new" + + // pipeline run + | "pipeline.run.state.changed" + | "pipeline.run.error" + | "pipeline.run.error_reset" + | "pipeline.run.new" + | "pipeline.run.updated" + + // sdn + | "sdn.network.new" + | "sdn.network.error" + | "sdn.network.error_reset" + | "sdn.network.reconfigured" + | "sdn.network.state.changed" + | "sdn.network.updated" + + // stack + | "stack.state.changed" + | "stack.error" + | "stack.error_reset" + | "stack.new" + | "stack.updated" + + // stack builds + | "stack.build.new" + | "stack.build.state.changed" + | "stack.build.error" + | "stack.build.error_reset" + | "stack.build.deployed" + + // virtual machines + | "virtual-machine.new" + | "virtual-machine.updated" + | "virtual-machine.reconfigured" + | "virtual-machine.state.changed" + | "virtual-machine.error" + | "virtual-machine.error_reset" + | "virtual-machine.ips.modified" + + // virtual machine ssh keys + | "virtual-machine.ssh-key.new" + | "virtual-machine.ssh-key.updated" + | "virtual-machine.ssh-key.state.changed" + | "virtual-machine.ssh-key.error" + | "virtual-machine.ssh-key.error_reset"; diff --git a/src/notifications/socket.ts b/src/notifications/socket.ts new file mode 100644 index 0000000..9bdf68b --- /dev/null +++ b/src/notifications/socket.ts @@ -0,0 +1,214 @@ +import { EventEmitter } from "node:events"; +import { Agent, WebSocket } from "undici"; +import type { NotificationMessage } from "./notification"; + +export interface NotificationSocketOptions { + /** Absolute path to the internal API unix socket. Defaults to `/var/run/cycle/api/api.sock`. */ + socketPath?: string; + /** Auth token sent as the `x-cycle-token` header. Defaults to `process.env.CYCLE_API_TOKEN`. */ + token?: string; + /** Reconnect automatically after an unexpected close. Defaults to `true`. */ + reconnect?: boolean; + /** Base delay (ms) for exponential reconnect backoff. Defaults to `1000`. */ + reconnectBaseDelayMs?: number; + /** Max delay (ms) for reconnect backoff. Defaults to `30000`. */ + reconnectMaxDelayMs?: number; + /** Extra headers merged into the upgrade request. */ + headers?: Record; +} + +export interface NotificationSocketEventMap { + open: []; + notification: [notification: T]; + raw: [data: string]; + error: [error: Error]; + close: [code: number, reason: string]; + reconnecting: [attempt: number, delayMs: number]; +} + +export interface NotificationSocket { + on>( + event: E, + listener: (...args: NotificationSocketEventMap[E]) => void, + ): this; + once>( + event: E, + listener: (...args: NotificationSocketEventMap[E]) => void, + ): this; + off>( + event: E, + listener: (...args: NotificationSocketEventMap[E]) => void, + ): this; + emit>( + event: E, + ...args: NotificationSocketEventMap[E] + ): boolean; +} + +/** + * Connects to the internal API's one-way notification pipeline over the + * local unix socket and re-emits each message as a typed event. + * + * @example + * const sock = connectNotifications(); + * sock.on("notification", (n) => console.log(n.metadata.event, n.text)); + * sock.on("error", (err) => console.error(err)); + * // later... + * sock.close(); + */ +export class NotificationSocket extends EventEmitter { + private ws: WebSocket | null = null; + private dispatcher: Agent | null = null; + private closedByUser = false; + private reconnectAttempts = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + + private readonly socketPath: string; + private readonly path: string; + private readonly host: string; + private readonly token: string | undefined; + private readonly autoReconnect: boolean; + private readonly baseDelayMs: number; + private readonly maxDelayMs: number; + private readonly extraHeaders: Record; + + constructor(options: NotificationSocketOptions = {}) { + super(); + this.socketPath = options.socketPath ?? "/var/run/cycle/api/api.sock"; + this.path = "/v1/notifications"; + this.host = "unix"; + this.token = options.token ?? process.env.CYCLE_API_TOKEN; + this.autoReconnect = options.reconnect ?? true; + this.baseDelayMs = options.reconnectBaseDelayMs ?? 1_000; + this.maxDelayMs = options.reconnectMaxDelayMs ?? 30_000; + this.extraHeaders = options.headers ?? {}; + } + + connect(): this { + if (this.ws) { + return this; + } + + this.closedByUser = false; + + const dispatcher = new Agent({ + connect: { socketPath: this.socketPath }, + }); + this.dispatcher = dispatcher; + + const headers: Record = { ...this.extraHeaders }; + if (this.token) { + headers["x-cycle-token"] = this.token; + } + + const url = `ws://${this.host}${this.path}`; + const ws = new WebSocket(url, { dispatcher, headers }); + this.ws = ws; + + ws.addEventListener("open", () => { + this.reconnectAttempts = 0; + this.emit("open"); + }); + + ws.addEventListener("message", (ev) => { + if (typeof ev.data !== "string") { + this.emit( + "error", + new Error("received non-text notification frame"), + ); + return; + } + + const text = ev.data; + this.emit("raw", text); + + let parsed: T; + try { + parsed = JSON.parse(text) as T; + } catch (err) { + const message = + err instanceof Error ? err.message : String(err); + this.emit( + "error", + new Error(`failed to parse notification: ${message}`), + ); + return; + } + + this.emit("notification", parsed); + }); + + ws.addEventListener("error", (ev) => { + const cause = (ev as { error?: unknown }).error; + if (cause instanceof Error) { + this.emit("error", cause); + return; + } + this.emit("error", new Error("websocket error")); + }); + + ws.addEventListener("close", (ev) => { + this.cleanupSocket(); + this.emit("close", ev.code, ev.reason); + + if (this.autoReconnect && !this.closedByUser) { + this.scheduleReconnect(); + } + }); + + return this; + } + + close(): void { + this.closedByUser = true; + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.ws) { + this.ws.close(); + } + + this.cleanupSocket(); + } + + get connected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + private cleanupSocket(): void { + this.ws = null; + + if (this.dispatcher) { + this.dispatcher.destroy().catch(() => undefined); + this.dispatcher = null; + } + } + + private scheduleReconnect(): void { + const attempt = ++this.reconnectAttempts; + const delayMs = Math.min( + this.baseDelayMs * 2 ** (attempt - 1), + this.maxDelayMs, + ); + + this.emit("reconnecting", attempt, delayMs); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (!this.closedByUser) { + this.connect(); + } + }, delayMs); + + this.reconnectTimer.unref(); + } +} + +export function connectNotifications( + options?: NotificationSocketOptions, +): NotificationSocket { + return new NotificationSocket(options).connect(); +} diff --git a/src/util/result.ts b/src/util/result.ts new file mode 100644 index 0000000..7cbb0c0 --- /dev/null +++ b/src/util/result.ts @@ -0,0 +1,11 @@ +export type Result = + | { data: T; error: null } + | { data: null; error: E }; + +export function asError(err: unknown): Error { + if (err instanceof Error) { + return err; + } + + return new Error(String(err)); +} diff --git a/vite.config.ts b/vite.config.ts index 109a928..4710b71 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,8 +27,8 @@ export default defineConfig({ build: { lib: { entry: resolve(__dirname, "./src/index.ts"), - name: "CycleInternalApiClient", fileName: "index", + formats: ["es", "cjs"], }, rollupOptions: { external, From a452e9543e44e78e5330d2634d34220d8189b948 Mon Sep 17 00:00:00 2001 From: Alexander Mattoni <5110855+mattoni@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:38:44 +0000 Subject: [PATCH 2/2] add notifications and env metadata to readme --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/README.md b/README.md index 7a68388..c703657 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,79 @@ const client = getClient({ }); ``` +### Streaming Notifications + +Beyond REST requests, the internal API exposes a **notification pipeline**: a one-way streaming WebSocket over the same Unix socket that pushes real-time notifications as things happen on the hub. Each message identifies a single event: its `topic`, the `object` it concerns, and a `context` of related resource IDs. Use the REST client to fetch fuller detail when a notification warrants it. + +It authenticates with the `CYCLE_API_TOKEN` environment variable by default, just like `getClient`. + +```ts +import { NotificationSocket } from "@cycleplatform/internal-api-client"; + +const socket = new NotificationSocket(); + +socket.on("open", () => console.log("OPEN")); +socket.on("notification", (n) => console.log("NOTIFICATION:", n)); +socket.on("error", (err) => console.error("ERROR:", err.message)); +socket.on("close", (code, reason) => console.log("CLOSE:", code, reason)); + +socket.connect(); +``` + +Attach your listeners _before_ calling `connect()`. `NotificationSocket` is a Node `EventEmitter`, and an emitted `error` with no listener attached will throw. + +A received notification looks like this: + +```json +{ + "topic": "environment.deployments.reconfigured", + "object": { "id": "68518ade194b15be6bfd0a1e" }, + "context": { + "label": null, + "hub_id": "5a14ddd8b6393d0001976f44", + "account_id": null, + "environments": [ + "68518ade194b15be6bfd0a1e", + "6813cd199cb8434cb64067c9" + ], + "dns_zones": null, + "clusters": ["production"], + "containers": null, + "virtual_machines": null + } +} +``` + +Call `socket.close()` to disconnect and stop reconnecting. The `socket.connected` getter reports whether the underlying socket is currently open. + +If you just want to connect with the defaults, `connectNotifications(options)` constructs and connects in a single call, returning the socket: + +```ts +import { connectNotifications } from "@cycleplatform/internal-api-client"; + +const socket = connectNotifications(); +socket.on("notification", (n) => console.log(n)); +``` + +### Reading Environment Metadata + +Cycle mounts a metadata file inside every instance describing its environment: the environment ID, deployment tags, private network, and active services. `getEnvironmentMetadata()` reads and parses that file for you. + +It returns a `Result` (`{ data, error }`) so check `error` before using `data`: + +```ts +import { getEnvironmentMetadata } from "@cycleplatform/internal-api-client"; + +const { data, error } = await getEnvironmentMetadata(); +if (error) { + console.error("failed to read environment metadata:", error.message); +} else { + console.log(data.id, data.services); +} +``` + +See [Environment Metadata File](https://cycle.io/docs/platform/container-environment-variables#environment-metadata-file) for the full field reference. + ## Development ### Cloning submodules