@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
Maintainers
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:
- Agent connects outbound — The Windows agent initiates a WebSocket connection from inside your network to your cloud server. No inbound firewall ports needed.
- 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.
- Agent executes and responds — The agent runs the SQL query or SAP operation locally and sends the result back over the same WebSocket connection.
- 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-bridgePeer dependencies (install if not already in your project):
npm install express ws zodQuick 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
/wsfor agents to connect to - REST API endpoints under
/apifor managing agents and submitting queries - A programmatic API for executing queries and SAP requests directly from your code
- A test console at
/bridgefor 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:
- First comma-separated
x-forwarded-hostvalue, trimmed. Host, trimmed.REPLIT_DEV_DOMAIN, only whenNODE_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 serverThe 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/bridgeThe 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
.rptfile 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.
listFileslists one directory, nonrecursively.relativePathdefaults 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: falsepermits only root listing and direct child files; it does not allow listing a subdirectory. pathis display metadata on the agent's own machine, never a path to access through the cloud server's filesystem.overwritedefaults to true at the SDK/protocol level. Passfalseto 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-packageThe 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:
- Creates the query record in storage with status
pending - If the target agent is connected, immediately sends the command (
execute_queryfor SQL orexecute_requestfor SAP targets) directly to the agent and updates status toexecuting - If the agent is not connected, marks the query as
errorwith the message "Agent is not connected" - 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
- Agent opens WebSocket to
{wsPath} - Agent sends
{ type: "auth", apiKey: "agent_xxx" } - Bridge validates the API key and responds with
{ type: "auth_success", agentId, connectionName, appName, appUrl }or{ type: "auth_failed" }(and closes the connection) - 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/pgOr if you use Neon serverless PostgreSQL:
npm install drizzle-orm @neondatabase/serverless drizzle-kit2. 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 neededPush the schema to your database:
npx drizzle-kit pushThis 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
errorwith 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 withtimeoutMs. - Agent test timeout: 5 seconds. The
POST {prefix}/agents/:id/testendpoint 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/
