@sourceregistry/node-ovsdb
Version:
TypeScript OVSDB client for Node.js
743 lines (742 loc) • 23 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.OVSDBClient = exports.OvsdbTransaction = exports.OvsdbTransactionError = exports.OvsdbProtocolError = exports.OvsdbRpcError = void 0;
exports.resolveConnectionOptions = resolveConnectionOptions;
const node_fs_1 = require("node:fs");
const node_net_1 = require("node:net");
const node_events_1 = require("node:events");
const node_tls_1 = require("node:tls");
/**
* JSON-RPC transport error returned by the OVSDB server.
*/
class OvsdbRpcError extends Error {
/**
* The error payload returned by the server.
*/
response;
/**
* Creates a new RPC error wrapper.
*/
constructor(response) {
super(response.details ? `${response.error}: ${response.details}` : response.error);
this.name = "OvsdbRpcError";
this.response = response;
}
}
exports.OvsdbRpcError = OvsdbRpcError;
/**
* Raised when a message does not match the expected JSON-RPC envelope.
*/
class OvsdbProtocolError extends Error {
/**
* The raw payload that failed validation.
*/
payload;
/**
* Creates a new protocol error wrapper.
*/
constructor(message, payload) {
super(message);
this.name = "OvsdbProtocolError";
this.payload = payload;
}
}
exports.OvsdbProtocolError = OvsdbProtocolError;
/**
* Raised when an OVSDB transaction response contains an operation-level error.
*/
class OvsdbTransactionError extends Error {
/**
* Zero-based index of the failed operation in the submitted transaction.
*/
operationIndex;
/**
* Operation that produced the error.
*/
operation;
/**
* OVSDB error payload returned for the failed operation.
*/
result;
/**
* Raw transaction results returned by the server.
*/
results;
/**
* Creates a new transaction error wrapper.
*/
constructor(options) {
super(`Transaction operation ${options.operationIndex} failed: ${options.result.error}`);
this.name = "OvsdbTransactionError";
this.operationIndex = options.operationIndex;
this.operation = options.operation;
this.result = options.result;
this.results = options.results;
}
}
exports.OvsdbTransactionError = OvsdbTransactionError;
/**
* Stages OVSDB operations before sending them as a single `transact` request.
*/
class OvsdbTransaction {
stagedOperations = [];
/**
* Returns the currently staged operations.
*/
get operations() {
return this.stagedOperations;
}
/**
* Adds an operation to the transaction.
*/
add(operation) {
this.stagedOperations.push(operation);
return operation;
}
/**
* Stages an insert operation.
*/
insert(operation) {
return this.add(operation);
}
/**
* Stages a select operation.
*/
select(operation) {
return this.add(operation);
}
/**
* Stages an update operation.
*/
update(operation) {
return this.add(operation);
}
/**
* Stages a mutate operation.
*/
mutate(operation) {
return this.add(operation);
}
/**
* Stages a delete operation.
*/
delete(operation) {
return this.add(operation);
}
/**
* Stages a wait operation.
*/
wait(operation) {
return this.add(operation);
}
/**
* Stages a comment operation.
*/
comment(comment) {
return this.add({
op: "comment",
comment
});
}
/**
* Stages an assert operation.
*/
assert(lock) {
return this.add({
op: "assert",
lock
});
}
/**
* Stages a commit operation.
*/
commit(durable = false) {
return this.add({
op: "commit",
durable
});
}
/**
* Stages an abort operation.
*/
abort() {
return this.add({
op: "abort"
});
}
}
exports.OvsdbTransaction = OvsdbTransaction;
/**
* A low-level, event-driven OVSDB client for Unix sockets, TCP, or TLS.
*
* The client exposes the RFC 7047 primitives together with common
* Open vSwitch protocol extensions while keeping the API small and predictable.
*/
class OVSDBClient extends node_events_1.EventEmitter {
timeout;
connectionOptions;
connectionFactory;
socket = null;
requestId = 1;
receiveBuffer = "";
pendingRequests = new Map();
connected = false;
closeEmitted = false;
/**
* Creates a new OVSDB client instance.
*/
constructor(options = {}) {
super();
this.timeout = options.timeout ?? 5000;
this.connectionOptions = resolveConnectionOptions(options);
this.connectionFactory = options.connectionFactory;
}
/**
* Returns `true` when the underlying socket is currently connected.
*/
get isConnected() {
return this.connected;
}
/**
* Opens the transport connection.
*
* @returns The connected client instance for chaining.
*/
async connect() {
if (this.connected) {
return this;
}
if (!this.connectionFactory &&
this.connectionOptions.transport === "unix" &&
!(0, node_fs_1.existsSync)(this.connectionOptions.socketPath)) {
throw new Error(`OVSDB socket not found: ${this.connectionOptions.socketPath}`);
}
const socket = this.connectionFactory
? this.connectionFactory(this.connectionOptions)
: createTransport(this.connectionOptions);
this.attachSocket(socket);
const connectEvent = this.connectionOptions.transport === "tls" ? "secureConnect" : "connect";
await new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
cleanup();
this.disposeTransport(new Error(`Connection timeout after ${this.timeout}ms`));
reject(new Error(`Connection timeout after ${this.timeout}ms`));
}, this.timeout);
const onConnect = () => {
cleanup();
this.connected = true;
this.closeEmitted = false;
this.emit("connect");
resolve();
};
const onError = (error) => {
cleanup();
reject(error);
};
const cleanup = () => {
clearTimeout(timeoutId);
socket.off(connectEvent, onConnect);
socket.off("error", onError);
};
socket.once(connectEvent, onConnect);
socket.once("error", onError);
});
return this;
}
/**
* Sends a raw JSON-RPC request and resolves with its `result` payload.
*
* @param method RPC method name.
* @param params RPC parameters.
*/
async request(method, params = []) {
this.assertConnected();
const id = this.requestId++;
const payload = {
method,
params,
id
};
return await new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request timeout for method: ${method}`));
}, this.timeout);
this.pendingRequests.set(id, {
resolve: (value) => resolve(value),
reject,
timeoutId
});
this.writeMessage(payload).catch((error) => {
clearTimeout(timeoutId);
this.pendingRequests.delete(id);
reject(error);
});
});
}
/**
* Sends a JSON-RPC notification without waiting for a response.
*
* @param method RPC method name.
* @param params RPC parameters.
*/
async notify(method, params = []) {
this.assertConnected();
await this.writeMessage({ method, params });
}
/**
* Returns the database names exposed by the connected OVSDB server.
*/
async listDbs() {
return await this.request("list_dbs", []);
}
/**
* Returns the schema definition for a database.
*
* @param dbName Database name.
*/
async getSchema(dbName = "Open_vSwitch") {
return await this.request("get_schema", [dbName]);
}
/**
* Executes a single transaction.
*
* The response preserves operation ordering, so tuple inputs infer tuple outputs.
*
* @param dbName Database name.
* @param operations Transaction operations.
*/
async transact(dbName, operations) {
return await this.request("transact", [dbName, ...operations]);
}
/**
* Stages a transaction in a callback and submits it only if the callback
* completes successfully.
*
* This helper is convenient when you want a scoped, imperative API while
* still sending exactly one OVSDB `transact` request.
*
* @param dbName Database name.
* @param callback Callback that stages operations on the transaction object.
* @param options Auto-commit behavior for the staged transaction.
*/
async transaction(dbName, callback, options = {}) {
const transaction = new OvsdbTransaction();
const value = await callback(transaction);
const operations = [...transaction.operations];
const autoCommit = options.autoCommit ?? true;
const hasFinalizer = operations.some((operation) => operation.op === "commit" || operation.op === "abort");
if (autoCommit && !hasFinalizer) {
operations.push({
op: "commit",
durable: options.durable ?? false
});
}
if (operations.length === 0) {
return {
value,
operations,
results: []
};
}
const results = await this.request("transact", [dbName, ...operations]);
for (const [index, result] of results.entries()) {
if (isOvsdbError(result)) {
throw new OvsdbTransactionError({
operationIndex: index,
operation: operations[index],
result,
results
});
}
}
return {
value,
operations,
results: results
};
}
/**
* Cancels a previously issued request by id.
*
* @param requestId JSON-RPC request id to cancel.
*/
async cancel(requestId) {
return await this.request("cancel", [requestId]);
}
/**
* Starts a standard RFC 7047 monitor.
*
* @param dbName Database name.
* @param monitorId Application-defined monitor id.
* @param monitorRequests Per-table monitor definitions.
*/
async monitor(dbName, monitorId, monitorRequests) {
return await this.request("monitor", [
dbName,
monitorId,
monitorRequests
]);
}
/**
* Starts an Open vSwitch conditional monitor.
*
* @param dbName Database name.
* @param monitorId Application-defined monitor id.
* @param monitorRequests Per-table conditional monitor definitions.
*/
async monitorCond(dbName, monitorId, monitorRequests) {
return await this.request("monitor_cond", [
dbName,
monitorId,
monitorRequests
]);
}
/**
* Starts an Open vSwitch conditional monitor from a known transaction id.
*
* @param dbName Database name.
* @param monitorId Application-defined monitor id.
* @param monitorRequests Per-table conditional monitor definitions.
* @param lastTransactionId Last seen transaction id, or `null` for a fresh snapshot.
*/
async monitorCondSince(dbName, monitorId, monitorRequests, lastTransactionId = null) {
return await this.request("monitor_cond_since", [
dbName,
monitorId,
monitorRequests,
lastTransactionId
]);
}
/**
* Cancels a monitor by its monitor id.
*
* @param monitorId Monitor id used when the monitor was created.
*/
async monitorCancel(monitorId) {
return await this.request("monitor_cancel", [monitorId]);
}
/**
* Acquires a named database lock.
*
* @param lockId Lock identifier.
*/
async lock(lockId) {
return await this.request("lock", [lockId]);
}
/**
* Forces ownership of a named database lock.
*
* @param lockId Lock identifier.
*/
async steal(lockId) {
return await this.request("steal", [lockId]);
}
/**
* Releases a previously acquired named database lock.
*
* @param lockId Lock identifier.
*/
async unlock(lockId) {
return await this.request("unlock", [lockId]);
}
/**
* Sends an echo request to validate transport liveness.
*
* @param payload Values to be echoed back by the server.
*/
async echo(...payload) {
return await this.request("echo", payload);
}
/**
* Enables or disables Open vSwitch database change awareness.
*
* @param enabled Whether the server should report change awareness metadata.
*/
async setDbChangeAware(enabled = true) {
return await this.request("set_db_change_aware", [enabled]);
}
/**
* Closes the connection and rejects all pending requests.
*/
async close() {
this.disposeTransport();
}
/**
* Implements `AsyncDisposable`.
*/
async [Symbol.asyncDispose]() {
await this.close();
}
attachSocket(socket) {
this.socket = socket;
this.receiveBuffer = "";
socket.on("data", this.handleData);
socket.on("error", this.handleSocketError);
socket.on("close", this.handleSocketClose);
}
handleData = (chunk) => {
this.receiveBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
let frame = extractJsonFrame(this.receiveBuffer);
while (frame) {
this.receiveBuffer = frame.rest;
this.parseFrame(frame.frame);
frame = extractJsonFrame(this.receiveBuffer);
}
const trimmed = this.receiveBuffer.trimStart();
if (trimmed && trimmed[0] !== "{" && trimmed[0] !== "[") {
this.emitProtocolError("Received non-JSON data on the transport", this.receiveBuffer);
this.receiveBuffer = "";
}
};
handleSocketError = (error) => {
this.emit("transportError", error);
this.disposeTransport(error);
};
handleSocketClose = () => {
this.disposeTransport();
};
parseFrame(frame) {
try {
const payload = JSON.parse(frame);
this.handleMessage(payload);
}
catch (error) {
const protocolError = new OvsdbProtocolError("Failed to parse JSON message", frame);
this.emit("protocolError", protocolError, frame);
if (error instanceof Error) {
void error;
}
}
}
handleMessage(payload) {
if (!payload || typeof payload !== "object") {
this.emitProtocolError("Expected a JSON object message", payload);
return;
}
if ("method" in payload && typeof payload.method === "string") {
const message = payload;
if (message.id !== undefined && message.id !== null) {
void this.handleIncomingRequest(message.method, message.params ?? [], message.id);
return;
}
this.handleNotification(payload);
return;
}
if ("id" in payload) {
this.handleResponse(payload);
return;
}
this.emitProtocolError("Received message without method or id", payload);
}
handleResponse(response) {
const pendingRequest = this.pendingRequests.get(response.id);
if (!pendingRequest) {
this.emitProtocolError("Received response for an unknown request id", response);
return;
}
this.pendingRequests.delete(response.id);
clearTimeout(pendingRequest.timeoutId);
if (response.error) {
pendingRequest.reject(new OvsdbRpcError(response.error));
return;
}
pendingRequest.resolve(response.result);
}
async handleIncomingRequest(method, params, id) {
if (method === "echo") {
await this.writeMessage({
id,
result: params,
error: null
});
return;
}
await this.writeMessage({
id,
result: null,
error: {
error: "not supported",
details: `Unsupported server request: ${method}`
}
});
}
handleNotification(notification) {
this.emit("notification", notification);
switch (notification.method) {
case "update":
this.emit("update", notification);
break;
case "update2":
this.emit("update2", notification);
break;
case "update3":
this.emit("update3", notification);
break;
case "locked":
this.emit("locked", notification);
break;
case "stolen":
this.emit("stolen", notification);
break;
default:
this.emitProtocolError("Received an unknown notification method", notification);
break;
}
}
emitProtocolError(message, payload) {
const error = new OvsdbProtocolError(message, payload);
this.emit("protocolError", error, payload);
}
async writeMessage(payload) {
this.assertConnected();
await new Promise((resolve, reject) => {
this.socket?.write(`${JSON.stringify(payload)}\n`, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
assertConnected() {
if (!this.connected || !this.socket) {
throw new Error("Not connected to OVSDB");
}
}
disposeTransport(reason) {
const socket = this.socket;
this.socket = null;
this.connected = false;
this.receiveBuffer = "";
if (socket) {
socket.off("data", this.handleData);
socket.off("error", this.handleSocketError);
socket.off("close", this.handleSocketClose);
if (!socket.destroyed) {
socket.destroy();
}
}
const closeError = reason ?? new Error("Connection closed");
for (const pendingRequest of this.pendingRequests.values()) {
clearTimeout(pendingRequest.timeoutId);
pendingRequest.reject(closeError);
}
this.pendingRequests.clear();
if (!this.closeEmitted) {
this.closeEmitted = true;
this.emit("close");
}
}
}
exports.OVSDBClient = OVSDBClient;
__exportStar(require("./types"), exports);
/**
* Resolves the user-supplied transport options into an explicit connection mode.
*/
function resolveConnectionOptions(options = {}) {
if (options.host) {
const port = options.port ?? 6640;
if (options.tls) {
return {
transport: "tls",
host: options.host,
port,
tlsOptions: {
host: options.host,
port,
...options.tlsOptions
}
};
}
return {
transport: "tcp",
host: options.host,
port
};
}
return {
transport: "unix",
socketPath: options.socketPath ?? "/var/run/openvswitch/db.sock"
};
}
function createTransport(options) {
switch (options.transport) {
case "unix":
return (0, node_net_1.createConnection)(options.socketPath);
case "tcp":
return (0, node_net_1.createConnection)(options.port, options.host);
case "tls":
return (0, node_tls_1.connect)(options.tlsOptions);
}
}
function isOvsdbError(value) {
return typeof value === "object" && value !== null && "error" in value && typeof value.error === "string";
}
function extractJsonFrame(buffer) {
let startIndex = 0;
while (startIndex < buffer.length && /\s/u.test(buffer[startIndex])) {
startIndex += 1;
}
if (startIndex >= buffer.length) {
return null;
}
const opening = buffer[startIndex];
const closing = opening === "{" ? "}" : opening === "[" ? "]" : null;
if (!closing) {
return null;
}
let depth = 0;
let inString = false;
let escaping = false;
for (let index = startIndex; index < buffer.length; index += 1) {
const char = buffer[index];
if (inString) {
if (escaping) {
escaping = false;
continue;
}
if (char === "\\") {
escaping = true;
continue;
}
if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === "{" || char === "[") {
depth += 1;
continue;
}
if (char === "}" || char === "]") {
depth -= 1;
if (depth === 0) {
return {
frame: buffer.slice(startIndex, index + 1),
rest: buffer.slice(index + 1)
};
}
}
}
return null;
}