npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bestoneconsulting/sap-b1-bridge

v1.5.4

Published

SAP Business One & SQL Server bridge for secure database and ERP access behind firewalls via WebSocket agents

Readme

SAP B1 Bridge

Securely query and manage SAP Business One and SQL Server databases behind firewalls. No ports to open, no VPN required.

SAP B1 Bridge connects your cloud application to on-premise SAP Business One systems and SQL Server databases through lightweight Windows agents that communicate outbound via WebSocket. Your internal systems stay protected behind the firewall while your app gets full access to data and SAP operations.

Your App (Cloud)           Firewall           Your Network
+------------------+          ||          +-------------------+
| Express + Bridge | <========||========= | Bridge Agent      |
|  - WebSocket     |   outbound only      |  - SQL Server     |
|  - REST API      |          ||          |  - SAP B1 SL      |
|  - Programmatic  |          ||          |  - SAP B1 DI API  |
+------------------+          ||          +-------------------+

How It Works

SAP B1 Bridge uses a synchronous direct-push communication model:

  1. Agent connects outbound — The Windows agent initiates a WebSocket connection from inside your network to your cloud server. No inbound firewall ports needed.
  2. Server pushes commands directly — When you execute a query or SAP request (via the programmatic API, REST endpoint, or test console), the server sends the command directly to the connected agent over the open WebSocket.
  3. Agent executes and responds — The agent runs the SQL query or SAP operation locally and sends the result back over the same WebSocket connection.
  4. Server resolves the result — The server matches the response to the original request and returns it to your code as a resolved Promise, or broadcasts it to connected UI clients in real time.

There is no polling. The server does not queue commands for agents to fetch later. Commands are dispatched immediately to the target agent, and the programmatic API methods (executeQuery, callServiceLayer, callDiApi) return a Promise that resolves when the agent responds or times out.

Installation

npm install @bestoneconsulting/sap-b1-bridge

Peer dependencies (install if not already in your project):

npm install express ws zod

Quick Start

import express from "express";
import { createServer } from "http";
import { createSAPB1Bridge } from "@bestoneconsulting/sap-b1-bridge";

const app = express();
// Apply your host's authentication/authorization BEFORE the bridge API/UI.
// See "Host middleware and transfer limits" below for production integration.
app.post("/api/queries", express.json({ limit: "36mb" }));
app.use(express.json()); // ordinary endpoints retain the smaller default
const server = createServer(app);

const bridge = await createSAPB1Bridge({
  app,
  server,
  appName: "My Company App",
  wsPath: "/ws",
  apiPrefix: "/api",
});

bridge.mountUI("/bridge");

server.listen(3000, () => {
  console.log("Bridge running on http://localhost:3000");
  console.log("Test console at http://localhost:3000/bridge");
});

That's it. Your app now has:

  • A WebSocket endpoint at /ws for agents to connect to
  • REST API endpoints under /api for managing agents and submitting queries
  • A programmatic API for executing queries and SAP requests directly from your code
  • A test console at /bridge for managing agents and running queries from your browser

Public application URL (1.5.2)

Set optional appUrl on SAPB1BridgeOptions / createSAPB1Bridge to send the application's canonical published URL in the agent's auth_success message. The supplied value is returned unchanged (no trimming, slash removal, or rewriting). For Lucky Feather, use:

const bridge = await createSAPB1Bridge({
  app,
  server,
  appName: "Lucky Feather",
  appUrl: "https://hub.luckyfeather.com",
});

PlanetJill must supply its actual primary published URL; do not use a guessed domain or a Replit development URL. This package has no downstream URL default.

When appUrl is omitted, both bridge entry points resolve metadata from the original WebSocket upgrade request in this order:

  1. First comma-separated x-forwarded-host value, trimmed.
  2. Host, trimmed.
  3. REPLIT_DEV_DOMAIN, only when NODE_ENV !== "production" and request host information is absent.

Request-derived URLs use the first trimmed x-forwarded-proto value, defaulting to https (also for the development-domain fallback). Empty header values are treated as absent. Without a usable host or permitted development fallback, appUrl is undefined and omitted from serialized JSON. Explicit values use nullish precedence, so even an explicit empty string is preserved. Existing callers need no changes. The v2 createBridge options are unchanged. Forwarded headers are metadata supplied by the host/proxy, not an authentication or authorization signal; configure trusted proxy/header handling in the host.

After confirming public registry availability:

npm view @bestoneconsulting/[email protected] version --registry=https://registry.npmjs.org/
npm install @bestoneconsulting/[email protected] --registry=https://registry.npmjs.org/

