@fluid-app/exigo-connection-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +190 -0
- package/dist/credentials.d.mts +49 -0
- package/dist/credentials.mjs +92 -0
- package/dist/errors.d.mts +79 -0
- package/dist/errors.mjs +73 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +3 -0
- package/dist/rest.d.mts +65 -0
- package/dist/rest.mjs +231 -0
- package/dist/sql.d.mts +53 -0
- package/dist/sql.mjs +295 -0
- package/package.json +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# @fluid-app/exigo-connection-sdk
|
|
2
|
+
|
|
3
|
+
One way for Fluid droplets and Mist apps to talk to Exigo: a REST client, a
|
|
4
|
+
cached SQL pool, a credential interface and typed errors. The package owns
|
|
5
|
+
connection, auth and errors only. Business queries stay in each app.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Public on npmjs:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @fluid-app/exigo-connection-sdk
|
|
13
|
+
# Only for apps that query the SQL replica:
|
|
14
|
+
pnpm add mssql
|
|
15
|
+
pnpm add -D @types/mssql
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| Import | Provides | Needs `mssql` |
|
|
19
|
+
| -------------------------------------- | -------------------------------------------------- | ------------- |
|
|
20
|
+
| `@fluid-app/exigo-connection-sdk` | `defineExigoCredentials`, credential types, errors | no |
|
|
21
|
+
| `@fluid-app/exigo-connection-sdk/rest` | `createExigoRestClient`, errors | no |
|
|
22
|
+
| `@fluid-app/exigo-connection-sdk/sql` | `getExigoPool`, `closeExigoPool`, errors | yes |
|
|
23
|
+
|
|
24
|
+
Runs on Node 20+: Cloud Run, and Vercel's Node runtime. Not on Edge.
|
|
25
|
+
|
|
26
|
+
## 1. Credentials, once per app
|
|
27
|
+
|
|
28
|
+
The package reads no environment variables and stores nothing. The app says
|
|
29
|
+
where credentials come from; a missing field throws `ExigoCredentialsError`
|
|
30
|
+
naming it.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// src/lib/exigo.ts — a multi-tenant droplet
|
|
34
|
+
import { defineExigoCredentials } from "@fluid-app/exigo-connection-sdk";
|
|
35
|
+
|
|
36
|
+
export const resolveExigoCredentials = defineExigoCredentials(
|
|
37
|
+
async (companyId: string) => {
|
|
38
|
+
const c = await prisma.credentials.findUniqueOrThrow({
|
|
39
|
+
where: { companyId },
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
rest: {
|
|
43
|
+
baseUrl: c.exigoApiBaseUrl,
|
|
44
|
+
username: c.exigoApiUser,
|
|
45
|
+
password: c.exigoApiPassword,
|
|
46
|
+
company: c.exigoCompanyName,
|
|
47
|
+
},
|
|
48
|
+
sql: {
|
|
49
|
+
host: c.exigoDbHost,
|
|
50
|
+
database: c.exigoDbName,
|
|
51
|
+
username: c.exigoDbUsername,
|
|
52
|
+
password: c.exigoDbPassword,
|
|
53
|
+
// Required, no default: Exigo's replicas are Azure SQL, whose
|
|
54
|
+
// certificate does not match the host you connect to, so this must
|
|
55
|
+
// be true. It skips server certificate verification.
|
|
56
|
+
trustServerCertificate: true,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A single-tenant Mist app returns its env vars from the same function instead.
|
|
64
|
+
The resolver runs on every call, so cache in the app if the lookup is costly.
|
|
65
|
+
|
|
66
|
+
## 2. REST
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import {
|
|
70
|
+
createExigoRestClient,
|
|
71
|
+
ExigoAuthError,
|
|
72
|
+
ExigoTimeoutError,
|
|
73
|
+
} from "@fluid-app/exigo-connection-sdk/rest";
|
|
74
|
+
|
|
75
|
+
const { rest } = await resolveExigoCredentials(company.id);
|
|
76
|
+
const exigo = createExigoRestClient(rest);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const account = await exigo.get<PointAccount>(
|
|
80
|
+
"/customers/{id}/pointaccounts/{accountId}",
|
|
81
|
+
{ params: { id: customerId, accountId: pointAccountId } },
|
|
82
|
+
);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof ExigoAuthError) {
|
|
85
|
+
/* wrong credentials: tell the admin */
|
|
86
|
+
}
|
|
87
|
+
if (error instanceof ExigoTimeoutError) {
|
|
88
|
+
/* retry, or fail the callback fast */
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The client:
|
|
95
|
+
|
|
96
|
+
- adds `/3.0` to the base URL, and does not double it when the stored URL
|
|
97
|
+
already has it; a base URL ending in another version, such as `/2.0`, throws
|
|
98
|
+
`ExigoCredentialsError` rather than pinning the client to it;
|
|
99
|
+
- requires an `https` base URL, since every request carries the password
|
|
100
|
+
(`http` only for `localhost`, for a local stub);
|
|
101
|
+
- sends Basic auth as `username@company`;
|
|
102
|
+
- times out after 15 s by default (`timeoutMs` on the client or per call, at
|
|
103
|
+
most 2,147,483,647 ms, the most `setTimeout` can hold);
|
|
104
|
+
- resolves to the parsed JSON, or `undefined` for an empty body;
|
|
105
|
+
- throws `ExigoApiError` for a non-2xx status, and for a 200 whose body is an
|
|
106
|
+
HTML page or not JSON, rather than handing the page back as data; the
|
|
107
|
+
password and Basic token are redacted from its message and `bodySnippet`;
|
|
108
|
+
- refuses a path that is an absolute URL, so the credentials only ever go to
|
|
109
|
+
the configured host, and a path that starts with an API version (`/3.0/...`),
|
|
110
|
+
since the client adds it;
|
|
111
|
+
- refuses a path param that is empty, `.` or `..`, which would otherwise
|
|
112
|
+
resolve to a different endpoint than the path names.
|
|
113
|
+
|
|
114
|
+
`get`, `delete` take `params`, `query`, `headers`, `timeoutMs` and `signal`;
|
|
115
|
+
`post`, `put`, `patch` also take a `body`, sent as JSON.
|
|
116
|
+
|
|
117
|
+
## 3. SQL
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { getExigoPool } from "@fluid-app/exigo-connection-sdk/sql";
|
|
121
|
+
|
|
122
|
+
const { sql: creds } = await resolveExigoCredentials(company.id);
|
|
123
|
+
const pool = await getExigoPool(creds);
|
|
124
|
+
|
|
125
|
+
const { recordset } = await pool
|
|
126
|
+
.request()
|
|
127
|
+
.input("email", email)
|
|
128
|
+
.query("SELECT CustomerID FROM Customers WHERE Email = @email");
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`getExigoPool` returns an `mssql` `ConnectionPool`, cached per host, port,
|
|
132
|
+
database and username, and within that per password. Repeated calls reuse
|
|
133
|
+
it, concurrent first calls share one connect, and a failed connect is not
|
|
134
|
+
cached. A rotated password opens its own pool without closing the previous
|
|
135
|
+
one, so requests in flight across a rotation keep working and a rollback
|
|
136
|
+
connects at once; the previous pool is closed once nothing has asked for it
|
|
137
|
+
in five minutes, and a database's current pool once nothing has asked for it
|
|
138
|
+
in 30. Pools are small (4 connections, closed after 10 s idle) because every running instance opens its own. Pass
|
|
139
|
+
`{ max, idleTimeoutMs, connectTimeoutMs, requestTimeoutMs }` as the second
|
|
140
|
+
argument to change that; options apply when the pool opens.
|
|
141
|
+
|
|
142
|
+
For typed parameters, import them from `mssql` itself
|
|
143
|
+
(`import sql from "mssql"; request.input("id", sql.Int, id)`).
|
|
144
|
+
|
|
145
|
+
Connect failures arrive as `ExigoAuthError`, `ExigoTimeoutError` or
|
|
146
|
+
`ExigoConnectionError`, with messages that say what to check. Errors from the
|
|
147
|
+
app's own queries are left alone; pass one through `translateExigoSqlError` to
|
|
148
|
+
map a timeout or a dropped connection the same way. A query error (`RequestError`)
|
|
149
|
+
comes back unchanged unless its code is a transport one (`ETIMEOUT`,
|
|
150
|
+
`ECONNCLOSED`, `ESOCKET`), so a bad table name is never reported as a network
|
|
151
|
+
failure.
|
|
152
|
+
|
|
153
|
+
Close pools on shutdown:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { closeAllExigoPools } from "@fluid-app/exigo-connection-sdk/sql";
|
|
157
|
+
|
|
158
|
+
process.once("SIGTERM", () => void closeAllExigoPools());
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
In Next, keep `mssql` out of the bundle and off Edge:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// next.config.ts
|
|
165
|
+
export default { serverExternalPackages: ["mssql"] };
|
|
166
|
+
|
|
167
|
+
// every route that queries
|
|
168
|
+
export const runtime = "nodejs";
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Errors
|
|
172
|
+
|
|
173
|
+
Every error extends `ExigoError`. None of them carries a password or an
|
|
174
|
+
Authorization header.
|
|
175
|
+
|
|
176
|
+
| Error | When |
|
|
177
|
+
| ----------------------- | ----------------------------------------------------- |
|
|
178
|
+
| `ExigoCredentialsError` | a field is missing or invalid (`fields` names them) |
|
|
179
|
+
| `ExigoAuthError` | REST 401/403, or a SQL login failure |
|
|
180
|
+
| `ExigoTimeoutError` | a REST call, SQL connect or SQL query ran too long |
|
|
181
|
+
| `ExigoConnectionError` | DNS, refused connection, TLS, network |
|
|
182
|
+
| `ExigoApiError` | REST answered with an error or a body that isn't JSON |
|
|
183
|
+
|
|
184
|
+
`source` is `"rest"` or `"sql"` on the auth, timeout and connection errors.
|
|
185
|
+
|
|
186
|
+
## Releasing
|
|
187
|
+
|
|
188
|
+
Listed in `.github/workflows/frontend-publish-rep-packages.yml`: a merge to
|
|
189
|
+
`main` that changes this package publishes it to npm, bumping the patch
|
|
190
|
+
version when the current one is already published.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region src/credentials.d.ts
|
|
2
|
+
interface ExigoRestCredentials {
|
|
3
|
+
/**
|
|
4
|
+
* The API host, such as `https://acme-api.exigo.com`. A trailing `/3.0` is
|
|
5
|
+
* accepted and not doubled; without one the client adds it.
|
|
6
|
+
*/
|
|
7
|
+
readonly baseUrl: string;
|
|
8
|
+
readonly username: string;
|
|
9
|
+
readonly password: string;
|
|
10
|
+
/** The Exigo company name; Basic auth sends `username@company`. */
|
|
11
|
+
readonly company: string;
|
|
12
|
+
}
|
|
13
|
+
interface ExigoSqlCredentials {
|
|
14
|
+
readonly host: string;
|
|
15
|
+
readonly database: string;
|
|
16
|
+
readonly username: string;
|
|
17
|
+
readonly password: string;
|
|
18
|
+
/** Defaults to 1433. */
|
|
19
|
+
readonly port?: number;
|
|
20
|
+
/** Defaults to true. */
|
|
21
|
+
readonly encrypt?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Required, with no default: whether to accept the server's certificate
|
|
24
|
+
* without verifying it. Exigo's replicas are Azure SQL, whose certificate
|
|
25
|
+
* does not match the host apps connect to, so they need `true`; that skips
|
|
26
|
+
* server identity checks, so each app makes the decision explicitly.
|
|
27
|
+
*/
|
|
28
|
+
readonly trustServerCertificate: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** What a resolver returns. An app includes the interfaces it uses. */
|
|
31
|
+
interface ExigoCredentials {
|
|
32
|
+
readonly rest?: ExigoRestCredentials;
|
|
33
|
+
readonly sql?: ExigoSqlCredentials;
|
|
34
|
+
}
|
|
35
|
+
type ExigoCredentialResolver<Key, Credentials extends ExigoCredentials> = (key: Key) => Credentials | null | undefined | Promise<Credentials | null | undefined>;
|
|
36
|
+
/** Throws `ExigoCredentialsError` naming every missing REST field. */
|
|
37
|
+
declare function assertExigoRestCredentials(value: unknown): asserts value is ExigoRestCredentials;
|
|
38
|
+
/** Throws `ExigoCredentialsError` naming every missing or invalid SQL field. */
|
|
39
|
+
declare function assertExigoSqlCredentials(value: unknown): asserts value is ExigoSqlCredentials;
|
|
40
|
+
/**
|
|
41
|
+
* Wraps an app's credential lookup so every caller gets validated credentials.
|
|
42
|
+
*
|
|
43
|
+
* The resolver runs on every call; cache in the app if the lookup is costly.
|
|
44
|
+
* Each interface the result includes is checked in full, and a result with
|
|
45
|
+
* neither, or none at all, throws.
|
|
46
|
+
*/
|
|
47
|
+
declare function defineExigoCredentials<Key = string, Credentials extends ExigoCredentials = ExigoCredentials>(resolver: ExigoCredentialResolver<Key, Credentials>): (key: Key) => Promise<Credentials>;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { ExigoCredentialResolver, ExigoCredentials, ExigoRestCredentials, ExigoSqlCredentials, assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ExigoCredentialsError } from "./errors.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/credentials.ts
|
|
4
|
+
/**
|
|
5
|
+
* The credential interface.
|
|
6
|
+
*
|
|
7
|
+
* The package never reads environment variables and stores nothing. Each app
|
|
8
|
+
* supplies credentials, or a resolver that finds them: env in a single-tenant
|
|
9
|
+
* Mist app, a table in a multi-tenant droplet. There is deliberately no
|
|
10
|
+
* fallback — the `RAIN_*` fallback that pointed a droplet at another client's
|
|
11
|
+
* Exigo came from exactly that — so a missing field throws.
|
|
12
|
+
*/
|
|
13
|
+
/** Present and not blank. Values from a database arrive as `null` when unset. */
|
|
14
|
+
const requiredString = z.string().refine((value) => value.trim() !== "");
|
|
15
|
+
const restCredentialsSchema = z.object({
|
|
16
|
+
baseUrl: requiredString,
|
|
17
|
+
username: requiredString,
|
|
18
|
+
password: requiredString,
|
|
19
|
+
company: requiredString
|
|
20
|
+
});
|
|
21
|
+
const sqlCredentialsSchema = z.object({
|
|
22
|
+
host: requiredString,
|
|
23
|
+
database: requiredString,
|
|
24
|
+
username: requiredString,
|
|
25
|
+
password: requiredString,
|
|
26
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
27
|
+
encrypt: z.boolean().optional(),
|
|
28
|
+
trustServerCertificate: z.boolean()
|
|
29
|
+
});
|
|
30
|
+
const REQUIRED_FIELDS = {
|
|
31
|
+
rest: Object.keys(restCredentialsSchema.shape),
|
|
32
|
+
sql: [
|
|
33
|
+
"host",
|
|
34
|
+
"database",
|
|
35
|
+
"username",
|
|
36
|
+
"password",
|
|
37
|
+
"trustServerCertificate"
|
|
38
|
+
]
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Validates one section and throws `ExigoCredentialsError` naming every field
|
|
42
|
+
* at fault. Zod's own messages are never used: they can quote the value, and
|
|
43
|
+
* these values are secrets.
|
|
44
|
+
*/
|
|
45
|
+
function assertSection(section, schema, value) {
|
|
46
|
+
const result = schema.safeParse(value);
|
|
47
|
+
if (result.success) return;
|
|
48
|
+
const missing = [];
|
|
49
|
+
const invalid = [];
|
|
50
|
+
const required = REQUIRED_FIELDS[section];
|
|
51
|
+
for (const issue of result.error.issues) {
|
|
52
|
+
const [field] = issue.path;
|
|
53
|
+
if (field === void 0) {
|
|
54
|
+
missing.push(...required.map((name) => `${section}.${name}`));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const name = `${section}.${String(field)}`;
|
|
58
|
+
const list = required.includes(String(field)) ? missing : invalid;
|
|
59
|
+
if (!list.includes(name)) list.push(name);
|
|
60
|
+
}
|
|
61
|
+
const label = `Exigo ${section.toUpperCase()} credentials`;
|
|
62
|
+
const parts = [missing.length > 0 ? `are missing ${missing.join(", ")}` : null, invalid.length > 0 ? `have an invalid ${invalid.join(", ")}` : null].filter((part) => part !== null);
|
|
63
|
+
const portHint = invalid.includes("sql.port") ? "; sql.port must be an integer from 1 to 65535" : "";
|
|
64
|
+
const trustHint = missing.includes("sql.trustServerCertificate") ? "; set sql.trustServerCertificate explicitly (Exigo's Azure SQL replicas need true, which skips certificate verification)" : "";
|
|
65
|
+
throw new ExigoCredentialsError(`${label} ${parts.join(" and ")}${portHint}${trustHint}.`, [...missing, ...invalid]);
|
|
66
|
+
}
|
|
67
|
+
/** Throws `ExigoCredentialsError` naming every missing REST field. */
|
|
68
|
+
function assertExigoRestCredentials(value) {
|
|
69
|
+
assertSection("rest", restCredentialsSchema, value);
|
|
70
|
+
}
|
|
71
|
+
/** Throws `ExigoCredentialsError` naming every missing or invalid SQL field. */
|
|
72
|
+
function assertExigoSqlCredentials(value) {
|
|
73
|
+
assertSection("sql", sqlCredentialsSchema, value);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Wraps an app's credential lookup so every caller gets validated credentials.
|
|
77
|
+
*
|
|
78
|
+
* The resolver runs on every call; cache in the app if the lookup is costly.
|
|
79
|
+
* Each interface the result includes is checked in full, and a result with
|
|
80
|
+
* neither, or none at all, throws.
|
|
81
|
+
*/
|
|
82
|
+
function defineExigoCredentials(resolver) {
|
|
83
|
+
return async (key) => {
|
|
84
|
+
const credentials = await resolver(key);
|
|
85
|
+
if (!credentials || !credentials.rest && !credentials.sql) throw new ExigoCredentialsError("The Exigo credential resolver returned no REST or SQL credentials.");
|
|
86
|
+
if (credentials.rest) assertExigoRestCredentials(credentials.rest);
|
|
87
|
+
if (credentials.sql) assertExigoSqlCredentials(credentials.sql);
|
|
88
|
+
return credentials;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
export { assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/errors.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Typed errors for everything this package does.
|
|
4
|
+
*
|
|
5
|
+
* Every error is an `ExigoError`, so an app can catch the package's failures
|
|
6
|
+
* as one family and branch on the subclass when the remedy differs: an
|
|
7
|
+
* `ExigoAuthError` is for the admin who entered the credentials, an
|
|
8
|
+
* `ExigoTimeoutError` is worth retrying, an `ExigoConnectionError` usually
|
|
9
|
+
* means a wrong host or an IP allowlist.
|
|
10
|
+
*
|
|
11
|
+
* Messages never carry a password or an Authorization header.
|
|
12
|
+
*/
|
|
13
|
+
/** Which of Exigo's two interfaces an error came from. */
|
|
14
|
+
type ExigoSource = "rest" | "sql";
|
|
15
|
+
interface ExigoErrorOptions {
|
|
16
|
+
readonly cause?: unknown;
|
|
17
|
+
}
|
|
18
|
+
declare class ExigoError extends Error {
|
|
19
|
+
name: string;
|
|
20
|
+
constructor(message: string, options?: ExigoErrorOptions);
|
|
21
|
+
}
|
|
22
|
+
/** Credentials were missing a field or held a value that cannot work. */
|
|
23
|
+
declare class ExigoCredentialsError extends ExigoError {
|
|
24
|
+
name: string;
|
|
25
|
+
/** Dotted names of the fields at fault, such as `rest.password`. */
|
|
26
|
+
readonly fields: readonly string[];
|
|
27
|
+
constructor(message: string, fields?: readonly string[]);
|
|
28
|
+
}
|
|
29
|
+
/** Exigo refused the credentials: REST 401/403, or a SQL login failure. */
|
|
30
|
+
declare class ExigoAuthError extends ExigoError {
|
|
31
|
+
name: string;
|
|
32
|
+
readonly source: ExigoSource;
|
|
33
|
+
/** The HTTP status, for REST. */
|
|
34
|
+
readonly status: number | undefined;
|
|
35
|
+
constructor(message: string, source: ExigoSource, options?: ExigoErrorOptions & {
|
|
36
|
+
readonly status?: number;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** A REST call or a SQL connect or query ran past its timeout. */
|
|
40
|
+
declare class ExigoTimeoutError extends ExigoError {
|
|
41
|
+
name: string;
|
|
42
|
+
readonly source: ExigoSource;
|
|
43
|
+
/** The limit that was exceeded, when known. */
|
|
44
|
+
readonly timeoutMs: number | undefined;
|
|
45
|
+
constructor(message: string, source: ExigoSource, options?: ExigoErrorOptions & {
|
|
46
|
+
readonly timeoutMs?: number;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Exigo could not be reached: DNS, refused connection, TLS, network. */
|
|
50
|
+
declare class ExigoConnectionError extends ExigoError {
|
|
51
|
+
name: string;
|
|
52
|
+
readonly source: ExigoSource;
|
|
53
|
+
constructor(message: string, source: ExigoSource, options?: ExigoErrorOptions);
|
|
54
|
+
}
|
|
55
|
+
interface ExigoApiErrorDetails {
|
|
56
|
+
readonly status: number;
|
|
57
|
+
readonly method: string;
|
|
58
|
+
/** The request path below the API version, without the query string. */
|
|
59
|
+
readonly path: string;
|
|
60
|
+
readonly contentType: string | null;
|
|
61
|
+
/** The start of the response body, for diagnostics. */
|
|
62
|
+
readonly bodySnippet: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The REST API answered, but not with a usable result: a non-2xx status other
|
|
66
|
+
* than an auth refusal, or a success whose body is not JSON (Exigo serves an
|
|
67
|
+
* HTML page for a wrong URL with a 200).
|
|
68
|
+
*/
|
|
69
|
+
declare class ExigoApiError extends ExigoError {
|
|
70
|
+
name: string;
|
|
71
|
+
readonly status: number;
|
|
72
|
+
readonly method: string;
|
|
73
|
+
readonly path: string;
|
|
74
|
+
readonly contentType: string | null;
|
|
75
|
+
readonly bodySnippet: string;
|
|
76
|
+
constructor(message: string, details: ExigoApiErrorDetails);
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoSource, ExigoTimeoutError };
|
package/dist/errors.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
var ExigoError = class extends Error {
|
|
3
|
+
name = "ExigoError";
|
|
4
|
+
constructor(message, options) {
|
|
5
|
+
super(message, options?.cause === void 0 ? void 0 : options);
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
/** Credentials were missing a field or held a value that cannot work. */
|
|
9
|
+
var ExigoCredentialsError = class extends ExigoError {
|
|
10
|
+
name = "ExigoCredentialsError";
|
|
11
|
+
/** Dotted names of the fields at fault, such as `rest.password`. */
|
|
12
|
+
fields;
|
|
13
|
+
constructor(message, fields = []) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.fields = fields;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** Exigo refused the credentials: REST 401/403, or a SQL login failure. */
|
|
19
|
+
var ExigoAuthError = class extends ExigoError {
|
|
20
|
+
name = "ExigoAuthError";
|
|
21
|
+
source;
|
|
22
|
+
/** The HTTP status, for REST. */
|
|
23
|
+
status;
|
|
24
|
+
constructor(message, source, options) {
|
|
25
|
+
super(message, options);
|
|
26
|
+
this.source = source;
|
|
27
|
+
this.status = options?.status;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/** A REST call or a SQL connect or query ran past its timeout. */
|
|
31
|
+
var ExigoTimeoutError = class extends ExigoError {
|
|
32
|
+
name = "ExigoTimeoutError";
|
|
33
|
+
source;
|
|
34
|
+
/** The limit that was exceeded, when known. */
|
|
35
|
+
timeoutMs;
|
|
36
|
+
constructor(message, source, options) {
|
|
37
|
+
super(message, options);
|
|
38
|
+
this.source = source;
|
|
39
|
+
this.timeoutMs = options?.timeoutMs;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/** Exigo could not be reached: DNS, refused connection, TLS, network. */
|
|
43
|
+
var ExigoConnectionError = class extends ExigoError {
|
|
44
|
+
name = "ExigoConnectionError";
|
|
45
|
+
source;
|
|
46
|
+
constructor(message, source, options) {
|
|
47
|
+
super(message, options);
|
|
48
|
+
this.source = source;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The REST API answered, but not with a usable result: a non-2xx status other
|
|
53
|
+
* than an auth refusal, or a success whose body is not JSON (Exigo serves an
|
|
54
|
+
* HTML page for a wrong URL with a 200).
|
|
55
|
+
*/
|
|
56
|
+
var ExigoApiError = class extends ExigoError {
|
|
57
|
+
name = "ExigoApiError";
|
|
58
|
+
status;
|
|
59
|
+
method;
|
|
60
|
+
path;
|
|
61
|
+
contentType;
|
|
62
|
+
bodySnippet;
|
|
63
|
+
constructor(message, details) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.status = details.status;
|
|
66
|
+
this.method = details.method;
|
|
67
|
+
this.path = details.path;
|
|
68
|
+
this.contentType = details.contentType;
|
|
69
|
+
this.bodySnippet = details.bodySnippet;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
export { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { ExigoCredentialResolver, ExigoCredentials, ExigoRestCredentials, ExigoSqlCredentials, assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials } from "./credentials.mjs";
|
|
2
|
+
import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
|
|
3
|
+
export { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, type ExigoCredentialResolver, type ExigoCredentials, ExigoCredentialsError, ExigoError, ExigoErrorOptions, type ExigoRestCredentials, ExigoSource, type ExigoSqlCredentials, ExigoTimeoutError, assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError } from "./errors.mjs";
|
|
2
|
+
import { assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials } from "./credentials.mjs";
|
|
3
|
+
export { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError, assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials };
|
package/dist/rest.d.mts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ExigoRestCredentials } from "./credentials.mjs";
|
|
2
|
+
import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/rest.d.ts
|
|
5
|
+
declare const EXIGO_API_VERSION = "3.0";
|
|
6
|
+
declare const DEFAULT_EXIGO_TIMEOUT_MS = 15e3;
|
|
7
|
+
type ExigoHttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
8
|
+
type ExigoQueryValue = string | number | boolean;
|
|
9
|
+
interface ExigoRequestOptions {
|
|
10
|
+
/** Values for `{name}` placeholders in the path, URL-encoded. */
|
|
11
|
+
readonly params?: Readonly<Record<string, string | number>>;
|
|
12
|
+
/** Query string values; `null` and `undefined` are left out. */
|
|
13
|
+
readonly query?: Readonly<Record<string, ExigoQueryValue | readonly ExigoQueryValue[] | null | undefined>>;
|
|
14
|
+
/** Sent as JSON. */
|
|
15
|
+
readonly body?: unknown;
|
|
16
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
17
|
+
/** Overrides the client's timeout for this call. */
|
|
18
|
+
readonly timeoutMs?: number;
|
|
19
|
+
/** Aborting it rejects with the signal's reason, not an Exigo error. */
|
|
20
|
+
readonly signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
type ExigoMethodOptions = Omit<ExigoRequestOptions, "body">;
|
|
23
|
+
type ExigoBodyMethodOptions = ExigoRequestOptions;
|
|
24
|
+
interface ExigoRestClientOptions {
|
|
25
|
+
/** Per-request timeout. Defaults to {@link DEFAULT_EXIGO_TIMEOUT_MS}. */
|
|
26
|
+
readonly timeoutMs?: number;
|
|
27
|
+
/** For tests and instrumentation. Defaults to the global `fetch`. */
|
|
28
|
+
readonly fetch?: typeof fetch;
|
|
29
|
+
}
|
|
30
|
+
interface ExigoRestClient {
|
|
31
|
+
/** The normalized base URL, including the API version. */
|
|
32
|
+
readonly baseUrl: string;
|
|
33
|
+
/**
|
|
34
|
+
* Resolves to the parsed JSON body, or `undefined` for an empty one. The
|
|
35
|
+
* type parameter is an assertion about Exigo's response, not a check.
|
|
36
|
+
*/
|
|
37
|
+
request<T = unknown>(method: ExigoHttpMethod, path: string, options?: ExigoRequestOptions): Promise<T>;
|
|
38
|
+
get<T = unknown>(path: string, options?: ExigoMethodOptions): Promise<T>;
|
|
39
|
+
post<T = unknown>(path: string, options?: ExigoBodyMethodOptions): Promise<T>;
|
|
40
|
+
put<T = unknown>(path: string, options?: ExigoBodyMethodOptions): Promise<T>;
|
|
41
|
+
patch<T = unknown>(path: string, options?: ExigoBodyMethodOptions): Promise<T>;
|
|
42
|
+
delete<T = unknown>(path: string, options?: ExigoMethodOptions): Promise<T>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `https://acme-api.exigo.com`, `…/3.0` and `…/3.0/` all become
|
|
46
|
+
* `https://acme-api.exigo.com/3.0`. A base URL ending in any other version
|
|
47
|
+
* segment is refused rather than kept, so no stored URL can pin a client to
|
|
48
|
+
* an API version it was not written for. Query strings and fragments are
|
|
49
|
+
* dropped.
|
|
50
|
+
*
|
|
51
|
+
* Requires HTTPS, since every request carries the password in its
|
|
52
|
+
* Authorization header; plain HTTP is accepted only for a loopback host, such
|
|
53
|
+
* as a local stub.
|
|
54
|
+
*/
|
|
55
|
+
declare function normalizeExigoBaseUrl(baseUrl: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Creates a client for one set of REST credentials.
|
|
58
|
+
*
|
|
59
|
+
* Accepts `undefined` so a resolver's optional `rest` section can be passed
|
|
60
|
+
* straight in: missing credentials throw `ExigoCredentialsError` here rather
|
|
61
|
+
* than failing later as an unauthenticated request.
|
|
62
|
+
*/
|
|
63
|
+
declare function createExigoRestClient(credentials: ExigoRestCredentials | null | undefined, options?: ExigoRestClientOptions): ExigoRestClient;
|
|
64
|
+
//#endregion
|
|
65
|
+
export { DEFAULT_EXIGO_TIMEOUT_MS, EXIGO_API_VERSION, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoBodyMethodOptions, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoHttpMethod, ExigoMethodOptions, ExigoQueryValue, ExigoRequestOptions, ExigoRestClient, ExigoRestClientOptions, type ExigoRestCredentials, ExigoSource, ExigoTimeoutError, createExigoRestClient, normalizeExigoBaseUrl };
|
package/dist/rest.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError } from "./errors.mjs";
|
|
2
|
+
import { assertExigoRestCredentials } from "./credentials.mjs";
|
|
3
|
+
//#region src/rest.ts
|
|
4
|
+
/**
|
|
5
|
+
* `@fluid-app/exigo-connection-sdk/rest`: one way to call Exigo's REST API.
|
|
6
|
+
*
|
|
7
|
+
* The client owns what every droplet used to rebuild: the `/3.0` version
|
|
8
|
+
* segment (a URL without it answered 200 with no data, #846), Basic auth as
|
|
9
|
+
* `username@company`, a default timeout, and reading Exigo's responses without
|
|
10
|
+
* mistaking its HTML error pages for data (#751). Endpoints and payloads stay
|
|
11
|
+
* in each app.
|
|
12
|
+
*
|
|
13
|
+
* Uses the global `fetch`, so it runs on Node 20+ (Cloud Run, Vercel's Node
|
|
14
|
+
* runtime) without Node-only APIs.
|
|
15
|
+
*/
|
|
16
|
+
const EXIGO_API_VERSION = "3.0";
|
|
17
|
+
const DEFAULT_EXIGO_TIMEOUT_MS = 15e3;
|
|
18
|
+
const BODY_SNIPPET_LENGTH = 500;
|
|
19
|
+
const VERSION_SEGMENT = /^\d+\.\d+$/;
|
|
20
|
+
const LOOPBACK_HOSTS = new Set([
|
|
21
|
+
"localhost",
|
|
22
|
+
"127.0.0.1",
|
|
23
|
+
"[::1]"
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* `https://acme-api.exigo.com`, `…/3.0` and `…/3.0/` all become
|
|
27
|
+
* `https://acme-api.exigo.com/3.0`. A base URL ending in any other version
|
|
28
|
+
* segment is refused rather than kept, so no stored URL can pin a client to
|
|
29
|
+
* an API version it was not written for. Query strings and fragments are
|
|
30
|
+
* dropped.
|
|
31
|
+
*
|
|
32
|
+
* Requires HTTPS, since every request carries the password in its
|
|
33
|
+
* Authorization header; plain HTTP is accepted only for a loopback host, such
|
|
34
|
+
* as a local stub.
|
|
35
|
+
*/
|
|
36
|
+
function normalizeExigoBaseUrl(baseUrl) {
|
|
37
|
+
let url;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(baseUrl.trim());
|
|
40
|
+
} catch {
|
|
41
|
+
throw new ExigoCredentialsError("Exigo rest.baseUrl is not a valid URL.", ["rest.baseUrl"]);
|
|
42
|
+
}
|
|
43
|
+
const loopbackHttp = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
44
|
+
if (url.protocol !== "https:" && !loopbackHttp) throw new ExigoCredentialsError("Exigo rest.baseUrl must be an https URL; requests carry the password.", ["rest.baseUrl"]);
|
|
45
|
+
if (url.username || url.password) throw new ExigoCredentialsError("Exigo rest.baseUrl must not carry credentials; pass username and password instead.", ["rest.baseUrl"]);
|
|
46
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
47
|
+
const last = segments.at(-1);
|
|
48
|
+
if (last !== void 0 && VERSION_SEGMENT.test(last)) {
|
|
49
|
+
if (last !== "3.0") throw new ExigoCredentialsError(`Exigo rest.baseUrl ends in /${last}; this client speaks the 3.0 API. Store the host alone or with /3.0.`, ["rest.baseUrl"]);
|
|
50
|
+
} else segments.push("3.0");
|
|
51
|
+
return `${url.origin}/${segments.join("/")}`;
|
|
52
|
+
}
|
|
53
|
+
/** Latin-1-safe Base64 of UTF-8 text, without Node's Buffer. */
|
|
54
|
+
function base64(text) {
|
|
55
|
+
let binary = "";
|
|
56
|
+
for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
|
|
57
|
+
return btoa(binary);
|
|
58
|
+
}
|
|
59
|
+
function authorizationHeader(credentials) {
|
|
60
|
+
const suffix = `@${credentials.company}`;
|
|
61
|
+
return `Basic ${base64(`${credentials.username.endsWith(suffix) ? credentials.username : `${credentials.username}${suffix}`}:${credentials.password}`)}`;
|
|
62
|
+
}
|
|
63
|
+
const ABSOLUTE_URL = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
|
|
64
|
+
const LEADING_VERSION_SEGMENT = /^\/?\d+\.\d+(?:\/|$)/;
|
|
65
|
+
function expandPath(path, params) {
|
|
66
|
+
if (ABSOLUTE_URL.test(path)) throw new TypeError(`Exigo request path must be relative to the base URL, got "${path}".`);
|
|
67
|
+
if (LEADING_VERSION_SEGMENT.test(path)) throw new TypeError(`Exigo request path must not start with an API version; the client adds /3.0. Got "${path}".`);
|
|
68
|
+
const expanded = path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
69
|
+
const value = params?.[name];
|
|
70
|
+
if (value === void 0) throw new TypeError(`Missing path parameter "${name}" for ${path}.`);
|
|
71
|
+
const text = String(value);
|
|
72
|
+
if (text === "" || text === "." || text === "..") throw new TypeError(`Path parameter "${name}" is empty or a dot segment for ${path}.`);
|
|
73
|
+
return encodeURIComponent(text);
|
|
74
|
+
});
|
|
75
|
+
return expanded.startsWith("/") ? expanded : `/${expanded}`;
|
|
76
|
+
}
|
|
77
|
+
function appendQuery(url, query) {
|
|
78
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
79
|
+
if (value === null || value === void 0) continue;
|
|
80
|
+
const values = Array.isArray(value) ? value : [value];
|
|
81
|
+
for (const item of values) url.searchParams.append(key, String(item));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const REDACTED = "[redacted]";
|
|
85
|
+
/**
|
|
86
|
+
* The start of a response body for diagnostics, with the credentials taken
|
|
87
|
+
* out first: an Exigo error body can echo the request, and the snippet ends up
|
|
88
|
+
* in the error's message and in whatever logs it.
|
|
89
|
+
*/
|
|
90
|
+
function redact(text, secrets) {
|
|
91
|
+
let redacted = text;
|
|
92
|
+
for (const secret of secrets) if (secret.length > 0) redacted = redacted.split(secret).join(REDACTED);
|
|
93
|
+
return redacted;
|
|
94
|
+
}
|
|
95
|
+
function snippet(text, secrets) {
|
|
96
|
+
const collapsed = redact(text, secrets).replace(/\s+/g, " ").trim();
|
|
97
|
+
return collapsed.length > BODY_SNIPPET_LENGTH ? `${collapsed.slice(0, BODY_SNIPPET_LENGTH)}…` : collapsed;
|
|
98
|
+
}
|
|
99
|
+
function looksLikeHtml(contentType, text) {
|
|
100
|
+
return /\btext\/html\b/i.test(contentType ?? "") || /^\s*<(?:!doctype\s+html|html)[\s>]/i.test(text);
|
|
101
|
+
}
|
|
102
|
+
function parseJson(text) {
|
|
103
|
+
try {
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
value: JSON.parse(text)
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
return { ok: false };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Exigo's error bodies vary by endpoint; take the first message they carry. */
|
|
113
|
+
function errorMessageFrom(body) {
|
|
114
|
+
if (body === null || typeof body !== "object") return null;
|
|
115
|
+
const record = body;
|
|
116
|
+
for (const key of [
|
|
117
|
+
"message",
|
|
118
|
+
"Message",
|
|
119
|
+
"error_description",
|
|
120
|
+
"error",
|
|
121
|
+
"title"
|
|
122
|
+
]) {
|
|
123
|
+
const value = record[key];
|
|
124
|
+
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
function describeNetworkFailure(error) {
|
|
129
|
+
const cause = error instanceof Error && error.cause instanceof Error ? error.cause : error;
|
|
130
|
+
const code = cause !== null && typeof cause === "object" ? cause.code : void 0;
|
|
131
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
132
|
+
return typeof code === "string" && !message.includes(code) ? `${code}: ${message}` : message;
|
|
133
|
+
}
|
|
134
|
+
const MAX_TIMEOUT_MS = 2147483647;
|
|
135
|
+
function validTimeout(timeoutMs) {
|
|
136
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) throw new TypeError(`Exigo timeoutMs must be a positive number up to ${MAX_TIMEOUT_MS}, got ${timeoutMs}.`);
|
|
137
|
+
return timeoutMs;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Creates a client for one set of REST credentials.
|
|
141
|
+
*
|
|
142
|
+
* Accepts `undefined` so a resolver's optional `rest` section can be passed
|
|
143
|
+
* straight in: missing credentials throw `ExigoCredentialsError` here rather
|
|
144
|
+
* than failing later as an unauthenticated request.
|
|
145
|
+
*/
|
|
146
|
+
function createExigoRestClient(credentials, options = {}) {
|
|
147
|
+
if (!credentials) throw new ExigoCredentialsError("No Exigo REST credentials were provided.", ["rest"]);
|
|
148
|
+
assertExigoRestCredentials(credentials);
|
|
149
|
+
const baseUrl = normalizeExigoBaseUrl(credentials.baseUrl);
|
|
150
|
+
const host = new URL(baseUrl).host;
|
|
151
|
+
const authorization = authorizationHeader(credentials);
|
|
152
|
+
const secrets = [credentials.password, authorization.replace(/^Basic /, "")];
|
|
153
|
+
const defaultTimeout = validTimeout(options.timeoutMs ?? 15e3);
|
|
154
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
155
|
+
async function request(method, path, requestOptions = {}) {
|
|
156
|
+
const relative = expandPath(path, requestOptions.params);
|
|
157
|
+
const url = new URL(`${baseUrl}${relative}`);
|
|
158
|
+
appendQuery(url, requestOptions.query);
|
|
159
|
+
const timeoutMs = validTimeout(requestOptions.timeoutMs ?? defaultTimeout);
|
|
160
|
+
const headers = new Headers(requestOptions.headers);
|
|
161
|
+
if (!headers.has("Accept")) headers.set("Accept", "application/json");
|
|
162
|
+
headers.set("Authorization", authorization);
|
|
163
|
+
let body;
|
|
164
|
+
if (requestOptions.body !== void 0) {
|
|
165
|
+
headers.set("Content-Type", "application/json");
|
|
166
|
+
body = JSON.stringify(requestOptions.body);
|
|
167
|
+
}
|
|
168
|
+
const callerSignal = requestOptions.signal;
|
|
169
|
+
callerSignal?.throwIfAborted();
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
let timedOut = false;
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
timedOut = true;
|
|
174
|
+
controller.abort();
|
|
175
|
+
}, timeoutMs);
|
|
176
|
+
const onCallerAbort = () => controller.abort(callerSignal?.reason);
|
|
177
|
+
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
178
|
+
let response;
|
|
179
|
+
let text;
|
|
180
|
+
try {
|
|
181
|
+
response = await fetchImpl(url, {
|
|
182
|
+
method,
|
|
183
|
+
headers,
|
|
184
|
+
body,
|
|
185
|
+
signal: controller.signal
|
|
186
|
+
});
|
|
187
|
+
text = await response.text();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (timedOut) throw new ExigoTimeoutError(`Exigo ${method} ${relative} did not answer within ${timeoutMs} ms.`, "rest", {
|
|
190
|
+
cause: error,
|
|
191
|
+
timeoutMs
|
|
192
|
+
});
|
|
193
|
+
if (callerSignal?.aborted) throw callerSignal.reason;
|
|
194
|
+
throw new ExigoConnectionError(`Could not reach Exigo at ${host}: ${describeNetworkFailure(error)}`, "rest", { cause: error });
|
|
195
|
+
} finally {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
198
|
+
}
|
|
199
|
+
const contentType = response.headers.get("content-type");
|
|
200
|
+
const details = {
|
|
201
|
+
status: response.status,
|
|
202
|
+
method,
|
|
203
|
+
path: url.pathname.slice(new URL(baseUrl).pathname.length) || "/",
|
|
204
|
+
contentType,
|
|
205
|
+
bodySnippet: snippet(text, secrets)
|
|
206
|
+
};
|
|
207
|
+
const html = looksLikeHtml(contentType, text);
|
|
208
|
+
const parsed = html || text.trim() === "" ? null : parseJson(text);
|
|
209
|
+
if (response.status === 401 || response.status === 403) throw new ExigoAuthError(`Exigo rejected the API credentials (HTTP ${response.status}). Check the username, password and company name.`, "rest", { status: response.status });
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
const exigoMessage = parsed?.ok ? errorMessageFrom(parsed.value) : null;
|
|
212
|
+
const reason = exigoMessage ? `: ${redact(exigoMessage, secrets)}` : html ? " with an HTML error page instead of JSON" : "";
|
|
213
|
+
throw new ExigoApiError(`Exigo ${method} ${details.path} failed with HTTP ${response.status}${reason}.`, details);
|
|
214
|
+
}
|
|
215
|
+
if (html) throw new ExigoApiError(`Exigo ${method} ${details.path} answered HTTP ${response.status} with an HTML page, not JSON. The base URL is probably not Exigo's API host.`, details);
|
|
216
|
+
if (parsed === null) return void 0;
|
|
217
|
+
if (!parsed.ok) throw new ExigoApiError(`Exigo ${method} ${details.path} answered HTTP ${response.status} with a body that is not JSON.`, details);
|
|
218
|
+
return parsed.value;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
baseUrl,
|
|
222
|
+
request,
|
|
223
|
+
get: (path, requestOptions) => request("GET", path, requestOptions),
|
|
224
|
+
post: (path, requestOptions) => request("POST", path, requestOptions),
|
|
225
|
+
put: (path, requestOptions) => request("PUT", path, requestOptions),
|
|
226
|
+
patch: (path, requestOptions) => request("PATCH", path, requestOptions),
|
|
227
|
+
delete: (path, requestOptions) => request("DELETE", path, requestOptions)
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
231
|
+
export { DEFAULT_EXIGO_TIMEOUT_MS, EXIGO_API_VERSION, ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError, createExigoRestClient, normalizeExigoBaseUrl };
|
package/dist/sql.d.mts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ExigoSqlCredentials } from "./credentials.mjs";
|
|
2
|
+
import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
|
|
3
|
+
import { ConnectionPool, ConnectionPool as ConnectionPool$1 } from "mssql";
|
|
4
|
+
|
|
5
|
+
//#region src/sql.d.ts
|
|
6
|
+
declare const EXIGO_SQL_DEFAULTS: {
|
|
7
|
+
readonly port: number;
|
|
8
|
+
readonly max: number;
|
|
9
|
+
readonly idleTimeoutMs: number;
|
|
10
|
+
readonly connectTimeoutMs: number;
|
|
11
|
+
readonly requestTimeoutMs: number;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Pool settings. They apply when the pool opens; a later call with different
|
|
15
|
+
* options reuses the open pool as it is.
|
|
16
|
+
*/
|
|
17
|
+
interface ExigoPoolOptions {
|
|
18
|
+
readonly max?: number;
|
|
19
|
+
readonly idleTimeoutMs?: number;
|
|
20
|
+
readonly connectTimeoutMs?: number;
|
|
21
|
+
readonly requestTimeoutMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Errors the pool raises outside a query, such as an idle connection that
|
|
24
|
+
* dropped. Defaults to a `console.warn`; the pool replaces the connection.
|
|
25
|
+
*/
|
|
26
|
+
readonly onError?: (error: ExigoError | Error) => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Turns an `mssql` failure into the package's typed errors where Exigo's
|
|
30
|
+
* remedy is known: login, timeout, and reaching the server. Anything else,
|
|
31
|
+
* such as a syntax error in a droplet's query, is returned unchanged.
|
|
32
|
+
*/
|
|
33
|
+
declare function translateExigoSqlError(error: unknown): unknown;
|
|
34
|
+
/**
|
|
35
|
+
* The open pool for these credentials, opening it on first use.
|
|
36
|
+
*
|
|
37
|
+
* Pools are keyed by host, port, database and username, and within that by
|
|
38
|
+
* password and TLS settings. Concurrent first calls share one connect. A
|
|
39
|
+
* failed connect is not cached, so the next call tries again. A rotated
|
|
40
|
+
* password opens its own pool without closing the previous one, which is
|
|
41
|
+
* closed once no call has used it for five minutes. A pool closed directly is
|
|
42
|
+
* replaced on the next call.
|
|
43
|
+
*/
|
|
44
|
+
declare function getExigoPool(credentials: ExigoSqlCredentials | null | undefined, options?: ExigoPoolOptions): Promise<ConnectionPool$1>;
|
|
45
|
+
/**
|
|
46
|
+
* Closes and forgets every pool for this database, whatever password it was
|
|
47
|
+
* opened with.
|
|
48
|
+
*/
|
|
49
|
+
declare function closeExigoPool(credentials: Pick<ExigoSqlCredentials, "host" | "database" | "username" | "port">): Promise<void>;
|
|
50
|
+
/** Closes every pool this package opened; for shutdown hooks and tests. */
|
|
51
|
+
declare function closeAllExigoPools(): Promise<void>;
|
|
52
|
+
//#endregion
|
|
53
|
+
export { type ConnectionPool, EXIGO_SQL_DEFAULTS, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoPoolOptions, ExigoSource, type ExigoSqlCredentials, ExigoTimeoutError, closeAllExigoPools, closeExigoPool, getExigoPool, translateExigoSqlError };
|
package/dist/sql.mjs
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError } from "./errors.mjs";
|
|
2
|
+
import { assertExigoSqlCredentials } from "./credentials.mjs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
//#region src/sql.ts
|
|
5
|
+
/**
|
|
6
|
+
* `@fluid-app/exigo-connection-sdk/sql`: one cached connection pool per Exigo database.
|
|
7
|
+
*
|
|
8
|
+
* Opening a pool per query leaks connections past the request that opened
|
|
9
|
+
* them (rewards-exigo's checkout path did this). Here the first call for a
|
|
10
|
+
* database opens a small pool, later calls reuse it, and the app closes it
|
|
11
|
+
* explicitly on shutdown. The cache lives on `globalThis`, so Next's dev
|
|
12
|
+
* reloads share one pool rather than stacking new ones.
|
|
13
|
+
*
|
|
14
|
+
* Node runtime only. `mssql` is an optional peer dependency that is imported
|
|
15
|
+
* the first time a pool opens, so an app that only imports `./rest` never
|
|
16
|
+
* loads it. In Next, list `mssql` in `serverExternalPackages` and set
|
|
17
|
+
* `export const runtime = "nodejs"` on routes that query.
|
|
18
|
+
*/
|
|
19
|
+
const EXIGO_SQL_DEFAULTS = {
|
|
20
|
+
port: 1433,
|
|
21
|
+
max: 4,
|
|
22
|
+
idleTimeoutMs: 1e4,
|
|
23
|
+
connectTimeoutMs: 12e3,
|
|
24
|
+
requestTimeoutMs: 3e4
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Each database holds one pool per credential fingerprint (password and TLS
|
|
28
|
+
* settings). A rotation therefore never closes a pool another request is
|
|
29
|
+
* using: requests that resolved the old password keep the old pool, new ones
|
|
30
|
+
* get the new pool, and a rollback finds its pool still there. A pool that
|
|
31
|
+
* is not its database's most recently used one, and that no request has asked
|
|
32
|
+
* for in this long, is closed and forgotten. The sweep runs on every call and,
|
|
33
|
+
* while any database holds more than one pool, on an unref'd timer, so it
|
|
34
|
+
* happens even for a tenant that stops querying.
|
|
35
|
+
*/
|
|
36
|
+
const UNUSED_POOL_TTL_MS = 5 * 6e4;
|
|
37
|
+
/**
|
|
38
|
+
* A database's current pool is closed too once nothing has asked for it in
|
|
39
|
+
* this long, so tenant churn does not grow the cache. It already holds no
|
|
40
|
+
* connections when idle (`min: 0`); the next call reopens it.
|
|
41
|
+
*/
|
|
42
|
+
const IDLE_POOL_TTL_MS = 30 * 6e4;
|
|
43
|
+
const CACHE_KEY = Symbol.for("@fluid-app/exigo-connection-sdk/sql-pools");
|
|
44
|
+
const SWEEP_TIMER_KEY = Symbol.for("@fluid-app/exigo-connection-sdk/sql-pool-sweep");
|
|
45
|
+
function cache() {
|
|
46
|
+
const holder = globalThis;
|
|
47
|
+
holder[CACHE_KEY] ??= /* @__PURE__ */ new Map();
|
|
48
|
+
return holder[CACHE_KEY];
|
|
49
|
+
}
|
|
50
|
+
const TRANSPORT_REQUEST_CODES = new Set([
|
|
51
|
+
"ETIMEOUT",
|
|
52
|
+
"ECONNCLOSED",
|
|
53
|
+
"ESOCKET"
|
|
54
|
+
]);
|
|
55
|
+
function poolKey(credentials) {
|
|
56
|
+
const port = credentials.port ?? EXIGO_SQL_DEFAULTS.port;
|
|
57
|
+
return JSON.stringify([
|
|
58
|
+
credentials.host.trim().toLowerCase(),
|
|
59
|
+
port,
|
|
60
|
+
credentials.database,
|
|
61
|
+
credentials.username
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
function fingerprint(credentials) {
|
|
65
|
+
return createHash("sha256").update(JSON.stringify([
|
|
66
|
+
credentials.password,
|
|
67
|
+
credentials.encrypt ?? true,
|
|
68
|
+
credentials.trustServerCertificate
|
|
69
|
+
])).digest("hex");
|
|
70
|
+
}
|
|
71
|
+
function toMssqlConfig(credentials, options) {
|
|
72
|
+
return {
|
|
73
|
+
server: credentials.host.trim(),
|
|
74
|
+
port: credentials.port ?? EXIGO_SQL_DEFAULTS.port,
|
|
75
|
+
database: credentials.database,
|
|
76
|
+
user: credentials.username,
|
|
77
|
+
password: credentials.password,
|
|
78
|
+
connectionTimeout: options.connectTimeoutMs ?? EXIGO_SQL_DEFAULTS.connectTimeoutMs,
|
|
79
|
+
requestTimeout: options.requestTimeoutMs ?? EXIGO_SQL_DEFAULTS.requestTimeoutMs,
|
|
80
|
+
pool: {
|
|
81
|
+
max: options.max ?? EXIGO_SQL_DEFAULTS.max,
|
|
82
|
+
min: 0,
|
|
83
|
+
idleTimeoutMillis: options.idleTimeoutMs ?? EXIGO_SQL_DEFAULTS.idleTimeoutMs
|
|
84
|
+
},
|
|
85
|
+
options: {
|
|
86
|
+
encrypt: credentials.encrypt ?? true,
|
|
87
|
+
trustServerCertificate: credentials.trustServerCertificate,
|
|
88
|
+
enableArithAbort: true
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async function loadMssql() {
|
|
93
|
+
let loaded;
|
|
94
|
+
try {
|
|
95
|
+
loaded = await import("mssql");
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new ExigoError("@fluid-app/exigo-connection-sdk/sql needs the mssql package. Install it next to @fluid-app/exigo-connection-sdk.", { cause: error });
|
|
98
|
+
}
|
|
99
|
+
return loaded.default ?? loaded;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* mssql wraps tedious, which wraps the socket error, sometimes in an
|
|
103
|
+
* AggregateError: `ECONNREFUSED` sits three levels below a top-level
|
|
104
|
+
* `ESOCKET` whose message only says "Could not connect (sequence)".
|
|
105
|
+
*/
|
|
106
|
+
function errorChain(error) {
|
|
107
|
+
const codes = /* @__PURE__ */ new Set();
|
|
108
|
+
const messages = [];
|
|
109
|
+
const seen = /* @__PURE__ */ new Set();
|
|
110
|
+
const visit = (value, depth) => {
|
|
111
|
+
if (value === null || typeof value !== "object" || seen.has(value) || depth > 5) return;
|
|
112
|
+
seen.add(value);
|
|
113
|
+
const record = value;
|
|
114
|
+
if (typeof record.code === "string") codes.add(record.code);
|
|
115
|
+
if (typeof record.message === "string") messages.push(record.message);
|
|
116
|
+
visit(record.originalError, depth + 1);
|
|
117
|
+
visit(record.cause, depth + 1);
|
|
118
|
+
if (Array.isArray(record.errors)) for (const inner of record.errors) visit(inner, depth + 1);
|
|
119
|
+
};
|
|
120
|
+
visit(error, 0);
|
|
121
|
+
return {
|
|
122
|
+
codes,
|
|
123
|
+
text: messages.join("\n")
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Turns an `mssql` failure into the package's typed errors where Exigo's
|
|
128
|
+
* remedy is known: login, timeout, and reaching the server. Anything else,
|
|
129
|
+
* such as a syntax error in a droplet's query, is returned unchanged.
|
|
130
|
+
*/
|
|
131
|
+
function translateExigoSqlError(error) {
|
|
132
|
+
if (error instanceof ExigoError) return error;
|
|
133
|
+
const { codes, text } = errorChain(error);
|
|
134
|
+
const code = error !== null && typeof error === "object" ? error.code : void 0;
|
|
135
|
+
const name = error instanceof Error ? error.name : "";
|
|
136
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
137
|
+
const options = { cause: error };
|
|
138
|
+
const has = (...candidates) => candidates.some((candidate) => codes.has(candidate));
|
|
139
|
+
if (name === "RequestError" && !TRANSPORT_REQUEST_CODES.has(String(code))) return error;
|
|
140
|
+
if (has("ELOGIN") || /Login failed/i.test(text)) return new ExigoAuthError("Exigo's SQL Server rejected the username or password.", "sql", options);
|
|
141
|
+
if (code === "ETIMEOUT") return name === "RequestError" ? new ExigoTimeoutError(`An Exigo SQL query timed out: ${raw}`, "sql", options) : new ExigoTimeoutError("Timed out connecting to Exigo's SQL Server. This is usually an IP allowlist or a wrong host or port, not a bad password.", "sql", options);
|
|
142
|
+
if (has("ENOTFOUND", "EAI_AGAIN", "EINSTLOOKUP") || /ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(text)) return new ExigoConnectionError(`Could not resolve the Exigo SQL host: ${raw}`, "sql", options);
|
|
143
|
+
if (has("ECONNREFUSED") || /ECONNREFUSED/i.test(text)) return new ExigoConnectionError("The Exigo SQL host refused the connection. Check the port and that the server accepts remote TCP connections.", "sql", options);
|
|
144
|
+
if (/self[- ]signed|certificate/i.test(text)) return new ExigoConnectionError(`TLS failed reaching Exigo's SQL Server: ${raw}`, "sql", options);
|
|
145
|
+
if (name === "ConnectionError" || code === "ESOCKET" || code === "ECONNCLOSED") return new ExigoConnectionError(`Could not connect to Exigo's SQL Server: ${raw}`, "sql", options);
|
|
146
|
+
return error;
|
|
147
|
+
}
|
|
148
|
+
async function openPool(credentials, options) {
|
|
149
|
+
const pool = new (await (loadMssql())).ConnectionPool(toMssqlConfig(credentials, options));
|
|
150
|
+
const onError = options.onError ?? ((error) => console.warn(`[@fluid-app/exigo-connection-sdk] ${error.message}`));
|
|
151
|
+
pool.on("error", (error) => {
|
|
152
|
+
const translated = translateExigoSqlError(error);
|
|
153
|
+
onError(translated instanceof Error ? translated : new Error(String(translated)));
|
|
154
|
+
});
|
|
155
|
+
try {
|
|
156
|
+
return await pool.connect();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
await pool.close().catch(() => void 0);
|
|
159
|
+
throw translateExigoSqlError(error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function isUsable(pool) {
|
|
163
|
+
return pool.connected || pool.connecting;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The open pool for these credentials, opening it on first use.
|
|
167
|
+
*
|
|
168
|
+
* Pools are keyed by host, port, database and username, and within that by
|
|
169
|
+
* password and TLS settings. Concurrent first calls share one connect. A
|
|
170
|
+
* failed connect is not cached, so the next call tries again. A rotated
|
|
171
|
+
* password opens its own pool without closing the previous one, which is
|
|
172
|
+
* closed once no call has used it for five minutes. A pool closed directly is
|
|
173
|
+
* replaced on the next call.
|
|
174
|
+
*/
|
|
175
|
+
async function getExigoPool(credentials, options = {}) {
|
|
176
|
+
if (!credentials) throw new ExigoCredentialsError("No Exigo SQL credentials were provided.", ["sql"]);
|
|
177
|
+
assertExigoSqlCredentials(credentials);
|
|
178
|
+
const pools = cache();
|
|
179
|
+
const identity = poolKey(credentials);
|
|
180
|
+
const current = fingerprint(credentials);
|
|
181
|
+
const group = () => {
|
|
182
|
+
let entries = pools.get(identity);
|
|
183
|
+
if (entries === void 0) {
|
|
184
|
+
entries = /* @__PURE__ */ new Map();
|
|
185
|
+
pools.set(identity, entries);
|
|
186
|
+
}
|
|
187
|
+
return entries;
|
|
188
|
+
};
|
|
189
|
+
const own = pools.get(identity)?.get(current);
|
|
190
|
+
if (own !== void 0) own.lastUsed = Date.now();
|
|
191
|
+
sweepSupersededPools(Date.now());
|
|
192
|
+
for (;;) {
|
|
193
|
+
const entries = group();
|
|
194
|
+
const existing = entries.get(current);
|
|
195
|
+
if (existing === void 0) break;
|
|
196
|
+
existing.lastUsed = Date.now();
|
|
197
|
+
const pool = await existing.pool;
|
|
198
|
+
if (isUsable(pool)) return pool;
|
|
199
|
+
if (entries.get(current) === existing) {
|
|
200
|
+
entries.delete(current);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const entry = {
|
|
205
|
+
pool: openPool(credentials, options),
|
|
206
|
+
lastUsed: Date.now()
|
|
207
|
+
};
|
|
208
|
+
const entries = group();
|
|
209
|
+
entries.set(current, entry);
|
|
210
|
+
entry.pool.catch(() => {
|
|
211
|
+
if (entries.get(current) === entry) entries.delete(current);
|
|
212
|
+
});
|
|
213
|
+
if (entries.size > 1) scheduleSweep();
|
|
214
|
+
return entry.pool;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Closes every pool that has been superseded on its database (it is not the
|
|
218
|
+
* most recently used one there) and has sat unused past the TTL. A database's
|
|
219
|
+
* current pool is never closed here, however long it idles.
|
|
220
|
+
*/
|
|
221
|
+
function sweepSupersededPools(now) {
|
|
222
|
+
const pools = cache();
|
|
223
|
+
let pending = false;
|
|
224
|
+
for (const [identity, entries] of pools) {
|
|
225
|
+
if (entries.size > 1) {
|
|
226
|
+
let keep;
|
|
227
|
+
let newest = Number.NEGATIVE_INFINITY;
|
|
228
|
+
for (const [key, entry] of entries) if (entry.lastUsed >= newest) {
|
|
229
|
+
newest = entry.lastUsed;
|
|
230
|
+
keep = key;
|
|
231
|
+
}
|
|
232
|
+
for (const [key, entry] of entries) {
|
|
233
|
+
if (key === keep || now - entry.lastUsed < UNUSED_POOL_TTL_MS) continue;
|
|
234
|
+
entries.delete(key);
|
|
235
|
+
closeEntry(entry).catch(() => void 0);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const [key, entry] of entries) {
|
|
239
|
+
if (now - entry.lastUsed < IDLE_POOL_TTL_MS) continue;
|
|
240
|
+
entries.delete(key);
|
|
241
|
+
closeEntry(entry).catch(() => void 0);
|
|
242
|
+
}
|
|
243
|
+
if (entries.size === 0) pools.delete(identity);
|
|
244
|
+
else if (entries.size > 1) pending = true;
|
|
245
|
+
}
|
|
246
|
+
if (pending) scheduleSweep();
|
|
247
|
+
}
|
|
248
|
+
/** One timer at a time; unref'd so it never keeps a process alive. */
|
|
249
|
+
function scheduleSweep() {
|
|
250
|
+
const holder = globalThis;
|
|
251
|
+
if (holder[SWEEP_TIMER_KEY] !== void 0) return;
|
|
252
|
+
const timer = setTimeout(() => {
|
|
253
|
+
holder[SWEEP_TIMER_KEY] = void 0;
|
|
254
|
+
sweepSupersededPools(Date.now());
|
|
255
|
+
}, UNUSED_POOL_TTL_MS);
|
|
256
|
+
timer.unref?.();
|
|
257
|
+
holder[SWEEP_TIMER_KEY] = timer;
|
|
258
|
+
}
|
|
259
|
+
function cancelSweep() {
|
|
260
|
+
const holder = globalThis;
|
|
261
|
+
if (holder[SWEEP_TIMER_KEY] === void 0) return;
|
|
262
|
+
clearTimeout(holder[SWEEP_TIMER_KEY]);
|
|
263
|
+
holder[SWEEP_TIMER_KEY] = void 0;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Closes and forgets every pool for this database, whatever password it was
|
|
267
|
+
* opened with.
|
|
268
|
+
*/
|
|
269
|
+
async function closeExigoPool(credentials) {
|
|
270
|
+
const pools = cache();
|
|
271
|
+
const identity = poolKey(credentials);
|
|
272
|
+
const entries = pools.get(identity);
|
|
273
|
+
if (entries === void 0) return;
|
|
274
|
+
pools.delete(identity);
|
|
275
|
+
await Promise.all([...entries.values()].map(closeEntry));
|
|
276
|
+
}
|
|
277
|
+
/** Closes every pool this package opened; for shutdown hooks and tests. */
|
|
278
|
+
async function closeAllExigoPools() {
|
|
279
|
+
const pools = cache();
|
|
280
|
+
const entries = [...pools.values()].flatMap((group) => [...group.values()]);
|
|
281
|
+
pools.clear();
|
|
282
|
+
cancelSweep();
|
|
283
|
+
await Promise.all(entries.map(closeEntry));
|
|
284
|
+
}
|
|
285
|
+
async function closeEntry(entry) {
|
|
286
|
+
let pool;
|
|
287
|
+
try {
|
|
288
|
+
pool = await entry.pool;
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
await pool.close();
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
export { EXIGO_SQL_DEFAULTS, ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError, closeAllExigoPools, closeExigoPool, getExigoPool, translateExigoSqlError };
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fluid-app/exigo-connection-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Exigo REST client, SQL pool and credential interface for Fluid droplets and Mist apps",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/fluid-commerce/fluid.git",
|
|
9
|
+
"directory": "packages/platform/exigo"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.mts",
|
|
19
|
+
"default": "./dist/index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./rest": {
|
|
22
|
+
"types": "./dist/rest.d.mts",
|
|
23
|
+
"default": "./dist/rest.mjs"
|
|
24
|
+
},
|
|
25
|
+
"./sql": {
|
|
26
|
+
"types": "./dist/sql.d.mts",
|
|
27
|
+
"default": "./dist/sql.mjs"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"registry": "https://registry.npmjs.org"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"zod": "4.6.5"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@fluid-app/typescript-config": "0.0.0",
|
|
39
|
+
"@types/mssql": "^12.3.0",
|
|
40
|
+
"@types/node": "24.10.12",
|
|
41
|
+
"mssql": "^12.7.2",
|
|
42
|
+
"tsdown": "^0.21.0",
|
|
43
|
+
"typescript": "^5",
|
|
44
|
+
"vitest": "^4.0.18"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@types/mssql": ">=9",
|
|
48
|
+
"mssql": ">=11 <13"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@types/mssql": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"mssql": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsdown",
|
|
60
|
+
"dev": "tsdown --watch",
|
|
61
|
+
"lint": "oxlint --deny-warnings",
|
|
62
|
+
"lint:fix": "oxlint --fix --deny-warnings",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"test:watch": "vitest",
|
|
65
|
+
"typecheck": "tsgo --noEmit",
|
|
66
|
+
"check:publish-shape": "node scripts/check-publish-shape.mjs"
|
|
67
|
+
}
|
|
68
|
+
}
|