bms-session-client
v1.1.1
Published
Client library for the BMS Session API v2.0 — query HOSxP hospital databases (SQL + REST CRUD + server functions)
Maintainers
Readme
bms-session-client
Client library for the BMS Session API v2.0 — secure, typed access to HOSxP hospital databases (Bangkok Medical Software) from Node.js and TypeScript.
One BMSClient class gives you read-only SQL, config-driven REST CRUD across 118+ tables, and ~70 server-side functions, behind a validated session — with a typed error hierarchy, an optional request queue, an optional local-gateway fast path, and zero runtime dependencies.
- Runtime: Node.js ≥ 18 (uses built-in
fetch/AbortSignal.timeout) - Formats: ESM + CommonJS, with bundled
.d.tstypes - Dependencies: none
Contents
- Install
- How it works
- Quickstart
- Configuration
- API reference
- Error handling
- Types
- HOSxP domain notes
- Recipes
- Troubleshooting
- Versioning
- License
Install
npm install bms-session-client// ESM / TypeScript
import { BMSClient } from "bms-session-client";// CommonJS
const { BMSClient } = require("bms-session-client");How it works
The BMS Session system brokers access to an on-premise HOSxP hospital database without exposing database credentials to the client. The flow has three stages:
- Session acquisition — a short-lived
bms-session-id(a GUID) is issued by the HOSxP system, typically delivered to your app via a URL parameter, cookie, or manual entry. - Session validation —
validateSession(id)exchanges that GUID athttps://hosxp.net/phapi/PasteJSONfor connection details: the hospital's API base URL (usually a tunnel likehttp://tunnel.hosxp.net:PORT), a bearer token, the database name and dialect, and user/hospital metadata. The client caches these in memory for the session's lifetime (~10 hours). - API execution — every subsequent call (
/api/sql,/api/rest/*,/api/function) is sent to the cached base URL with the bearer token attached.
The client exposes two tiers over this flow:
| Tier | Methods | Behavior on a BMS error |
|---|---|---|
| Transport | validateSession, executeQuery, restRequest, callFunction | Returns the raw response; you inspect MessageCode. |
| Helper | listTables, describeTable, restGet, restWrite, the function wrappers | Throws BMSApiError when MessageCode is not 2xx. |
Use the transport tier when you want full control over the response; use the helper tier for concise try/catch code. Both are on the same object — mix freely.
Quickstart
import { BMSClient } from "bms-session-client";
const client = new BMSClient({
// optional — only needed to authorize REST/SQL writes
marketplaceToken: process.env.BMS_MARKETPLACE_TOKEN,
});
// 1. Validate a bms-session-id → caches base URL + bearer token
const session = await client.validateSession(process.env.BMS_SESSION_ID!);
console.log(`Connected to ${session.databaseName} (${session.databaseType})`);
console.log(`User: ${session.userName} · expires in ${(session.expiresInSeconds / 3600).toFixed(1)}h`);
// 2. Read with SQL (SELECT / DESCRIBE / EXPLAIN / SHOW / WITH only)
const result = await client.executeQuery(
"SELECT hn, fname, lname FROM patient LIMIT 5"
);
if (result.MessageCode === 200) console.table(result.data);
// 3. Read with the config-driven REST API (auto-joins + filters)
const visits = await client.restGet({
table: "ovst",
select: "vn,vstdate,patient(fname,lname)",
filter: ["vstdate=gte.2026-07-01"],
order: "vstdate.desc",
limit: 20,
});
// 4. Discover the schema
const tables = await client.listTables("patient*");
const columns = await client.describeTable("patient");
// 5. Call a server function (via a typed wrapper)
const vn = await client.getSerialNumber("vn", "ovst", "vn");Configuration
Every option is optional. Pass an object to the constructor:
const client = new BMSClient({
validationUrl: "https://hosxp.net/phapi/PasteJSON", // session-exchange endpoint
timeoutMs: 30_000, // per-request timeout
appName: "BMS.Session.Client", // sent as `app` on /api/sql
marketplaceToken: undefined, // authorizes writes
localApiUrl: "http://127.0.0.1:45011", // probed by probeLocalApi()
localProbeTimeoutMs: 3_000, // fast-fail for the probe
cacheBust: false, // add &random= to every URL
queue: false, // dedup + concurrency + retry
});| Option | Type | Default | Purpose |
|---|---|---|---|
| validationUrl | string | https://hosxp.net/phapi/PasteJSON | Where validateSession exchanges the session ID. Override for staging/dev. |
| timeoutMs | number | 30000 | Per-request timeout (via AbortSignal.timeout). Applies to every call except the local probe. |
| appName | string | BMS.Session.Client | Value sent as the app field on /api/sql. Identifies your app in HOSxP audit logs. |
| marketplaceToken | string | — | Authorizes REST/SQL writes. Also settable later via setMarketplaceToken(). Blank strings are ignored. |
| localApiUrl | string | http://127.0.0.1:45011 | Base URL of the HOSxP local API gateway, probed by probeLocalApi(). |
| localProbeTimeoutMs | number | 3000 | Timeout for the local probe — deliberately short so it never blocks connection. |
| cacheBust | boolean | false | Append a unique &random=… to every request URL so no proxy/tunnel/cache returns a stale response. Recommended for tunnelled/SPA usage. |
| queue | boolean \| BMSQueueOptions | false | Route executeQuery through an in-client queue (dedup + concurrency cap + 429 retry). true uses defaults; pass an object to tune. |
API reference
Session
new BMSClient(options?: BMSClientOptions)
Constructs a client. Performs no I/O — nothing is contacted until validateSession.
validateSession(sessionId: string): Promise<BMSSessionData>
Exchanges a bms-session-id for connection config and caches it in memory. Must be called before any query/REST/function method. Returns the parsed BMSSessionData.
- Throws
BMSSessionErrorif the exchange fails (MessageCode ≠ 200) or no auth token is present. - Throws
BMSNetworkErroron a network failure, timeout, or non-JSON body.
getSession(): BMSSessionData
Returns the cached session synchronously. Throws BMSSessionError if called before validateSession.
setMarketplaceToken(token: string | null): void
Sets or clears the write-authorization token at runtime. Blank/whitespace strings are treated as null.
hasMarketplaceToken(): boolean
Whether a non-empty marketplace token is currently configured.
SQL queries
executeQuery(sql: string, params?: SqlParams): Promise<BMSQueryResponse>
Executes a read-only statement against /api/sql. The server permits SELECT, DESCRIBE, EXPLAIN, SHOW, and WITH (CTE) — writes are rejected server-side.
- Pass
paramsto bind:nameplaceholders safely on the server (see SQL parameters). - Returns the raw
BMSQueryResponse— inspectMessageCodeyourself (transport tier). - Throws
BMSAuthErroron HTTP 501 (expired session),BMSRateLimitErroron HTTP 429,BMSNetworkErroron network/parse failure.
// Plain
const r = await client.executeQuery("SELECT COUNT(*) AS n FROM patient");
// With server-side parameter binding
const r2 = await client.executeQuery(
"SELECT * FROM patient WHERE hn = :hn",
{ hn: { value: "000000001", value_type: "string" } }
);SQL parameters (server-side binding)
SqlParams is a map of placeholder name (without the leading colon) to { value, value_type }:
await client.executeQuery(
"SELECT * FROM ovst WHERE vstdate = :d AND hn = :hn",
{
d: { value: "2026-07-01", value_type: "date" },
hn: { value: "000000001", value_type: "string" },
}
);value_type ∈ string | integer | float | date | time | datetime | text.
Schema discovery
listTables(pattern?: string): Promise<string[]>
Returns table names, dialect-aware (SHOW TABLES on MariaDB, information_schema.tables on PostgreSQL). * in the pattern is translated to the SQL LIKE wildcard %.
- The pattern is validated against
/^[a-zA-Z0-9_*%]*$/; anything else throwsTypeError(no network call). This is an injection guard — the pattern is interpolated into SQL. - Throws
BMSApiErroron a non-2xx response.
await client.listTables(); // all tables
await client.listTables("patient*"); // → ["patient", "patient_drug", ...]
await client.listTables("*drug*");describeTable(table: string): Promise<Record<string, unknown>[]>
Returns column metadata for one table, dialect-aware (DESCRIBE vs information_schema.columns). Table name must match /^[a-zA-Z_][a-zA-Z0-9_]*$/ or a TypeError is thrown. Throws BMSApiError on a non-2xx response.
REST CRUD
The /api/rest endpoint is a config-driven CRUD layer over whitelisted tables. It auto-joins related tables, applies per-table row limits, and supports rich filtering — often more convenient than hand-written SQL.
restGet(options: RestGetOptions): Promise<BMSRestResponse>
Reads records. Builds the path and query string from RestGetOptions, then throws BMSApiError on a non-2xx response.
// List with filter, ordering, pagination, and an auto-join
await client.restGet({
table: "ovst",
select: "vn,vstdate,patient(fname,lname),doctor(name)",
filter: ["vstdate=gte.2026-06-01", "pttype=in.(01,02)"],
order: "vstdate.desc",
limit: 50,
offset: 0,
});
// Single record by its REST id (hn for patient, vn for ovst, an for ipt)
await client.restGet({ table: "patient", id: "000000001" });Filter operators
Each filter entry is a string "field=op.value":
| Operator | Meaning | Example |
|---|---|---|
| eq | equals | pttype=eq.01 |
| neq | not equals | pttype=neq.01 |
| gt / gte | greater / greater-or-equal | vstdate=gte.2026-06-01 |
| lt / lte | less / less-or-equal | age=lt.18 |
| like | SQL LIKE | fname=like.%John% |
| in | in a set | pttype=in.(01,02,03) |
| is | IS (null checks) | discharge=is.null |
select expansions
select takes a comma list of fields, and related(fields) expressions to pull joined data — e.g. "*,patient(fname,lname)" or "expand(all)".
restWrite(method, table, options?): Promise<BMSRestResponse>
Creates, updates, or deletes a record. Requires a marketplace token — throws TypeError without one.
method∈"POST" | "PUT" | "DELETE".PUT/DELETErequireoptions.id(elseTypeError).- The server validates required fields, ranges, foreign keys, and finance locks, strips protected/PII fields, and fills identity/audit fields (PKs, timestamps) itself — send only business fields.
- Throws
BMSApiErroron a non-2xx response.
client.setMarketplaceToken(process.env.BMS_MARKETPLACE_TOKEN!);
await client.restWrite("POST", "oapp", { data: { hn: "000000001", nextdate: "2026-08-01" } });
await client.restWrite("PUT", "oapp", { id: "123", data: { nextdate: "2026-09-01" } });
await client.restWrite("DELETE", "oapp", { id: "123" });restRequest(method, path, options?): Promise<BMSRestResponse>
Low-level escape hatch for /api/rest/* when restGet/restWrite don't fit. Transport tier — returns the raw response; you inspect MessageCode. The marketplace token is auto-appended as a query param on non-GET methods.
await client.restRequest("GET", "patient/000000001", { query: "select=hn,fname" });Server functions
callFunction(name: string, payload?: Record<string, unknown>): Promise<BMSFunctionResponse>
Invokes a HOSxP server-side function via /api/function?name=… — operations that aren't plain SQL (validation, PK generation, pricing, lookups, system variables). Transport tier — returns the raw BMSFunctionResponse; most functions put their result in Value.
const r = await client.callFunction("get_serialnumber", {
serial_name: "vn", table_name: "ovst", field_name: "vn",
});
console.log(r.Value);Server-function wrappers
Typed convenience shells over callFunction. Each validates the response and throws BMSApiError on a non-2xx body, returning a clean value:
| Method | Returns | Function |
|---|---|---|
| getSerialNumber(serial, table, field) | number | get_serialnumber — generate a unique integer PK (HOSxP tables have no AUTO_INCREMENT; call immediately before INSERT). |
| getNewGuid() | string | get_newguid |
| getNewHn() | string | get_new_hn |
| getHosVariable(name) | string | get_hosvariable — read a sys_var value (missing → ""). |
| setHosVariable(name, value) | void | set_hosvariable — create/update a sys_var (needs operator rights). |
| getServerDateTime() | { serverDateTime, serverDate } | get_server_datetime — server clock, for drift reconciliation. |
| getHospitalInfo() | { hospitalCode, hospitalName, hospitalNameEng, hospitalAddress, hospitalTel, hospitalProvince } | get_hospital_info |
| getPatientInfo(hn) | Record<string, unknown> \| null | get_patient_info |
| getPatientVisitInfo(vn) | Record<string, unknown> \| null | get_patient_visit_info |
| getPatientAge(hn, refDate?) | number | get_patient_age |
| validateHn(hn) | boolean | validate_hn |
| validateVn(vn) | boolean | validate_vn |
| validateAn(an) | boolean | validate_an |
| validateCid(cid) | boolean | validate_cid — Thai citizen-ID checksum. |
| getIcd10Name(code) | { name, thaiName } | get_icd10_name |
const hn = await client.getNewHn();
const vn = await client.getSerialNumber("vn", "ovst", "vn");
const ok = await client.validateCid("1234567890123");
const dx = await client.getIcd10Name("J45"); // { name: "Asthma", thaiName: "โรคหืด" }Local API gateway
When your code runs on the same machine as HOSxP, you can bypass the remote tunnel and talk to the local API server directly (127.0.0.1:45011), avoiding tunnel latency and bandwidth.
probeLocalApi(): Promise<boolean>
Sends a SELECT 1 to localApiUrl with the session's bearer token and a short timeout. If it answers MessageCode 200, all subsequent /api/* calls switch to the local base URL and true is returned. On any failure (offline, timeout, CORS) the tunnel is kept and false is returned. Requires a validated session.
isUsingLocalApi(): boolean
Whether calls are currently routed to the local gateway.
useRemoteApi(): void
Force subsequent calls back onto the remote tunnel URL.
await client.validateSession(sessionId);
if (await client.probeLocalApi()) {
// isUsingLocalApi() === true — calls now hit 127.0.0.1:45011
}
// later, force the tunnel again:
client.useRemoteApi();Browser note: from an HTTPS-served SPA, calling
http://127.0.0.1:45011is subject to mixed-content and CORS rules and may be blocked unless the local server sends CORS headers. From Node it is unrestricted.
Request queue
Enable with queue: true (defaults) or queue: { … } (tuned). When enabled, executeQuery is routed through an in-client queue that:
- De-duplicates identical concurrent queries — same SQL + params + connection share a single in-flight HTTP request.
- Caps concurrency at
maxConcurrent(default 3). - Retries on HTTP 429 (
BMSRateLimitError) with exponential backoff + jitter, honouring the server'sRetry-Afterwhen present.
const client = new BMSClient({
queue: { maxConcurrent: 3, maxRetryAttempts: 3, baseRetryDelayMs: 1000, maxRetryDelayMs: 30000 },
});
client.getQueueStats(); // { pending, active, completed, failed } — or null if no queue
client.clearQueue(); // reject + drop pending requests (call on disconnect)The queue wraps reads only — REST and function writes are never de-duplicated. The BMSRequestQueue class is also exported if you want to reuse the mechanism standalone.
Error handling
Every error the library throws extends Error via the abstract BMSError, so existing catch (e) / e instanceof Error code keeps working. Catch by subclass to branch:
| Class | Thrown when | Extra fields |
|---|---|---|
| BMSSessionError | Session validation failed, or a method was called before validateSession | — |
| BMSAuthError | HTTP 501 from an /api/* endpoint — session expired/invalid | endpoint |
| BMSApiError | A helper-tier call received a non-2xx MessageCode | messageCode, endpoint |
| BMSRateLimitError | HTTP 429 — you are being rate-limited | retryAfterSeconds, endpoint |
| BMSNetworkError | fetch rejected, timed out, or the body wasn't JSON | cause |
Programmer errors — invalid table/function name, missing id on PUT/DELETE, restWrite without a token, an out-of-charset listTables pattern — throw plain TypeError, because they're caller bugs, not API conditions.
import {
BMSAuthError, BMSApiError, BMSRateLimitError, BMSNetworkError,
} from "bms-session-client";
try {
const rows = await client.restGet({ table: "ovst", limit: 10 });
} catch (e) {
if (e instanceof BMSAuthError) {
// session expired → re-validate with a fresh session ID
await client.validateSession(await getFreshSessionId());
} else if (e instanceof BMSRateLimitError) {
await sleep((e.retryAfterSeconds ?? 5) * 1000); // then retry
} else if (e instanceof BMSApiError) {
console.error(`BMS ${e.messageCode} on ${e.endpoint}: ${e.message}`);
} else if (e instanceof BMSNetworkError) {
console.error("Network/parse failure:", e.cause);
} else {
throw e; // TypeError or something unexpected
}
}Types
All types are exported for import (import type { … } from "bms-session-client").
BMSSessionData
Returned by validateSession / getSession:
| Field | Type | Notes |
|---|---|---|
| bmsUrl | string | API base URL (tunnel or local gateway). |
| bearerToken | string | Bearer token attached to every call. |
| databaseType | "MariaDB" \| "PostgreSQL" | Drives dialect in listTables/describeTable. |
| databaseName | string | Connected schema/database. |
| userName, position, hospitalCode, department | string | User/hospital metadata. |
| version | string | HOSxP system version. |
| expiresInSeconds | number | Session lifetime (typically 36000 = 10h). |
BMSQueryResponse
MessageCode, Message, RequestTime?, data?: Record<string, unknown>[], field?: number[], field_name?: string[], record_count?: number.
BMSRestResponse
As above plus limit?, offset?, insert_count?, update_count?; data may be a single object (by-id read) or an array (list).
BMSFunctionResponse
MessageCode, Message?, RequestTime?, Value? (most functions), value?, plus an index signature for function-specific fields (e.g. server_datetime, hospital_code, valid).
RestGetOptions
table (required), id?, select?, filter?: string[], limit?, offset?, order?.
SqlParam / SqlParams
SqlParam = { value: string | number; value_type: SqlParamValueType }. SqlParams = Record<string, SqlParam>.
BMSQueueOptions / BMSQueueStats
Options: maxConcurrent?, maxRetryAttempts?, baseRetryDelayMs?, maxRetryDelayMs?. Stats: pending, active, completed, failed.
Other exported names
BMSDatabaseType, SqlParamValueType, BMSClientOptions, BMSSessionResponse, and the classes BMSClient, BMSRequestQueue, plus the five error classes.
HOSxP domain notes
- Identifiers:
HN= hospital number (patient),VN= visit number (OPD),AN= admission number (IPD). Many tables also carry ahos_guid. These are the usual RESTidvalues. - Common tables:
patient(patients),ovst/opd(outpatient visits),ipt(inpatient admissions),doctor,drugitems(medications),oapp(appointments). - Dates: HOSxP frequently stores Thai Buddhist-era dates (e.g.
2569-12-22= 2026-12-22). Convert on display as needed. - Field type codes (for write payloads):
1=Boolean,2=Integer,3=Float,4=DateTime,5=Time,6=UTF8Text/String,7=Blob (Base64),9=UTF8Text. - PII masking: the server pre-masks sensitive fields (CID, names, phones, addresses, emails) — masked values like
1234****890orส***are normal, not corruption. Display them as-is. - Restricted tables:
opduser,opdconfig,sys_var,user_var,user_jwtare not directly queryable. - Query limits:
/api/sqlis read-only and allows at most 20 tables per query; use single quotes for string literals.
Recipes
Node script (read-only)
import { BMSClient } from "bms-session-client";
const client = new BMSClient({ cacheBust: true });
await client.validateSession(process.env.BMS_SESSION_ID!);
const today = await client.executeQuery(
"SELECT COUNT(*) AS n FROM ovst WHERE vstdate = CURDATE()"
);
console.log("OPD visits today:", today.data?.[0]?.n);Same-host deployment (prefer the local gateway)
const client = new BMSClient({ cacheBust: true });
await client.validateSession(sessionId);
await client.probeLocalApi(); // silently upgrades to 127.0.0.1:45011 if presentHigh-throughput dashboard (queue on)
const client = new BMSClient({ queue: { maxConcurrent: 3 }, cacheBust: true });
await client.validateSession(sessionId);
// Fired in parallel from many widgets — identical queries de-dupe,
// concurrency is capped, and 429s retry automatically.
const [kpi, trend, depts] = await Promise.all([
client.executeQuery(KPI_SQL),
client.executeQuery(TREND_SQL),
client.executeQuery(DEPT_SQL),
]);Insert with a generated primary key
client.setMarketplaceToken(process.env.BMS_MARKETPLACE_TOKEN!);
const orderId = await client.getSerialNumber("refill_order_id", "refill_order", "order_id");
await client.restWrite("POST", "refill_order", {
data: { order_id: orderId, hn: "000000001" },
});Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| BMSSessionError: Session validation failed | Expired or wrong bms-session-id | Obtain a fresh session ID from HOSxP and re-validate. |
| BMSAuthError mid-session | Session expired (10h lifetime) or token no longer valid | Re-run validateSession with a new ID. |
| BMSApiError (500): Invalid Key data … | A server function is missing a required payload key | Supply the named key (the message names it). |
| TypeError: Write operations require a marketplace token | restWrite without a token | setMarketplaceToken(...) or pass marketplaceToken to the constructor. |
| TypeError: Invalid table pattern | listTables pattern has disallowed characters | Use only letters, digits, _, *, %. |
| Stale data through a tunnel/proxy | Cached responses | Construct with cacheBust: true. |
| BMSRateLimitError | HTTP 429 | Back off retryAfterSeconds, or enable queue to retry automatically. |
Versioning
Semantic versioning. 1.1.0 added the local-API gateway probe, cache-busting, BMSRateLimitError (429 handling), SQL named parameters, the opt-in request queue, detectDatabaseType(), and the server-function wrappers — all additive over 1.0.0.
License
MIT