Configure the canonical URL, reconnect an agent, and confirm its auth_success metadata before removing a downstream temporary URL patch. No Lucky Feather or PlanetJill deployment or patch is modified by this release.

Registering an Agent

Before the bridge can talk to your on-premise systems, you need to register an agent and install the agent software on your Windows server.

const agent = await bridge.registerAgent({
  name: "Main Office",
  serverName: "Production ERP",  // A friendly label — not a hostname or connection string
  capabilities: ["sql_server", "sap_service_layer", "sap_di_api"],
});

console.log("Agent API Key:", agent.apiKey);
// Give this API key to the agent software running on your Windows server

The serverName field is a descriptive label to help you identify which server or environment this agent represents (e.g. "Production ERP", "US Warehouse", "Dev Server"). It is not used as a connection string.

Contact Best One Consulting to obtain the Windows agent installer.

Built-in Test Console

The package includes a built-in web-based test console for managing agents, running queries, and viewing history. No separate build step or static files required.

bridge.mountUI("/bridge");
// Now accessible at http://localhost:3000/bridge

The test console includes:

  • Query Console — Execute SQL queries, SAP Service Layer requests, DI API operations, and Crystal Reports renders with a form-based interface. Select your target type and agent, enter your query or request details, and see results pushed back in real time over WebSocket.
  • Crystal Reports rendering — Choose the Crystal Reports target type to render a .rpt file that lives on the agent's machine. Enter the full report path (e.g. C:\Program Files (x86)\SAP\SAP Business One Server\B1_SHR\CR\Documents.rpt), optional string parameters as JSON (e.g. {"DocKey@":"1","ObjectId@":"13"}), the output format (PDF, Excel, or Word), and an output file name. When the render completes, the console shows the file name, type, and size with a one-click Download button. Renders are given up to ~4 minutes before the console reports a timeout (the History tab still records the final status).
  • Shared Files — Choose the Shared Files target and an online agent, refresh its folders, and select an operator-shared folder. List immediate contents, navigate permitted subfolders, read/download original file bytes, or explicitly upload a selected file. Choose whether to replace an existing file and confirm the write. File diagnostics only list a valid shared root; they never upload or modify files.
  • Agents — Register new agents, view live connection status, copy API keys, and delete agents. Displays the WebSocket URL that agents need to connect to, with a one-click copy button.
  • History — View recent query history with status, execution time, and row counts.
  • Live WebSocket — Real-time connection status indicator and instant result updates. When a query completes, the result is pushed to all connected UI clients immediately.

The test console adapts to your bridge configuration (app name, API prefix, WebSocket path) automatically.

If you have a custom full-featured UI (such as the React-based interface from the source repository), you can serve it instead by providing a staticDir:

bridge.mountUI("/bridge", "./path/to/custom-ui-dist");

Programmatic API

Shared files (introduced in 1.5.0)

Both createBridge(app, options) and the legacy createSAPB1Bridge(options) expose these methods with the same typed BridgeResult return envelopes:

listFiles(agentId: string, options: FileListCall): Promise<BridgeResult<FileListResult>>
readFile(agentId: string, options: FileReadCall): Promise<BridgeResult<FileReadResult>>
writeFile(agentId: string, options: FileWriteCall): Promise<BridgeResult<FileWriteResult>>
refreshAgentCapabilities(agentId: string): Promise<boolean>

The package exports SharedFileFolder, SharedFileEntry, FileCapabilityStatus, FileCall, FileListCall, FileReadCall, FileWriteCall, and all three result types. Calls resolve with completed, failed, timeout, or agent_offline; check the status before using result. Agent errors are preserved.

Discover folders and refresh changes

The operator configures folder IDs on the Windows agent, not in the cloud. There is no separate folder lookup operation. Refresh after the operator changes sharing settings; no server restart is needed:

import { createBridge } from "@bestoneconsulting/sap-b1-bridge";

const bridge = createBridge(app, { server, agents: registeredAgents });
const refreshed = await bridge.refreshAgentCapabilities(agentId);
if (!refreshed) throw new Error("Agent offline or capability refresh failed");
const agent = bridge.getAgent(agentId);
if (!agent?.online || !agent.capabilities.files) {
  throw new Error("File sharing is unavailable");
}
const folder = agent.capabilities.fileFolders[0];
if (!folder) throw new Error("The operator has not shared any folders");

const listing = await bridge.listFiles(agentId, { folderId: folder.id });
if (listing.status !== "completed") throw new Error(listing.error);
for (const entry of listing.result!.entries) {
  console.log(entry.name, entry.relativePath, entry.isDirectory,
    entry.sizeBytes, entry.lastModifiedUtc);
}

