From 7bd391cc95fc0eb53e0ec8edbb53557d442cb7b5 Mon Sep 17 00:00:00 2001 From: Brian Engert Date: Fri, 28 Aug 2026 13:50:57 -0500 Subject: [PATCH] feat!: replace the shared token with a session pool BREAKING CHANGE: calls are no longer serialized on one shared session, so per-session server state (found sets, script globals) does not persist across separate calls anymore; use withSession() to pin related calls to one session. The client holds server sessions, so call destroy() on shutdown. --- README.md | 128 +++++++++++++- jest.config.js | 3 + package-lock.json | 12 ++ package.json | 3 + rollup.config.js | 1 + src/Client.ts | 226 +++++++++--------------- src/FileMakerError.ts | 19 ++ src/Layout.ts | 13 +- src/Session.ts | 148 ++++++++++++++++ src/SessionPool.ts | 179 +++++++++++++++++++ src/index.ts | 6 + test/Client.test.ts | 398 +++++++++++++++++++++++++++++++++++++++++- 12 files changed, 987 insertions(+), 149 deletions(-) create mode 100644 src/FileMakerError.ts create mode 100644 src/Session.ts create mode 100644 src/SessionPool.ts diff --git a/README.md b/README.md index a2dc73d..14dbf67 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,22 @@ Data API specification: carried, alongside the existing counts. - `UpdateResponse` gained an optional `newPortalRecordInfo`. +It also changes how sessions are managed. The client now keeps a **pool** of Data API sessions +instead of one shared token, so concurrent calls no longer interleave on a single FileMaker session, +and a burst of concurrent cold calls no longer leaves orphaned sessions on the server: + + - Concurrent calls use up to `max` sessions (default 5), configurable via a new optional fifth + constructor argument. See [Session pooling](#session-pooling). + - **New `client.destroy()`** signs out of every session and closes the pool. Call it on shutdown. + `Client` also implements `Symbol.asyncDispose`, so `await using` works. + - **New `client.withSession(callback)`** pins several calls to one session, for the cases where + FileMaker keeps state per session. + - **`client.clearToken()` is deprecated** in favour of `destroy()`. It still signs out of every + session held at the time of the call and leaves the client usable, so existing calls keep working. + - `FileMakerError` is now exported from the package root. Previously there was no supported way to + get at it for an `instanceof` check. + - Pool exhaustion rejects with `TimeoutError`, re-exported from the package root. + ## Connecting to a server In order to connect to a server, create a new instance of a client: @@ -35,15 +51,121 @@ In order to connect to a server, create a new instance of a client: ```typescript import {Client} from 'fm-data-api-client'; -const client = new Client('https://file-maker-server', 'username', 'password', 'database'); +const client = new Client('https://file-maker-server', 'database', 'username', 'password'); +``` + +A client is meant to be long lived. Create one per process, share it, and shut it down with +`destroy()` - see [Session pooling](#session-pooling) below. + +## Session pooling + +The client does not hold a single Data API token. It keeps a pool of sessions, and every call checks +one out for its duration and returns it afterwards. FileMaker treats a session as one logical +connection and serialises the work on it, so concurrent callers each get their own session instead of +interleaving on a shared one: + +```typescript +// Three sessions, three concurrent Data API calls. +await Promise.all([ + client.layout('orders').find({query: [{status: '=open'}]}), + client.layout('orders').find({query: [{status: '=shipped'}]}), + client.layout('customers').range({limit: 100}), +]); +``` + +Sessions are signed in on demand, reused while they stay warm, and signed out once they have been +idle for `idleTimeoutMillis` - shortly before FileMaker's own 15 minute expiry, so they are released +on the server rather than left to time out. If the server reports an invalid token (error `952`), that +session is retired and the call is retried once on a fresh one. + +The pool is configured with an optional fifth constructor argument: + +```typescript +const client = new Client('https://file-maker-server', 'database', 'username', 'password', { + max: 10, +}); +``` + +| Option | Default | Meaning | +| --- | --- | --- | +| `max` | `5` | Maximum concurrent sessions. Each one is a real FileMaker Server connection, so this is the ceiling on Data API concurrency for this client. Calls beyond it queue. | +| `min` | `0` | Sessions to keep on hand. Leave it at `0`: a non-zero value holds sessions open past `idleTimeoutMillis` and keeps a timer alive for the lifetime of the process. It does not pre-warm the pool - sessions are only ever created on demand. | +| `idleTimeoutMillis` | `13 * 60 * 1000` | How long an unused session is kept before it is signed out. | +| `acquireTimeoutMillis` | `30_000` | How long a call waits for a free session before rejecting with `TimeoutError`. | +| `createTimeoutMillis` | `30_000` | How long a sign-in may take. | +| `destroyTimeoutMillis` | `5_000` | How long a sign-out may take. | +| `reapIntervalMillis` | `30_000` | How often idle sessions are looked for. | +| `createRetryIntervalMillis` | `200` | How long to wait after a failed sign-in before trying again. | +| `propagateCreateError` | `true` | Reject the waiting call as soon as a sign-in fails. With `false`, bad credentials are retried until `acquireTimeoutMillis` elapses and then surface as a `TimeoutError` instead of the FileMaker error that explains the problem. | +| `log` | none | Receives the pool's internal warnings. | + +`max` bounds the sessions the pool holds, not quite the sessions FileMaker sees: when a session is +retired, its sign-out overlaps the sign-in of its replacement, so budget a little headroom against any +server-side session limit. + +Pool exhaustion is reported with `TimeoutError`, which is re-exported for convenience: + +```typescript +import {TimeoutError} from 'fm-data-api-client'; + +try { + await client.layout('orders').range(); +} catch (e) { + if (e instanceof TimeoutError) { + // No session became free within acquireTimeoutMillis. + } +} +``` + +### Using one session for several calls + +FileMaker keeps some state per session, a found set being the obvious example. `withSession` pins a +run of calls to a single session: + +```typescript +await client.withSession(async session => { + const created = await session.layout('orders').create({customer: 'ACME'}); + return session.layout('orders').get(created.recordId); +}); +``` + +The session is returned to the pool once the callback settles and cannot be used afterwards, so do not +store it or let it escape. Nothing inside the callback may call `client.request`, `client.layout(...)` +or `client.withSession` again: those check out a *second* session while the callback is still holding +the first, which deadlocks once the pool is full. Use the `session` argument for everything inside. + +An invalid token is not retried inside `withSession`, because the callback may already have written +something and must not run twice. + +### Shutting down + +The client holds server sessions, so release them on shutdown: + +```typescript +await client.destroy(); ``` +That signs out of every session, waiting for in-flight calls to finish first, and closes the pool. +The client cannot be used afterwards. On Node 22 `await using` works too: + +```typescript +await using client = new Client('https://file-maker-server', 'database', 'username', 'password'); +``` + +Forgetting `destroy()` will not hang your process - idle sessions are signed out on their own, and +the pool's timer does not hold the event loop open - but the sessions stay registered on the server +until they expire. + ## Sign out -If you want to manually sign out to release the token, you can call the `clearToken` method: +> **Deprecated.** Idle sessions are signed out automatically. Use +> [`destroy()`](#shutting-down) to shut a client down for good, which is almost always what is +> wanted here. + +`clearToken` signs out of every session held right now and carries on with fresh ones: ```typescript -client.clearToken(); +await client.clearToken(); ``` ## Retrieving a layout client diff --git a/jest.config.js b/jest.config.js index 5cd159d..498359f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,4 +2,7 @@ module.exports = { preset: 'ts-jest', clearMocks: true, + // clearMocks only resets call data; without this the Date.now() spies in Client.test.ts leak + // into later tests, where the pool would then compare timestamps taken under different clocks. + restoreMocks: true, }; diff --git a/package-lock.json b/package-lock.json index a5517c9..9fc1473 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "fm-data-api-client", "version": "3.0.1", "license": "MIT", + "dependencies": { + "tarn": "^3.1.2" + }, "devDependencies": { "@biomejs/biome": "2.5.9", "@commitlint/cli": "^21.2.2", @@ -5658,6 +5661,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tarn": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.2.tgz", + "integrity": "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/package.json b/package.json index b32c536..999b5fc 100644 --- a/package.json +++ b/package.json @@ -57,5 +57,8 @@ }, "lint-staged": { "*.ts": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true" + }, + "dependencies": { + "tarn": "^3.1.2" } } diff --git a/rollup.config.js b/rollup.config.js index ac63620..4ebe2fa 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -4,6 +4,7 @@ import pkg from './package.json'; export default [ { input: 'src/index.ts', + external: ['tarn', '@js-joda/core', 'node:fs/promises', 'node:path'], output: [ {file: pkg.main, format: 'cjs', sourcemap: true}, {file: pkg.module, format: 'es', sourcemap: true}, diff --git a/src/Client.ts b/src/Client.ts index e793ac9..68a3131 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -1,41 +1,35 @@ +import {FileMakerError} from './FileMakerError'; import type {FieldData, GenericPortalData} from './Layout'; import Layout from './Layout'; - -export class FileMakerError extends Error { - public readonly code: string; - - public constructor(code: string, message: string) { - super(message); - this.code = code; - } -} - -type FileMakerErrorResponse = { - messages: Array<{ - code: string; - message: string; - }>; -}; - -type FileMakerResponse = { - response: T; -}; - -type ContainerDownload = { - contentType?: string | null; - buffer: ReadableStream | null; -}; - +import type {ContainerDownload} from './Session'; +import Session from './Session'; +import {SessionPool, type SessionPoolOptions} from './SessionPool'; + +export {FileMakerError} from './FileMakerError'; +export type {ContainerDownload} from './Session'; + +/** + * A client for one FileMaker database. + * + * The client owns a pool of Data API sessions rather than a single shared token. Every call checks a + * session out for its duration and returns it afterwards, so concurrent callers get their own + * sessions instead of interleaving on one, up to `max`. + * + * A client is meant to be long lived: build one per process and share it. Because it holds server + * sessions, call {@link Client.destroy} on shutdown, or use `await using` on Node 22. + */ export default class Client { - private token: string | null = null; - private lastCall = 0; + private pool: SessionPool; public constructor( private readonly uri: string, private readonly database: string, private readonly username: string, private readonly password: string, - ) {} + private readonly poolOptions: SessionPoolOptions = {}, + ) { + this.pool = this.createPool(); + } public layout( layout: string, @@ -43,135 +37,85 @@ export default class Client { return new Layout(layout, this); } - public async request(path: string, request?: RequestInit, retryOnInvalidToken = true): Promise { - const authorizedRequest = Client.injectHeaders( - new Headers({ - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${await this.getToken()}`, - }), - request, - ); - - const response = await fetch(`${this.uri}/fmi/data/v1/databases/${this.database}/${path}`, authorizedRequest); - - if (!response.ok) { - const data = (await response.json()) as FileMakerErrorResponse; + /** + * Runs `callback` against a single session, so that every call inside it uses the same token. + * Useful where FileMaker keeps state per session, such as a found set that a later call relies + * on. + * + * The session is returned to the pool when the callback settles, and is unusable afterwards, so + * it must not be stored or leaked out of the callback. Nothing inside the callback may call back + * into the client's own `request`/`requestContainer`/`withSession`: the pool is bounded, and + * waiting for a slot the callback itself is holding deadlocks. + * + * Unlike {@link Client.request}, an invalid token (FileMaker error 952) is not retried here, + * because the callback may already have written something and must not run twice. The session is + * retired either way. + */ + public async withSession(callback: (session: Session) => Promise): Promise { + // Captured once: clearToken() swaps this.pool while calls are in flight, and the session + // must go back to the pool it came from, whose destroy() waits on it. + const pool = this.pool; + const pooled = await pool.acquire(); + const session = new Session(pooled, this.uri, this.database); + + try { + return await callback(session); + } finally { + session.markReleased(); + pool.release(pooled); + } + } - if (data.messages[0].code === '952' && retryOnInvalidToken) { - this.token = null; - return this.request(path, request, false); + public async request(path: string, request?: RequestInit, retryOnInvalidToken = true): Promise { + try { + return await this.withSession(session => session.request(path, request)); + } catch (e) { + if (retryOnInvalidToken && e instanceof FileMakerError && e.code === '952') { + // The session that failed has been marked invalid, so the pool will not hand it back + // out; this acquires a freshly signed-in one. + return this.withSession(session => session.request(path, request)); } - throw new FileMakerError(data.messages[0].code, data.messages[0].message); + throw e; } - - this.lastCall = Date.now(); - return ((await response.json()) as FileMakerResponse).response; } public async requestContainer(containerUrl: string, request?: RequestInit): Promise { + // Checked before acquiring, so a bad URL does not cost a sign-in. if (!containerUrl.toLowerCase().startsWith(this.uri.toLowerCase())) { throw new Error('Container url must start with the same url as the FM host'); } - const token = await this.getToken(); - const authorizedRequest = Client.injectHeaders( - new Headers({ - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }), - request, - ); - authorizedRequest.redirect = 'manual'; - - const response = await fetch(containerUrl, authorizedRequest); - - if (response.status === 302 && response.headers.has('set-cookie')) { - const redirectRequest = Client.injectHeaders( - new Headers({ - 'cookie': response.headers.get('set-cookie') ?? '', - }), - request, - ); - return this.requestContainer(containerUrl, redirectRequest); - } - - if (!response.ok) { - throw new Error(`Failed to download container ${response.status}`); - } - - return { - contentType: response.headers.get('Content-Type'), - buffer: response.body, - }; + return this.withSession(session => session.requestContainer(containerUrl, request)); } - public async clearToken(): Promise { - if (!this.token) { - return; - } - - await fetch(`${this.uri}/fmi/data/v1/databases/${this.database}/sessions/${this.token}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - }); - - this.token = null; - this.lastCall = 0; + /** + * Signs out of every session and closes the pool. Waits for in-flight calls to finish first. The + * client cannot be used afterwards. + */ + public async destroy(): Promise { + await this.pool.destroy(); } - private async getToken(): Promise { - if (this.token !== null && Date.now() - this.lastCall < 14 * 60 * 1000) { - return this.token; - } - - const headers = { - 'Content-Type': 'application/json', - 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, - }; - - const response = await fetch(`${this.uri}/fmi/data/v1/databases/${this.database}/sessions`, { - method: 'POST', - body: '{}', - headers, - }); - - if (!response.ok) { - const data = (await response.json()) as FileMakerErrorResponse; - throw new FileMakerError(data.messages[0].code, data.messages[0].message); - } - - this.token = response.headers.get('X-FM-Data-Access-Token'); - - if (!this.token) { - throw new Error('Could not get token'); - } - - this.lastCall = Date.now(); - return this.token; + public async [Symbol.asyncDispose](): Promise { + await this.destroy(); } - private static injectHeaders(headers: Headers, request?: RequestInit): RequestInit { - if (!request) { - request = {}; - } - - request.headers = new Headers(request.headers); - - for (const header of headers) { - // If form data is set, skip setting a content-type header in order to let fetch - // generate one with a boundary instead. - if (header[0] === 'content-type' && request.body instanceof FormData) { - continue; - } - - if (!request.headers.has(header[0])) { - request.headers.append(header[0], header[1]); - } - } + /** + * Signs out of every session held right now, then carries on with fresh ones. + * + * @deprecated Sessions are signed out on their own once they go idle. Use {@link Client.destroy} + * to shut the client down for good, which is almost always what is wanted here. + */ + public async clearToken(): Promise { + // Swapped before draining so that calls arriving during the sign-out get a working pool + // rather than a rejection, and so that in-flight calls on the old pool run to completion. + const previous = this.pool; + this.pool = this.createPool(); + await previous.destroy(); + } - return request; + private createPool(): SessionPool { + return new SessionPool(this.uri, this.database, this.username, this.password, this.poolOptions); } } diff --git a/src/FileMakerError.ts b/src/FileMakerError.ts new file mode 100644 index 0000000..ddd8e8f --- /dev/null +++ b/src/FileMakerError.ts @@ -0,0 +1,19 @@ +export class FileMakerError extends Error { + public readonly code: string; + + public constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +export type FileMakerErrorResponse = { + messages: Array<{ + code: string; + message: string; + }>; +}; + +export type FileMakerResponse = { + response: T; +}; diff --git a/src/Layout.ts b/src/Layout.ts index 04dec59..40ad1a0 100644 --- a/src/Layout.ts +++ b/src/Layout.ts @@ -1,7 +1,14 @@ import {readFile} from 'node:fs/promises'; import path from 'node:path'; -import type Client from './Client'; -import {FileMakerError} from './Client'; +import {FileMakerError} from './FileMakerError'; + +/** + * The part of a client a layout actually needs. Both `Client`, which checks a session out of the + * pool per call, and `Session`, which is already bound to one, satisfy it. + */ +export interface LayoutClient { + request(path: string, request?: RequestInit): Promise; +} export type Numerish = string | number; export type FieldValue = string | Numerish; @@ -132,7 +139,7 @@ export type ExecuteScriptResponse = Pick { public constructor( private readonly layout: string, - private readonly client: Client, + private readonly client: LayoutClient, ) {} public async create(fieldData: Partial, params: CreateParams = {}): Promise { diff --git a/src/Session.ts b/src/Session.ts new file mode 100644 index 0000000..c15e6f5 --- /dev/null +++ b/src/Session.ts @@ -0,0 +1,148 @@ +import {FileMakerError, type FileMakerErrorResponse, type FileMakerResponse} from './FileMakerError'; +import type {FieldData, GenericPortalData} from './Layout'; +import Layout from './Layout'; +import type {PooledSession} from './SessionPool'; + +export type ContainerDownload = { + contentType?: string | null; + buffer: ReadableStream | null; +}; + +/** + * A single FileMaker Data API session, checked out of the pool for the duration of one + * {@link Client.withSession} call. + * + * A session is only valid inside the callback it was handed to. Nothing reachable from within that + * callback may check out another session: the pool is bounded, so a nested acquire waits for a slot + * that the outer call is still holding, and at `max: 1` that is an immediate deadlock. + */ +export default class Session { + private released: boolean = false; + + public constructor( + private readonly pooled: PooledSession, + private readonly uri: string, + private readonly database: string, + ) {} + + public get token(): string { + return this.pooled.token; + } + + public layout( + layout: string, + ): Layout { + return new Layout(layout, this); + } + + public async request(path: string, request?: RequestInit): Promise { + this.assertUsable(); + + const authorizedRequest = Session.injectHeaders( + new Headers({ + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.pooled.token}`, + }), + request, + ); + + const response = await fetch(`${this.uri}/fmi/data/v1/databases/${this.database}/${path}`, authorizedRequest); + + if (!response.ok) { + const data = (await response.json()) as FileMakerErrorResponse; + + if (data.messages[0].code === '952') { + // The server no longer recognises this token, so the session must not go back into + // rotation. Marking it here is what makes the pool destroy it instead of handing it + // to the next caller; the retry itself happens in Client, on a fresh session. + this.pooled.invalid = true; + throw new FileMakerError(data.messages[0].code, data.messages[0].message); + } + + // The server answered, which means it also reset this session's idle timer, even though + // the answer was an error. Empty find results (code 401) are routine and must not make a + // perfectly live session look stale. + this.pooled.lastUsedAt = Date.now(); + throw new FileMakerError(data.messages[0].code, data.messages[0].message); + } + + this.pooled.lastUsedAt = Date.now(); + return ((await response.json()) as FileMakerResponse).response; + } + + public async requestContainer(containerUrl: string, request?: RequestInit): Promise { + this.assertUsable(); + + const authorizedRequest = Session.injectHeaders( + new Headers({ + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.pooled.token}`, + }), + request, + ); + authorizedRequest.redirect = 'manual'; + + const response = await fetch(containerUrl, authorizedRequest); + + if (response.status === 302 && response.headers.has('set-cookie')) { + const redirectRequest = Session.injectHeaders( + new Headers({ + 'cookie': response.headers.get('set-cookie') ?? '', + }), + request, + ); + // Recurses on this session rather than back through Client, which would try to check out + // a second session while this one is still held. + return this.requestContainer(containerUrl, redirectRequest); + } + + if (response.status === 401) { + this.pooled.invalid = true; + } + + if (!response.ok) { + throw new Error(`Failed to download container ${response.status}`); + } + + this.pooled.lastUsedAt = Date.now(); + + return { + contentType: response.headers.get('Content-Type'), + buffer: response.body, + }; + } + + /** @internal Called by Client once the session has gone back to the pool. */ + public markReleased(): void { + this.released = true; + } + + private assertUsable(): void { + if (this.released) { + throw new Error( + 'This session has been released back to the pool and can no longer be used. ' + + 'Sessions must not outlive the withSession() callback they were passed to.', + ); + } + } + + private static injectHeaders(headers: Headers, request?: RequestInit): RequestInit { + // Copies rather than mutates the caller's RequestInit: it is reused for the retry after + // a 952, which must not see the first attempt's Authorization header. + const mergedHeaders = new Headers(request?.headers); + + for (const header of headers) { + // If form data is set, skip setting a content-type header in order to let fetch + // generate one with a boundary instead. + if (header[0] === 'content-type' && request?.body instanceof FormData) { + continue; + } + + if (!mergedHeaders.has(header[0])) { + mergedHeaders.append(header[0], header[1]); + } + } + + return {...request, headers: mergedHeaders}; + } +} diff --git a/src/SessionPool.ts b/src/SessionPool.ts new file mode 100644 index 0000000..7cdf8a5 --- /dev/null +++ b/src/SessionPool.ts @@ -0,0 +1,179 @@ +import {Pool} from 'tarn'; +import {FileMakerError, type FileMakerErrorResponse} from './FileMakerError'; + +/** + * A FileMaker Data API session checked out of the pool. + * + * `lastUsedAt` mirrors FileMaker's own idle timer: the server expires a session 15 minutes after the + * last request it answered, so the timestamp is refreshed on every completed response rather than on + * creation alone. + */ +export type PooledSession = { + token: string; + lastUsedAt: number; + invalid: boolean; +}; + +export type SessionPoolOptions = { + /** + * Minimum number of sessions to keep. Defaults to 0, and should stay there: tarn keeps `min` + * resources alive past `idleTimeoutMillis`, so a non-zero value both holds FileMaker sessions + * open needlessly and keeps the reaping timer alive for the lifetime of the process. + */ + min?: number; + /** + * Maximum number of concurrent sessions. Defaults to 5. Each session is a real FileMaker Server + * connection, so this is the ceiling on how much Data API concurrency one client can use. + */ + max?: number; + /** How long to wait for a free session before rejecting. Defaults to 30 seconds. */ + acquireTimeoutMillis?: number; + /** How long a sign-in may take before it is abandoned. Defaults to 30 seconds. */ + createTimeoutMillis?: number; + /** How long a sign-out may take before the session is dropped anyway. Defaults to 5 seconds. */ + destroyTimeoutMillis?: number; + /** + * How long an unused session is kept before it is signed out. Defaults to 13 minutes, which + * releases it on the server shortly before FileMaker's own 15 minute expiry. + */ + idleTimeoutMillis?: number; + /** How often to look for idle sessions. Defaults to 30 seconds. */ + reapIntervalMillis?: number; + /** How long to wait after a failed sign-in before trying again. Defaults to 200 milliseconds. */ + createRetryIntervalMillis?: number; + /** + * Whether a failed sign-in rejects the waiting caller immediately. Defaults to `true`, which + * differs from the pool implementation's own default on purpose: with `false`, bad credentials + * are retried silently until `acquireTimeoutMillis` elapses and then surface as a `TimeoutError`, + * losing the FileMaker error code that explains what actually went wrong. Set it to `false` only + * if you would rather ride out a restarting server than see the error. + */ + propagateCreateError?: boolean; + /** Receives the pool's internal warnings. */ + log?: (message: string) => void; +}; + +/** + * How long a session may sit unused before we stop trusting its token. FileMaker expires a session + * 15 minutes after its last request; we retire ours a minute early to avoid racing that. + */ +const SESSION_TTL_MILLIS = 14 * 60 * 1000; + +export class SessionPool { + private readonly pool: Pool; + private destroyed: boolean = false; + + public constructor( + private readonly uri: string, + private readonly database: string, + private readonly username: string, + private readonly password: string, + options: SessionPoolOptions = {}, + ) { + this.pool = new Pool({ + create: async () => this.signIn(), + validate: session => this.isUsable(session), + destroy: async session => this.signOut(session), + min: options.min ?? 0, + max: options.max ?? 5, + acquireTimeoutMillis: options.acquireTimeoutMillis ?? 30 * 1000, + createTimeoutMillis: options.createTimeoutMillis ?? 30 * 1000, + destroyTimeoutMillis: options.destroyTimeoutMillis ?? 5 * 1000, + idleTimeoutMillis: options.idleTimeoutMillis ?? 13 * 60 * 1000, + reapIntervalMillis: options.reapIntervalMillis ?? 30 * 1000, + createRetryIntervalMillis: options.createRetryIntervalMillis ?? 200, + propagateCreateError: options.propagateCreateError ?? true, + log: options.log ?? (() => undefined), + }); + + // Without this, a single request keeps the process alive for up to idleTimeoutMillis, because + // the reaping interval is not unref'd. That interval is assigned *after* the startReaping + // handlers run, hence the setImmediate. Reaching for a protected field is deliberate and + // deliberately defensive: if a future release renames it, the pool still works, callers just + // have to rely on destroy() to let the process exit. + this.pool.on('startReaping', () => { + setImmediate(() => { + const {interval} = this.pool as unknown as {interval: {unref?: () => void} | null}; + interval?.unref?.(); + }); + }); + } + + public async acquire(): Promise { + if (this.destroyed) { + throw new Error('Client has been destroyed'); + } + + return this.pool.acquire().promise; + } + + public release(session: PooledSession): void { + this.pool.release(session); + } + + /** + * Signs out every session and closes the pool, waiting for sessions that are currently checked + * out to be released first. The pool cannot be used afterwards. + */ + public async destroy(): Promise { + if (this.destroyed) { + return; + } + + this.destroyed = true; + await this.pool.destroy(); + } + + private isUsable(session: PooledSession): boolean { + if (session.invalid) { + return false; + } + + // Math.abs mirrors how the pool measures idle time, so that a clock stepping backwards (NTP, + // a machine waking from sleep) retires the session instead of extending its life. + return Math.abs(Date.now() - session.lastUsedAt) < SESSION_TTL_MILLIS; + } + + private async signIn(): Promise { + const response = await fetch(`${this.uri}/fmi/data/v1/databases/${this.database}/sessions`, { + method: 'POST', + body: '{}', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, + }, + }); + + if (!response.ok) { + const data = (await response.json()) as FileMakerErrorResponse; + throw new FileMakerError(data.messages[0].code, data.messages[0].message); + } + + const token = response.headers.get('X-FM-Data-Access-Token'); + + if (!token) { + throw new Error('Could not get token'); + } + + // Must be stamped here: the pool validates a resource on the very first acquire, including + // the one that just created it, so a session starting at 0 would look expired on sight and + // the pool would sign in and out in a loop until the acquire timed out. + return {token, lastUsedAt: Date.now(), invalid: false}; + } + + private async signOut(session: PooledSession): Promise { + // This must never reject. When a sign-in overruns createTimeoutMillis and then succeeds + // anyway, the pool calls the destroyer directly without awaiting or catching it, so a + // rejection here becomes an unhandled rejection and takes the process down on Node 22. + try { + await fetch(`${this.uri}/fmi/data/v1/databases/${this.database}/sessions/${session.token}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch { + // A session the server has already expired cannot be signed out, and that is fine. + } + } +} diff --git a/src/index.ts b/src/index.ts index fdc2915..2174b88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,11 @@ +export {TimeoutError} from 'tarn'; export {default as Client} from './Client'; +export {FileMakerError} from './FileMakerError'; +export type {LayoutClient} from './Layout'; export {default as Layout} from './Layout'; +export type {ContainerDownload} from './Session'; +export {default as Session} from './Session'; +export type {PooledSession, SessionPoolOptions} from './SessionPool'; import * as utils from './Utils'; diff --git a/test/Client.test.ts b/test/Client.test.ts index 7b33753..3c176c0 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -1,6 +1,7 @@ import {Readable} from 'node:stream'; import {text} from 'node:stream/consumers'; import fetchMock from '@fetch-mock/jest'; +import {TimeoutError} from 'tarn'; import {Client} from '../src'; import {FileMakerError} from '../src/Client'; @@ -9,10 +10,27 @@ describe('Client', () => { beforeEach(() => { fetchMock.mockGlobal(); - client = new Client('https://localhost', 'db', 'user', 'pass'); + client = new Client('https://localhost', 'db', 'user', 'pass', { + // Keeps the reaper out of tests that move Date.now() past the session TTL, and makes a + // wedged pool fail in a second instead of consuming jest's default 5s test timeout. + idleTimeoutMillis: 60 * 60 * 1000, + reapIntervalMillis: 60 * 60 * 1000, + acquireTimeoutMillis: 1000, + createTimeoutMillis: 1000, + destroyTimeoutMillis: 1000, + }); + // Sign-out is now driven by the pool, so a DELETE can happen in any test that signed in. + fetchMock.delete('glob:https://localhost/fmi/data/v1/databases/db/sessions/*', { + status: 200, + body: {}, + }); }); - afterEach(() => { + afterEach(async () => { + // Restore the clock before draining, and drain before removing the fetch mock, so the + // sign-out requests still have a route to hit. + jest.restoreAllMocks(); + await client.destroy(); fetchMock.mockRestore(); }); @@ -290,6 +308,94 @@ describe('Client', () => { const value = await text(containerReadable); expect(value).toBe('test'); expect(response.contentType).toBe('application/text'); + + // The redirect hop must reuse the session already held. Acquiring a second one would + // wait on a slot this call is still occupying, which deadlocks at max 1. + expect(fetchMock).toHavePostedTimes(1, new URL('https://localhost/fmi/data/v1/databases/db/sessions')); + }); + + it('should follow the redirect on the session it already holds', async () => { + const containerPath = '/Streaming_SSL/MainDB/asdf.xml?RCType=EmbeddedRCFileProcessor'; + + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + let firstRequest = true; + fetchMock.get(`https://localhost${containerPath}`, () => { + if (firstRequest) { + firstRequest = false; + return {status: 302, headers: {'set-cookie': 'X-FMS-Session-Key=asdf123; HttpOnly'}, body: {}}; + } + + return {headers: {'content-type': 'application/text'}, body: 'test'}; + }); + + // A single slot: if the recursion re-entered the pool this would time out instead. + const client = new Client('https://localhost', 'db', 'user', 'pass', { + max: 1, + acquireTimeoutMillis: 200, + }); + fetchMock.delete('glob:https://localhost/fmi/data/v1/databases/db/sessions/*', 200); + + try { + const response = await client.requestContainer(`https://localhost${containerPath}`); + expect(response.contentType).toBe('application/text'); + } finally { + await client.destroy(); + } + }); + + it('should throw on a failed download', async () => { + const containerPath = '/Streaming_SSL/MainDB/asdf.xml'; + + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + fetchMock.get(`https://localhost${containerPath}`, {status: 404, body: ''}); + + await expect(client.requestContainer(`https://localhost${containerPath}`)).rejects.toEqual( + new Error('Failed to download container 404'), + ); + }); + + it('should retire the session when the container endpoint rejects the token', async () => { + const containerPath = '/Streaming_SSL/MainDB/asdf.xml'; + let issued = 0; + + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', () => { + issued += 1; + return {status: 200, headers: {'X-FM-Data-Access-Token': `token-${issued}`}, body: {}}; + }); + + fetchMock.get(`https://localhost${containerPath}`, {status: 401, body: ''}); + + await expect(client.requestContainer(`https://localhost${containerPath}`)).rejects.toEqual( + new Error('Failed to download container 401'), + ); + + fetchMock.get('https://localhost/fmi/data/v1/databases/db/test', {status: 200, body: {response: 'test'}}); + const response = await client.request('test'); + expect(response).toBe('test'); + + // The rejected session is signed out rather than handed to the next caller, which signs + // in afresh. That happens on the next acquire, not at release, so a session invalidated + // by the last call of a quiet period lingers until the reaper collects it. + expect(issued).toBe(2); + expect(fetchMock).toHaveDeleted(new URL('https://localhost/fmi/data/v1/databases/db/sessions/token-1')); + }); + + it('should not sign in for a url that does not match the host', async () => { + await expect(client.requestContainer('https://example.io')).rejects.toEqual( + new Error('Container url must start with the same url as the FM host'), + ); + + expect(fetchMock).toHaveFetchedTimes(0, new URL('https://localhost/fmi/data/v1/databases/db/sessions')); }); }); @@ -299,6 +405,271 @@ describe('Client', () => { ); }); + describe('session pool', () => { + const mockSlowLayoutRequest = (delayMillis = 20) => { + let inFlight = 0; + let peakInFlight = 0; + + fetchMock.get('https://localhost/fmi/data/v1/databases/db/test', async () => { + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, delayMillis)); + inFlight -= 1; + return {status: 200, body: {response: 'test'}}; + }); + + return () => peakInFlight; + }; + + const mockNumberedSessions = () => { + let issued = 0; + + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', () => { + issued += 1; + return {status: 200, headers: {'X-FM-Data-Access-Token': `token-${issued}`}, body: {}}; + }); + + return () => issued; + }; + + it('should run concurrent requests on separate sessions up to max', async () => { + const sessionsIssued = mockNumberedSessions(); + const peakInFlight = mockSlowLayoutRequest(); + + const client = new Client('https://localhost', 'db', 'user', 'pass', {max: 3}); + fetchMock.delete('glob:https://localhost/fmi/data/v1/databases/db/sessions/*', 200); + + try { + const responses = await Promise.all(Array.from({length: 9}, () => client.request('test'))); + + expect(responses).toHaveLength(9); + expect(sessionsIssued()).toBe(3); + expect(peakInFlight()).toBe(3); + } finally { + await client.destroy(); + } + }); + + it('should sign in only once for concurrent cold requests when max is one', async () => { + const sessionsIssued = mockNumberedSessions(); + const peakInFlight = mockSlowLayoutRequest(); + + const client = new Client('https://localhost', 'db', 'user', 'pass', {max: 1}); + fetchMock.delete('glob:https://localhost/fmi/data/v1/databases/db/sessions/*', 200); + + try { + await Promise.all([client.request('test'), client.request('test'), client.request('test')]); + + // Without a pool each of the three would race to create its own session, and two of + // them would leak on the server until FileMaker timed them out. + expect(sessionsIssued()).toBe(1); + expect(peakInFlight()).toBe(1); + } finally { + await client.destroy(); + } + }); + + it('should reject once the pool is exhausted', async () => { + mockNumberedSessions(); + mockSlowLayoutRequest(200); + + const client = new Client('https://localhost', 'db', 'user', 'pass', { + max: 1, + acquireTimeoutMillis: 20, + }); + fetchMock.delete('glob:https://localhost/fmi/data/v1/databases/db/sessions/*', 200); + + try { + const [first, second] = await Promise.allSettled([client.request('test'), client.request('test')]); + + expect(first.status).toBe('fulfilled'); + expect(second.status).toBe('rejected'); + expect((second as PromiseRejectedResult).reason).toBeInstanceOf(TimeoutError); + } finally { + await client.destroy(); + } + }); + + it('should sign out an invalidated session and retry on a fresh one', async () => { + mockNumberedSessions(); + + fetchMock.getOnce('https://localhost/fmi/data/v1/databases/db/test', { + status: 400, + body: {messages: [{code: '952', message: 'Invalid FileMaker DATA API token'}]}, + }); + fetchMock.getOnce('https://localhost/fmi/data/v1/databases/db/test', { + status: 200, + headers: {'authorization': 'Bearer token-2'}, + body: {response: 'test'}, + }); + + const response = await client.request('test'); + expect(response).toBe('test'); + expect(fetchMock).toHaveDeleted(new URL('https://localhost/fmi/data/v1/databases/db/sessions/token-1')); + }); + + it('should retry with the fresh token when the caller supplies its own request', async () => { + mockNumberedSessions(); + + fetchMock.postOnce('https://localhost/fmi/data/v1/databases/db/test', { + status: 400, + body: {messages: [{code: '952', message: 'Invalid FileMaker DATA API token'}]}, + }); + fetchMock.postOnce('https://localhost/fmi/data/v1/databases/db/test', ({options}) => ({ + status: 200, + body: {response: new Headers(options.headers).get('authorization')}, + })); + + // The caller's RequestInit must come back untouched, or the retry reuses the stale token. + const request: RequestInit = {method: 'POST', body: JSON.stringify({fieldData: {}})}; + const response = await client.request('test', request); + + expect(response).toBe('Bearer token-2'); + expect(request.headers).toBeUndefined(); + }); + + it('should not reuse a session the server rejected', async () => { + mockNumberedSessions(); + + fetchMock.get('https://localhost/fmi/data/v1/databases/db/test', { + status: 400, + body: {messages: [{code: '952', message: 'Invalid FileMaker DATA API token'}]}, + }); + + await expect(client.request('test')).rejects.toEqual( + new FileMakerError('952', 'Invalid FileMaker DATA API token'), + ); + + // One session for the initial attempt, a second for the retry; neither is reused. + expect(fetchMock).toHavePostedTimes(2, new URL('https://localhost/fmi/data/v1/databases/db/sessions')); + }); + + it('should surface a sign-in failure immediately rather than timing out', async () => { + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 401, + body: {messages: [{code: '212', message: 'Invalid user account and/or password'}]}, + }); + + await expect(client.request('test')).rejects.toEqual( + new FileMakerError('212', 'Invalid user account and/or password'), + ); + }); + }); + + describe('withSession', () => { + it('should use a single session for every call in the callback', async () => { + let issued = 0; + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', () => { + issued += 1; + return {status: 200, headers: {'X-FM-Data-Access-Token': `token-${issued}`}, body: {}}; + }); + + fetchMock.get('https://localhost/fmi/data/v1/databases/db/test', ({options}) => ({ + status: 200, + body: {response: new Headers(options.headers).get('authorization')}, + })); + + const tokens = await client.withSession(async session => [ + await session.request('test'), + await session.request('test'), + await session.request('test'), + ]); + + expect(issued).toBe(1); + expect(tokens).toEqual(['Bearer token-1', 'Bearer token-1', 'Bearer token-1']); + }); + + it('should expose a layout client bound to the session', async () => { + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + fetchMock.get('glob:https://localhost/fmi/data/v1/databases/db/layouts/my-layout/records*', { + status: 200, + body: {response: {data: [], dataInfo: {}}}, + }); + + const response = await client.withSession(session => session.layout('my-layout').range()); + expect(response.data).toEqual([]); + }); + + it('should release the session even when the callback throws', async () => { + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + const client = new Client('https://localhost', 'db', 'user', 'pass', { + max: 1, + acquireTimeoutMillis: 200, + }); + fetchMock.delete('glob:https://localhost/fmi/data/v1/databases/db/sessions/*', 200); + + try { + await expect(client.withSession(async () => Promise.reject(new Error('boom')))).rejects.toEqual( + new Error('boom'), + ); + + // The single slot must be free again, otherwise this would time out. + await expect(client.withSession(async session => session.token)).resolves.toBe('foo'); + } finally { + await client.destroy(); + } + }); + + it('should reject use of a session after the callback returned', async () => { + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + const escaped = await client.withSession(async session => session); + await expect(escaped.request('test')).rejects.toThrow('released back to the pool'); + }); + }); + + describe('destroy', () => { + it('should sign out every session and refuse further requests', async () => { + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + fetchMock.get('https://localhost/fmi/data/v1/databases/db/test', {status: 200, body: {response: 'test'}}); + + await client.request('test'); + await client.destroy(); + + expect(fetchMock).toHaveDeletedTimes(1, new URL('https://localhost/fmi/data/v1/databases/db/sessions/foo')); + await expect(client.request('test')).rejects.toEqual(new Error('Client has been destroyed')); + }); + + it('should be safe to call twice', async () => { + await client.destroy(); + await expect(client.destroy()).resolves.toBeUndefined(); + }); + + it('should be reachable through async disposal', async () => { + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + fetchMock.get('https://localhost/fmi/data/v1/databases/db/test', {status: 200, body: {response: 'test'}}); + + await client.request('test'); + await client[Symbol.asyncDispose](); + + expect(fetchMock).toHaveDeletedTimes(1, new URL('https://localhost/fmi/data/v1/databases/db/sessions/foo')); + }); + }); + describe('clearToken', () => { it('should do nothing without a token', async () => { fetchMock.delete('https://localhost/fmi/data/v1/databases/db/sessions/null', { @@ -374,5 +745,28 @@ describe('Client', () => { await client.request('test'); }); + + it('should let in-flight calls finish and sign their sessions out', async () => { + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + fetchMock.get('https://localhost/fmi/data/v1/databases/db/test', async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + return {status: 200, body: {response: 'test'}}; + }); + + const inFlight = client.request('test'); + // Give the request time to check its session out of the pool about to be swapped. + await new Promise(resolve => setTimeout(resolve, 10)); + + // Waits on the old pool draining; a release into the swapped-in pool would hang here. + await client.clearToken(); + + await expect(inFlight).resolves.toBe('test'); + expect(fetchMock).toHaveDeleted(new URL('https://localhost/fmi/data/v1/databases/db/sessions/foo')); + }); }); });