const read = await bridge.readFile(agentId, {
  folderId: folder.id,
  relativePath: "invoice.pdf",
});
if (read.status !== "completed") throw new Error(read.error);
const originalBytes = Buffer.from(read.result!.dataBase64, "base64");
// originalBytes may legitimately be an empty Buffer.

// This is an explicit write. In a user-facing app, request confirmation first.
const written = await bridge.writeFile(agentId, {
  folderId: folder.id,
  relativePath: "result.txt",
  dataBase64: Buffer.from("Export complete\n", "utf8").toString("base64"),
  overwrite: false, // safer opt-in; omitting this field defaults to true
});
if (written.status !== "completed") throw new Error(written.error);
console.log(written.result!.fileName, written.result!.sizeBytes);

File Sharing capability and status

The wire capability key is exactly files (case-sensitive), not fileSharing. File Sharing is the portal's display label; the built-in tester's target is Files. An agent can advertise:

{
  "type": "capabilities",
  "capabilities": {
    "sql": { "enabled": true, "available": true, "reason": null },
    "serviceLayer": { "enabled": false, "available": false, "reason": null },
    "diApi": { "enabled": false, "available": false, "reason": null },
    "files": {
      "enabled": true,
      "available": true,
      "reason": null,
      "folders": [
        {
          "id": "exports",
          "path": "C:\\SAP\\Exports",
          "includeSubfolders": true
        }
      ]
    }
  }
}

File operations require both enabled and available to be true. Folder IDs identify operator-approved roots; includeSubfolders controls access beneath each root. Use reason to explain disabled or unavailable sharing.

Capability mapping: the wire uses a detailed files object, while the v2 SDK exposes a normalized boolean and a separate folder list:

// createBridge().getAgent(id).capabilities
{
  sql: true, serviceLayer: true, diApi: true, crystalReports: false,
  files: true,
  fileFolders: [
    { id: "operator-folder-id", path: "C:\\SAP\\Exports", includeSubfolders: true }
  ]
}

The detailed/legacy representation retains the wire metadata:

// legacyBridge.getAgentCapabilities(id), or GET /api/agents/capabilities
{
  // sql / serviceLayer / diApi / crystalReports omitted here
  files: {
    enabled: true, available: true, reason: null,
    folders: [
      { id: "operator-folder-id", path: "C:\\SAP\\Exports", includeSubfolders: true }
    ]
  }
}

For the legacy API, call await legacyBridge.refreshAgentCapabilities(agentId), then read legacyBridge.getAgentCapabilities(agentId)?.files?.folders. Old agents without Files support remain compatible and are treated as unavailable with no shared folders. Refresh supports modern and legacy capability envelopes. An empty folder list is valid even when sharing is enabled. Never reuse an ID after it disappears from refreshed capabilities.

Capabilities are updated when the agent sends a capabilities message and when an on-demand agent_capabilities request completes. Refresh responses may use result or results, including a nested capabilities object. The server broadcasts the refreshed status to connected UI clients; capabilities are not captured only at authentication. auth_success is the server's authentication acknowledgment, not the agent's capability report.

Restrictions and transport

  • A file is limited to 25 MiB (26,214,400 bytes) in either direction. Base64 overhead does not count toward the decoded file cap. Empty files work.
  • listFiles lists one directory, nonrecursively. relativePath defaults to "" (the shared root). Read and write require a nonempty relative file path.
  • Use forward slashes. Absolute paths, drive paths, backslashes and .. traversal are rejected. includeSubfolders: false permits only root listing and direct child files; it does not allow listing a subdirectory.
  • path is display metadata on the agent's own machine, never a path to access through the cloud server's filesystem.
  • overwrite defaults to true at the SDK/protocol level. Pass false to reject an existing file. Testers require an explicit overwrite choice.
  • Malformed base64 and oversized writes fail before dispatch. Filesystem access, actual read sizes, permissions, and final path/subfolder enforcement remain authoritative on the agent.
  • Disabled sharing, unknown folders, forbidden paths, missing files, overwrite conflicts, timeouts and offline agents return errors. There are no automatic retries, chunking, delete operations, synchronization or recursive bulk transfers.
  • Each file call accepts an optional timeoutMs; otherwise the SDK's normal query timeout applies. A timeout is not a cancellation of work on the agent; do not automatically repeat a write.

The existing JSON transport is used without a new binary protocol: execute_request, targetType: "sap", and requestPayload.operation equal to file_list, file_read or file_write. The tester API target is files:

{
  "agentId": "registered-agent-id",
  "targetType": "files",
  "requestPayload": {
    "operation": "file_list",
    "folderId": "operator-folder-id",
    "relativePath": ""
  }
}

Send this to POST /api/queries. Completion uses the existing query lifecycle. For a browser download, first connect the tester WebSocket with { "type": "ui_connect" }, take the server-issued clientId from ui_connected, and include it as uiClientId in the HTTP POST body. Full file results are sent only to that requesting socket. Other UI subscribers receive metadata without bytes; SDK calls do not broadcast their file contents to testers. The included testers handle this automatically. History and active-query summaries are not download endpoints. Object payloads in query_result.results (plural), including messages without a status, are retained as the result. Read results contain dataBase64, fileName, relativePath, sizeBytes and success; write results contain the same metadata without bytes. History summaries omit file bytes. Download bytes are for the initiating result view, not for rendering as JSON or logging.

Host middleware and transfer limits

Express's default JSON limit is too small for file uploads. A parser that runs first cannot be overridden later by the SDK. Scope a 36 MiB JSON limit to the authenticated query endpoint, before any smaller global JSON parser:

// Supply these from your application's existing security/session layer.
app.use("/api", authenticateUser, authorizeBridgeAccess);
app.use("/bridge", authenticateUser, authorizeBridgeAccess);
app.post("/api/queries", express.json({ limit: "36mb" }));
app.use(express.json()); // other endpoints retain their default limit
// Only now construct the bridge and mount its tester.

If your authentication routes require a global parser first, skip POST /api/queries in that parser, then mount its larger parser after authentication/authorization. Do not raise the global unauthenticated body limit. The SDK does not provide your application's user authentication; secure the tester, REST endpoints, and UI WebSocket upgrades in your host.

The 36 MiB HTTP allowance fits a 25 MiB file encoded as ~33⅓ MiB of base64 plus JSON metadata. The bridge explicitly caps incoming WebSocket messages at 100 MiB, retaining the previous transport allowance for Crystal Reports. Configure reverse proxies with sufficient finite request/frame limits too. Return HTTP 413 with a clear size error instead of retrying an oversized body. Do not log request/response bodies containing file bytes.

Verification from the source repository

npm run check
npm run test:bridge
npm run test:files-ui
npm run build
npm run smoke:bridge-package

The bridge suite uses mock WebSocket agents, including exact-cap HTTP/WS byte round-trips and legacy SQL/SAP/Crystal regression checks. Browser tests use isolated fixtures for both testers; they do not access live Windows folders. The package smoke test builds and packs the release, installs that tarball in a clean temporary npm consumer, checks exported TypeScript APIs, and executes both SDK surfaces against mock agents. The UI suite requires Chromium (set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH when it is not at the Replit default). Live Windows permissions, configured folder paths, and agent runtime behavior must be verified separately in an operator-approved test folder.

All programmatic methods are synchronous — they return a Promise that resolves when the agent responds with the result or the request times out (default: 5 minutes). The server sends the command directly to the agent over WebSocket and waits for the response.

Execute SQL Queries

Run queries against SQL Server databases on your internal network:

const result = await bridge.executeQuery(agentId, "SELECT TOP 10 * FROM OITM");

if (result.status === "completed") {
  console.log("Rows:", result.rowCount);
  console.log("Data:", result.result);
  console.log("Duration:", result.executionTime, "ms");
} else {
  console.log("Error:", result.error);
}

Call SAP Service Layer

Send REST API requests to SAP Business One Service Layer:

// GET request
const partners = await bridge.callServiceLayer(agentId, {
  method: "GET",
  endpoint: "/BusinessPartners",
  queryParams: {
    $top: "5",
    $select: "CardCode,CardName,CardType",
    $filter: "CardType eq 'C'",
  },
});

// POST request
const newOrder = await bridge.callServiceLayer(agentId, {
  method: "POST",
  endpoint: "/Orders",
  body: {
    CardCode: "C20000",
    DocumentLines: [
      { ItemCode: "A00001", Quantity: 10 },
    ],
  },
});

// PATCH request
await bridge.callServiceLayer(agentId, {
  method: "PATCH",
  endpoint: "/BusinessPartners('C20000')",
  body: { Phone1: "555-0100" },
});

Call SAP DI API

Invoke SAP Business One DI API methods using the XML-first contract:

// Get an item
const item = await bridge.callDiApi(agentId, {
  operation: "di_get",
  object: "Items",
  key: { ItemCode: "A00001" },
});

// Add a business partner with XML
const result = await bridge.callDiApi(agentId, {
  operation: "di_add",
  object: "BusinessPartners",
  sapXml: `<BOM>
    <BO>
      <AdmInfo><Object>2</Object></AdmInfo>
      <BusinessPartners>
        <row>
          <CardCode>C99999</CardCode>
          <CardName>New Customer</CardName>
          <CardType>cCustomer</CardType>
        </row>
      </BusinessPartners>
    </BO>
  </BOM>`,
  options: { dryRun: false },
});

// Run a DI query
const queryResult = await bridge.callDiApi(agentId, {
  operation: "di_query",
  object: "Items",
  sapXml: `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
    <env:Body>
      <dis:DoQuery xmlns:dis="http://www.sap.com/SBO/DIS">
        <QueryParams>SELECT ItemCode, ItemName FROM OITM WHERE ItemCode LIKE 'A%'</QueryParams>
      </dis:DoQuery>
    </env:Body>
  </env:Envelope>`,
});

DI API Operations:

| Operation | Description | Required Fields | |-----------|-------------|-----------------| | di_get | Retrieve an object | object, key | | di_add | Create a new object | object, sapXml | | di_update | Update an existing object | object, key, sapXml | | di_action | Perform an action (cancel, close, remove) | object, key, action | | di_query | Execute a DI query | object, sapXml |

DI API Options:

| Option | Type | Description | |--------|------|-------------| | dryRun | boolean | Validate without committing | | transaction | boolean | Wrap in a transaction | | returnXml | boolean | Return raw XML response | | allowDiQuery | boolean | Allow DI query execution |

Render Crystal Reports

Render a Crystal Reports .rpt file that lives on the agent's machine to PDF, Excel, or Word. The rendered document comes back base64-encoded inside the result — no separate download channel needed.

const r = await bridge.renderCrystalReport(agentId, {
  reportPath: "C:\\SAP\\CR_ASSOC\\AR Invoice.rpt", // must end in .rpt
  overrideDataSources: true,                        // default true — use the agent's configured SQL connection
  parameters: { "DocKey@": "1", "ObjectId@": "13" },
  outputFormat: "pdf",                              // "pdf" | "excel" | "word"
  outputFileName: "AR Invoice 1",                   // base name, no extension
});

if (r.status === "completed" && r.result) {
  const bytes = Buffer.from(r.result.dataBase64, "base64");
  // r.result: { success, fileName, contentType, dataBase64, sizeBytes }
  // e.g. stream `bytes` back to the browser as r.result.contentType, named r.result.fileName
} else {
  // Surface r.error in full — the agent appends diagnostic notes to
  // Crystal's often-misleading "Database logon failed" errors. Don't truncate it.
  console.error(r.error);
}

The request is dispatched as an execute_request with payload { operation: "crystal_render", reportPath, overrideDataSources, parameters, outputFormat, outputFileName } and the result is read from query_result.result as a single object (not a row array).

Fields:

| Field | Type | Description | |-------|------|-------------| | reportPath | string | Full path to a .rpt file on the agent's machine (required, must end in .rpt) | | overrideDataSources | boolean | Default true. Overrides report datasources with the agent's configured SQL connection | | parameters | Record<string, string> | Report parameter values; unrecognized names are skipped and listed in warning | | outputFormat | "pdf" \| "excel" \| "word" | Export format (required). Determines returned fileName extension and contentType | | outputFileName | string | Base file name without extension (required) | | timeoutMs | number | Client-side timeout override. Default 240000 (4 minutes) |

Timeout guidance: rendering runs in a separate helper process on the agent with its own 3-minute limit — longer than the 2-minute limit for SQL/Service Layer. The SDK therefore uses a 4-minute client-side timeout for renderCrystalReport by default so it never races the agent's own limit. Note that dataBase64 can be several MB for multi-page reports.

Agents report a crystalReports capability. If the Crystal Reports runtime isn't installed (or the feature is disabled in the agent profile), crystalReports is false on the agent record and crystal_render requests fail immediately with a clear error. Offline agents return { status: "agent_offline" } without a round-trip.

Agent Management

// List all agents
const agents = await bridge.getAgents();

// Get a specific agent
const agent = await bridge.getAgent(agentId);

// Check if an agent is currently connected
const online = bridge.isAgentConnected(agentId);

// Get live capability status (from in-memory cache, reported by agent)
const caps = bridge.getAgentCapabilities(agentId);
// Returns: { sql: { enabled, available, reason }, serviceLayer: {...}, diApi: {...} }

// Request an agent to refresh its capabilities
await bridge.refreshAgentCapabilities(agentId);

// Get all currently connected agent IDs
const connectedIds = bridge.getConnectedAgentIds();

// Dispatch a previously created query to an agent for execution
await bridge.dispatchQueryToAgent(agentId, queryId);

// Delete an agent
await bridge.deleteAgent(agentId);

Company Management

Organize agents by company for multi-tenant setups:

const company = await bridge.createCompany("Acme Corp", "Production environment");
const companies = await bridge.getCompanies();
await bridge.deleteCompany(company.id);

REST API Endpoints

The bridge automatically registers these REST endpoints on your Express app:

| Endpoint | Method | Description | |----------|--------|-------------| | {prefix}/agents | GET | List all registered agents | | {prefix}/agents | POST | Register a new agent | | {prefix}/agents/:id | PATCH | Update agent details | | {prefix}/agents/:id | DELETE | Remove an agent | | {prefix}/agents/capabilities | GET | Get live capability status for all agents | | {prefix}/agents/:id/refresh-capabilities | POST | Request capability refresh from agent | | {prefix}/agents/:id/test | POST | Test agent connection (sends ping, waits for pong) | | {prefix}/queries | POST | Submit a query for execution | | {prefix}/queries/active | GET | Get most recent query | | {prefix}/queries/recent | GET | Get recent query history | | {prefix}/queries | GET | Get all queries | | {prefix}/companies | GET | List companies | | {prefix}/companies | POST | Create a company | | {prefix}/companies/:id | PATCH | Update a company | | {prefix}/companies/:id | DELETE | Delete a company | | {prefix}/config | GET | Get bridge configuration |

Query Submission (POST {prefix}/queries)

When you submit a query via the REST endpoint, the server:

  1. Creates the query record in storage with status pending
  2. If the target agent is connected, immediately sends the command (execute_query for SQL or execute_request for SAP targets) directly to the agent and updates status to executing
  3. If the agent is not connected, marks the query as error with the message "Agent is not connected"
  4. Returns the query record (with its current status) in the response

Results arrive asynchronously over WebSocket and are broadcast to connected UI clients as query_completed events. If you need to await the result in server code, use the programmatic API (executeQuery, callServiceLayer, callDiApi) instead.

WebSocket Protocol

Agents connect to {wsPath} and the bridge handles all communication over a single persistent WebSocket connection per agent.

Agent Authentication Flow

  1. Agent opens WebSocket to {wsPath}
  2. Agent sends { type: "auth", apiKey: "agent_xxx" }
  3. Bridge validates the API key and responds with { type: "auth_success", agentId, connectionName, appName, appUrl } or { type: "auth_failed" } (and closes the connection)
  4. Agent is now connected and ready to receive commands

Server-to-Agent Messages (Direct Push)

The server pushes commands directly to the agent — the agent never polls for work:

| Message Type | Direction | Description | |---|---|---| | execute_query | Server → Agent | Execute a SQL query. Fields: queryId, sqlQuery | | execute_request | Server → Agent | Execute a SAP request. Fields: queryId, targetType, requestPayload | | ping | Server → Agent | Connection health check. Fields: pingId | | heartbeat_ack | Server → Agent | Acknowledgment of agent heartbeat |

Agent-to-Server Messages

| Message Type | Direction | Description | |---|---|---| | auth | Agent → Server | Authenticate with API key | | heartbeat | Agent → Server | Periodic keepalive (every 30s) | | capabilities | Agent → Server | Report available capabilities | | query_result | Agent → Server | Return query/request results. Fields: queryId, result, rowCount, executionTime, error | | pong | Agent → Server | Response to ping. Fields: pingId |

UI Client Messages

Browser clients connect to the same WebSocket endpoint with { type: "ui_connect" } to receive real-time updates:

| Message Type | Direction | Description | |---|---|---| | ui_connect | Client → Server | Register as a UI client | | ui_connected | Server → Client | Confirmation of UI registration | | agent_status_update | Server → Client | Agent went online/offline. Fields: agentId, status | | agent_capabilities_update | Server → Client | Agent capabilities changed. Fields: agentId, capabilities | | query_submitted | Server → Client | New query created. Fields: queryId, agentId | | query_executing | Server → Client | Query sent to agent for execution. Fields: queryId | | query_completed | Server → Client | Query finished. Fields: queryId, status, result, rowCount, executionTime, error |

const ws = new WebSocket("ws://localhost:3000/ws");

ws.onopen = () => {
  ws.send(JSON.stringify({ type: "ui_connect" }));
};

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  switch (message.type) {
    case "ui_connected":
      console.log("Connected to bridge");
      break;
    case "agent_status_update":
      console.log(`Agent ${message.agentId} is now ${message.status}`);
      break;
    case "query_completed":
      console.log(`Query ${message.queryId}: ${message.status}`, message.result);
      break;
  }
};

Custom Storage

By default, the bridge uses in-memory storage (data is lost on restart). For persistence, provide your own storage implementation:

import { createSAPB1Bridge } from "@bestoneconsulting/sap-b1-bridge";
import type { IBridgeStorage } from "@bestoneconsulting/sap-b1-bridge";

class MyDatabaseStorage implements IBridgeStorage {
  async getAgents() { /* query your database */ }
  async getAgent(id) { /* ... */ }
  async getAgentByApiKey(apiKey) { /* ... */ }
  async createAgent(data) { /* ... */ }
  async updateAgent(id, updates) { /* ... */ }
  async updateAgentStatus(id, status, lastHeartbeat) { /* ... */ }
  async deleteAgent(id) { /* ... */ }

  async getQueries() { /* ... */ }
  async getQuery(id) { /* ... */ }
  async getRecentQueries(limit) { /* ... */ }
  async getActiveQuery() { /* ... */ }
  async createQuery(data) { /* ... */ }
  async updateQueryStatus(id, status, updates?) { /* ... */ }

  async getCompanies() { /* ... */ }
  async getCompany(id) { /* ... */ }
  async createCompany(name, description?) { /* ... */ }
  async updateCompany(id, updates) { /* ... */ }
  async deleteCompany(id) { /* ... */ }
}

const bridge = await createSAPB1Bridge(options, new MyDatabaseStorage());

See the full IBridgeStorage interface for method signatures and return types.

Built-in PostgreSQL Storage (Drizzle ORM)

The package includes a ready-to-use PostgreSQL storage adapter built on Drizzle ORM. This gives you persistent storage without writing any database code.

1. Install Drizzle dependencies:

npm install drizzle-orm pg drizzle-kit
npm install -D @types/pg

Or if you use Neon serverless PostgreSQL:

npm install drizzle-orm @neondatabase/serverless drizzle-kit

2. Set up the database connection and push the schema:

Create a drizzle.config.ts in your project root:

import { defineConfig } from "drizzle-kit";

export default defineConfig({
  out: "./drizzle",
  schema: "./src/db/schema.ts",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Create src/db/schema.ts that re-exports the bridge schema (and any of your own tables):

export { bridgeCompanies, bridgeAgents, bridgeQueries } from "@bestoneconsulting/sap-b1-bridge";

// Add your own application tables here if needed

Push the schema to your database:

npx drizzle-kit push

This creates three tables: bridge_companies, bridge_agents, and bridge_queries. The bridge_ prefix avoids conflicts with your own application tables.

3. Create the Drizzle client and pass it to the bridge:

For standard PostgreSQL (using pg):

import express from "express";
import { createServer } from "http";
import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import { createSAPB1Bridge, createDrizzleBridgeStorage } from "@bestoneconsulting/sap-b1-bridge";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);

const app = express();
app.use(express.json());
const server = createServer(app);

const storage = createDrizzleBridgeStorage(db);

const bridge = await createSAPB1Bridge(
  { app, server, appName: "My SAP App" },
  storage
);

bridge.mountUI("/bridge");
server.listen(3000);

For Neon serverless PostgreSQL:

import express from "express";
import { createServer } from "http";
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import { createSAPB1Bridge, createDrizzleBridgeStorage } from "@bestoneconsulting/sap-b1-bridge";

const sql = neon(process.env.DATABASE_URL!);
const db = drizzle(sql);

const app = express();
app.use(express.json());
const server = createServer(app);

const storage = createDrizzleBridgeStorage(db);

const bridge = await createSAPB1Bridge(
  { app, server, appName: "My SAP App" },
  storage
);

bridge.mountUI("/bridge");
server.listen(3000);

Schema details:

| Table | Purpose | |-------|---------| | bridge_companies | Organization grouping for agents | | bridge_agents | Registered agents with API keys and connection status | | bridge_queries | Query history with status, results, and execution metrics |

The Drizzle schema objects are exported for advanced use cases (custom queries, migrations, extending):

import { bridgeCompanies, bridgeAgents, bridgeQueries } from "@bestoneconsulting/sap-b1-bridge";

Configuration Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | app | Express | required | Your Express application instance | | server | Server | required | Node.js HTTP server instance | | appName | string | "SAP B1 Bridge" | Application name sent to agents on connection | | wsPath | string | "/ws" | WebSocket endpoint path | | apiPrefix | string | "/api" | Prefix for all REST API routes |

Graceful shutdown (introduced in 1.5.1)

Both createBridge() and createSAPB1Bridge() expose the public method:

shutdown(options?: { timeoutMs?: number }): Promise<void>

Call await bridge.shutdown({ timeoutMs: 5000 }) before the host exits. The bridge sends WebSocket close code 1012, reason Service Restart, to agent and UI connections and allows a bounded time for close handshakes. Unresponsive connections are terminated after the timeout (default 5 seconds). Repeated calls are safe; shutdown is permanent for that bridge instance. Create a new instance when starting the application again.

Shutdown rejects new bridge work, settles pending calls, and clears bridge timers. It waits for cleanup writes within the same timeout; a slow external storage adapter may finish its writes after the deadline. It removes its WebSocket upgrade listener at completion without closing other host services.

The package does not register signal handlers, call process.exit(), or close the host's HTTP server. The host owns those resources and must also stop its schedulers and other work. For example, after creating bridge and server:

let shuttingDown = false;

async function stop() {
  if (shuttingDown) return;
  shuttingDown = true;
  // Stop your schedulers and reject new application work here.
  const deadline = setTimeout(() => process.exit(1), 8000);
  try {
    const httpClosed = new Promise<void>((resolve, reject) => {
      server.close(error => error ? reject(error) : resolve());
    });
    await Promise.all([bridge.shutdown({ timeoutMs: 5000 }), httpClosed]);
    // Close your database pools and other host-owned resources here.
    clearTimeout(deadline);
    process.exit(0);
  } catch (error) {
    console.error("Shutdown failed", error);
    process.exit(1);
  }
}

process.on("SIGTERM", () => { void stop(); });
process.on("SIGINT", () => { void stop(); });

Choose deadlines shorter than your host's termination grace period. Clients must still reconnect after abnormal disconnects: a forced kill or machine crash cannot perform a clean WebSocket handshake.

Timeouts

  • Query timeout: 300 seconds (5 minutes). If an agent doesn't respond within this window, the Promise rejects and the query is marked as error with message "Query timed out".
  • Crystal Reports render timeout: 240 seconds (4 minutes) by default for renderCrystalReport — longer than the agent's own 3-minute render limit so the client never races it. Override per call with timeoutMs.
  • Agent test timeout: 5 seconds. The POST {prefix}/agents/:id/test endpoint sends a ping and waits up to 5 seconds for a pong response.
  • Heartbeat interval: Agents send heartbeats every 30 seconds. An agent is marked offline if no heartbeat is received within 90 seconds.

TypeScript Support

Full TypeScript support with exported types:

import type {
  SAPB1BridgeOptions,
  SAPB1BridgeAPI,
  AgentInfo,
  QueryResult,
  ServiceLayerRequest,
  DiApiRequest,
  AgentCapabilities,
  CapabilityStatus,
  CrystalReportRenderCall,
  CrystalReportRenderResult,
  CompanyInfo,
  RegisterAgentOptions,
  IBridgeStorage,
  BridgeQuery,
} from "@bestoneconsulting/sap-b1-bridge";

Target Types

| Target | Capability Key | Description | |--------|---------------|-------------| | SQL Server | sql_server | Execute T-SQL queries against Microsoft SQL Server | | SAP Service Layer | sap_service_layer | REST API calls to SAP Business One Service Layer | | SAP DI API | sap_di_api | XML-based SAP Business One DI API operations | | Crystal Reports | sap_crystal_reports | Render Crystal Reports .rpt files to PDF/Excel/Word on the agent's machine |

Example: Express App with SAP Integration

import express from "express";
import { createServer } from "http";
import { createSAPB1Bridge } from "@bestoneconsulting/sap-b1-bridge";

const app = express();
app.use(express.json());
const server = createServer(app);

const bridge = await createSAPB1Bridge({
  app,
  server,
  appName: "Inventory Dashboard",
  apiPrefix: "/api/bridge",
});

// Your own route that uses the bridge programmatic API
app.get("/api/inventory", async (req, res) => {
  const agents = await bridge.getAgents();
  const onlineAgent = agents.find((a) => bridge.isAgentConnected(a.id));

  if (!onlineAgent) {
    return res.status(503).json({ error: "No agents online" });
  }

  try {
    // This awaits the result — the server sends the command to the agent
    // and waits for the response over WebSocket
    const result = await bridge.executeQuery(
      onlineAgent.id,
      "SELECT ItemCode, ItemName, OnHand FROM OITM WHERE OnHand > 0"
    );

    if (result.status === "completed") {
      res.json({ items: result.result, count: result.rowCount });
    } else {
      res.status(500).json({ error: result.error });
    }
  } catch (err) {
    res.status(500).json({ error: "Query failed" });
  }
});

server.listen(3000);

License

MIT License - Copyright (c) 2025 Best One Consulting

Support

For questions, agent software, or implementation help:

Best One Consulting https://www.bestoneconsulting.com/