@bpmnkit/api 0.0.8

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.
Files changed (48) hide show
  1. package/README.md +150 -0
  2. package/dist/generated/admin-resources.d.ts +199 -0
  3. package/dist/generated/admin-resources.js +381 -0
  4. package/dist/generated/admin-types.d.ts +283 -0
  5. package/dist/generated/admin-types.js +4 -0
  6. package/dist/generated/resources.d.ts +1519 -0
  7. package/dist/generated/resources.js +2650 -0
  8. package/dist/generated/types.d.ts +11946 -0
  9. package/dist/generated/types.js +4 -0
  10. package/dist/index.d.ts +11 -0
  11. package/dist/index.js +10 -0
  12. package/dist/runtime/auth.d.ts +31 -0
  13. package/dist/runtime/auth.js +136 -0
  14. package/dist/runtime/cache.d.ts +13 -0
  15. package/dist/runtime/cache.js +48 -0
  16. package/dist/runtime/cache.test.d.ts +2 -0
  17. package/dist/runtime/cache.test.js +38 -0
  18. package/dist/runtime/client.d.ts +25 -0
  19. package/dist/runtime/client.js +33 -0
  20. package/dist/runtime/config.d.ts +15 -0
  21. package/dist/runtime/config.js +371 -0
  22. package/dist/runtime/errors.d.ts +53 -0
  23. package/dist/runtime/errors.js +101 -0
  24. package/dist/runtime/errors.test.d.ts +2 -0
  25. package/dist/runtime/errors.test.js +40 -0
  26. package/dist/runtime/events.d.ts +12 -0
  27. package/dist/runtime/events.js +48 -0
  28. package/dist/runtime/events.test.d.ts +2 -0
  29. package/dist/runtime/events.test.js +57 -0
  30. package/dist/runtime/http.d.ts +15 -0
  31. package/dist/runtime/http.js +210 -0
  32. package/dist/runtime/logger.d.ts +9 -0
  33. package/dist/runtime/logger.js +34 -0
  34. package/dist/runtime/relations.d.ts +43 -0
  35. package/dist/runtime/relations.js +54 -0
  36. package/dist/runtime/retry.d.ts +14 -0
  37. package/dist/runtime/retry.js +41 -0
  38. package/dist/runtime/retry.test.d.ts +2 -0
  39. package/dist/runtime/retry.test.js +46 -0
  40. package/dist/runtime/token-cache.d.ts +42 -0
  41. package/dist/runtime/token-cache.js +104 -0
  42. package/dist/runtime/types.d.ts +191 -0
  43. package/dist/runtime/types.js +2 -0
  44. package/dist/runtime/yaml.d.ts +14 -0
  45. package/dist/runtime/yaml.js +234 -0
  46. package/dist/runtime/yaml.test.d.ts +2 -0
  47. package/dist/runtime/yaml.test.js +93 -0
  48. package/package.json +31 -0
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { TypedEventEmitter } from "./events.js";
3
+ describe("TypedEventEmitter", () => {
4
+ it("emits events to registered listeners", () => {
5
+ const emitter = new TypedEventEmitter();
6
+ const handler = vi.fn();
7
+ emitter.on("ping", handler);
8
+ emitter.emit("ping", { id: 42 });
9
+ expect(handler).toHaveBeenCalledWith({ id: 42 });
10
+ });
11
+ it("supports multiple listeners for the same event", () => {
12
+ const emitter = new TypedEventEmitter();
13
+ const a = vi.fn();
14
+ const b = vi.fn();
15
+ emitter.on("ping", a);
16
+ emitter.on("ping", b);
17
+ emitter.emit("ping", { id: 1 });
18
+ expect(a).toHaveBeenCalledOnce();
19
+ expect(b).toHaveBeenCalledOnce();
20
+ });
21
+ it("off removes a listener", () => {
22
+ const emitter = new TypedEventEmitter();
23
+ const handler = vi.fn();
24
+ emitter.on("ping", handler);
25
+ emitter.off("ping", handler);
26
+ emitter.emit("ping", { id: 1 });
27
+ expect(handler).not.toHaveBeenCalled();
28
+ });
29
+ it("once fires exactly once", () => {
30
+ const emitter = new TypedEventEmitter();
31
+ const handler = vi.fn();
32
+ emitter.once("ping", handler);
33
+ emitter.emit("ping", { id: 1 });
34
+ emitter.emit("ping", { id: 2 });
35
+ expect(handler).toHaveBeenCalledOnce();
36
+ });
37
+ it("does not propagate listener errors", () => {
38
+ const emitter = new TypedEventEmitter();
39
+ emitter.on("ping", () => {
40
+ throw new Error("boom");
41
+ });
42
+ expect(() => emitter.emit("ping", { id: 1 })).not.toThrow();
43
+ });
44
+ it("removeAllListeners clears all events", () => {
45
+ const emitter = new TypedEventEmitter();
46
+ const a = vi.fn();
47
+ const b = vi.fn();
48
+ emitter.on("ping", a);
49
+ emitter.on("pong", b);
50
+ emitter.removeAllListeners();
51
+ emitter.emit("ping", { id: 1 });
52
+ emitter.emit("pong", { message: "hi" });
53
+ expect(a).not.toHaveBeenCalled();
54
+ expect(b).not.toHaveBeenCalled();
55
+ });
56
+ });
57
+ //# sourceMappingURL=events.test.js.map
@@ -0,0 +1,15 @@
1
+ import { Cache } from "./cache.js";
2
+ import type { TypedEventEmitter } from "./events.js";
3
+ import type { CamundaClientConfig, ClientEventMap, RequestOptions } from "./types.js";
4
+ /**
5
+ * Core HTTP transport layer. Handles auth, retries, caching, timeouts,
6
+ * logging, and event emission. Used by all generated resource classes.
7
+ */
8
+ export declare class HttpClient {
9
+ #private;
10
+ constructor(config: CamundaClientConfig, emitter: TypedEventEmitter<ClientEventMap>);
11
+ request<T>(options: RequestOptions): Promise<T>;
12
+ /** Expose cache for manual invalidation. */
13
+ get cache(): Cache | null;
14
+ }
15
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1,210 @@
1
+ import { createAuthProvider } from "./auth.js";
2
+ import { Cache } from "./cache.js";
3
+ import { CamundaHttpError, CamundaNetworkError, CamundaTimeoutError, buildHttpError, } from "./errors.js";
4
+ import { createLogger } from "./logger.js";
5
+ import { withRetry } from "./retry.js";
6
+ import { resolveTokenStore } from "./token-cache.js";
7
+ /**
8
+ * Core HTTP transport layer. Handles auth, retries, caching, timeouts,
9
+ * logging, and event emission. Used by all generated resource classes.
10
+ */
11
+ export class HttpClient {
12
+ #config;
13
+ #auth;
14
+ #cache;
15
+ #logger;
16
+ #emitter;
17
+ constructor(config, emitter) {
18
+ this.#config = config;
19
+ this.#emitter = emitter;
20
+ this.#logger = createLogger(config.logger);
21
+ const tokenStore = config.auth.type === "oauth2" ? resolveTokenStore(config.auth.tokenCache) : undefined;
22
+ this.#auth = createAuthProvider(config.auth, tokenStore, () => {
23
+ if (config.auth.type === "oauth2") {
24
+ emitter.emit("tokenRefresh", { tokenUrl: config.auth.tokenUrl });
25
+ }
26
+ });
27
+ const cacheConfig = config.cache;
28
+ this.#cache =
29
+ cacheConfig?.enabled === true
30
+ ? new Cache(cacheConfig.ttl ?? 30_000, cacheConfig.maxSize ?? 500)
31
+ : null;
32
+ }
33
+ async request(options) {
34
+ const url = this.#buildUrl(options);
35
+ // Cache check (only for cacheable requests)
36
+ if (options.cacheable && this.#cache) {
37
+ const cacheKey = this.#cacheKey(options);
38
+ const cached = this.#cache.get(cacheKey);
39
+ if (cached !== undefined) {
40
+ this.#logger.debug("Cache hit", { url });
41
+ this.#emitter.emit("cacheHit", { url });
42
+ this.#emitter.emit("response", {
43
+ method: options.method,
44
+ url,
45
+ status: 200,
46
+ durationMs: 0,
47
+ cached: true,
48
+ });
49
+ return cached;
50
+ }
51
+ this.#emitter.emit("cacheMiss", { url });
52
+ }
53
+ return withRetry(() => this.#executeRequest(url, options), this.#config.retry, (err) => this.#shouldRetry(err), (ctx) => {
54
+ this.#logger.warn("Retrying request", { url, ...ctx });
55
+ this.#emitter.emit("retry", {
56
+ method: options.method,
57
+ url,
58
+ ...ctx,
59
+ });
60
+ });
61
+ }
62
+ async #executeRequest(url, options) {
63
+ const authHeader = await this.#auth.getAuthorizationHeader();
64
+ const headers = {
65
+ "Content-Type": "application/json",
66
+ Accept: options.accept ?? "application/json",
67
+ };
68
+ if (authHeader) {
69
+ headers.Authorization = authHeader;
70
+ }
71
+ const body = options.body !== undefined ? JSON.stringify(options.body) : undefined;
72
+ if (body === undefined) {
73
+ // biome-ignore lint/performance/noDelete: removing the header key is correct here
74
+ delete headers["Content-Type"];
75
+ }
76
+ const requestEvent = { method: options.method, url, headers, body: options.body };
77
+ this.#logger.debug("Request", requestEvent);
78
+ this.#emitter.emit("request", requestEvent);
79
+ const timeout = options.timeout ?? this.#config.timeout ?? 30_000;
80
+ const controller = new AbortController();
81
+ const timer = setTimeout(() => controller.abort(), timeout);
82
+ const startMs = Date.now();
83
+ let response;
84
+ try {
85
+ response = await fetch(url, {
86
+ method: options.method,
87
+ headers,
88
+ body,
89
+ signal: controller.signal,
90
+ });
91
+ }
92
+ catch (err) {
93
+ clearTimeout(timer);
94
+ const durationMs = Date.now() - startMs;
95
+ this.#logger.error("Network error", { url, durationMs, err });
96
+ if (err instanceof Error && err.name === "AbortError") {
97
+ const timeoutErr = new CamundaTimeoutError(`Request timed out after ${timeout}ms: ${options.method} ${url}`);
98
+ this.#emitter.emit("error", { method: options.method, url, error: timeoutErr });
99
+ throw timeoutErr;
100
+ }
101
+ const netErr = new CamundaNetworkError(`Network error: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
102
+ this.#emitter.emit("error", { method: options.method, url, error: netErr });
103
+ throw netErr;
104
+ }
105
+ clearTimeout(timer);
106
+ const durationMs = Date.now() - startMs;
107
+ this.#logger.debug("Response", { url, status: response.status, durationMs });
108
+ this.#emitter.emit("response", {
109
+ method: options.method,
110
+ url,
111
+ status: response.status,
112
+ durationMs,
113
+ cached: false,
114
+ });
115
+ // Emit raw response event (clones body so the original stream is untouched)
116
+ {
117
+ const rawHeaders = {};
118
+ for (const [k, v] of response.headers.entries()) {
119
+ rawHeaders[k] = v;
120
+ }
121
+ const rawBody = await response.clone().text();
122
+ this.#emitter.emit("rawResponse", {
123
+ method: options.method,
124
+ url,
125
+ status: response.status,
126
+ headers: rawHeaders,
127
+ body: rawBody,
128
+ requestHeaders: headers,
129
+ requestBody: body,
130
+ });
131
+ }
132
+ // Handle 401 with potential token refresh
133
+ if (response.status === 401) {
134
+ const canRetry = await this.#auth.handleUnauthorized();
135
+ if (canRetry) {
136
+ return this.#executeRequest(url, options);
137
+ }
138
+ }
139
+ if (!response.ok) {
140
+ let errorBody;
141
+ try {
142
+ errorBody = await response.json();
143
+ }
144
+ catch {
145
+ errorBody = await response.text().catch(() => null);
146
+ }
147
+ const err = buildHttpError(response.status, errorBody, url);
148
+ this.#logger.error("API error", { url, status: response.status, body: errorBody });
149
+ this.#emitter.emit("error", { method: options.method, url, error: err });
150
+ throw err;
151
+ }
152
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
153
+ return undefined;
154
+ }
155
+ const data = (options.responseType === "text" ? await response.text() : await response.json());
156
+ // Store in cache if applicable
157
+ if (options.cacheable && this.#cache) {
158
+ this.#cache.set(this.#cacheKey(options), data);
159
+ }
160
+ return data;
161
+ }
162
+ #buildUrl(options) {
163
+ let path = options.path;
164
+ // Substitute path parameters
165
+ if (options.pathParams) {
166
+ for (const [key, value] of Object.entries(options.pathParams)) {
167
+ path = path.replace(`{${key}}`, encodeURIComponent(String(value)));
168
+ }
169
+ }
170
+ const base = this.#config.baseUrl.replace(/\/$/, "");
171
+ let url = `${base}${path}`;
172
+ // Append query string
173
+ if (options.query) {
174
+ const params = new URLSearchParams();
175
+ for (const [key, value] of Object.entries(options.query)) {
176
+ if (value !== undefined && value !== null) {
177
+ params.set(key, String(value));
178
+ }
179
+ }
180
+ const qs = params.toString();
181
+ if (qs)
182
+ url += `?${qs}`;
183
+ }
184
+ return url;
185
+ }
186
+ #cacheKey(options) {
187
+ return `${options.method}:${this.#buildUrl(options)}:${JSON.stringify(options.body ?? null)}`;
188
+ }
189
+ #shouldRetry(err) {
190
+ if (err instanceof CamundaTimeoutError) {
191
+ return { retry: true, reason: "timeout" };
192
+ }
193
+ if (err instanceof CamundaNetworkError) {
194
+ return { retry: true, reason: "network error" };
195
+ }
196
+ if (err instanceof CamundaHttpError) {
197
+ return {
198
+ retry: true,
199
+ reason: `HTTP ${err.status}`,
200
+ statusCode: err.status,
201
+ };
202
+ }
203
+ return { retry: false, reason: "unknown error" };
204
+ }
205
+ /** Expose cache for manual invalidation. */
206
+ get cache() {
207
+ return this.#cache;
208
+ }
209
+ }
210
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,9 @@
1
+ import type { LoggerConfig } from "./types.js";
2
+ export interface Logger {
3
+ debug(message: string, data?: unknown): void;
4
+ info(message: string, data?: unknown): void;
5
+ warn(message: string, data?: unknown): void;
6
+ error(message: string, data?: unknown): void;
7
+ }
8
+ export declare function createLogger(config?: LoggerConfig): Logger;
9
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1,34 @@
1
+ const LEVELS = {
2
+ debug: 0,
3
+ info: 1,
4
+ warn: 2,
5
+ error: 3,
6
+ none: 4,
7
+ };
8
+ const defaultSink = (level, message, data) => {
9
+ const ts = new Date().toISOString();
10
+ const prefix = `[camunda-api] [${ts}] [${level.toUpperCase()}] ${message}`;
11
+ if (data !== undefined) {
12
+ /* eslint-disable no-console */
13
+ console[level === "debug" ? "debug" : level === "warn" ? "warn" : level === "error" ? "error" : "log"](prefix, data);
14
+ }
15
+ else {
16
+ console[level === "debug" ? "debug" : level === "warn" ? "warn" : level === "error" ? "error" : "log"](prefix);
17
+ }
18
+ };
19
+ export function createLogger(config) {
20
+ const minLevel = LEVELS[config?.level ?? "info"];
21
+ const sink = config?.sink ?? defaultSink;
22
+ const log = (level, message, data) => {
23
+ if (LEVELS[level] >= minLevel) {
24
+ sink(level, message, data);
25
+ }
26
+ };
27
+ return {
28
+ debug: (m, d) => log("debug", m, d),
29
+ info: (m, d) => log("info", m, d),
30
+ warn: (m, d) => log("warn", m, d),
31
+ error: (m, d) => log("error", m, d),
32
+ };
33
+ }
34
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Framework-agnostic relation graph between API operations.
3
+ *
4
+ * A Relation declares that a field produced by one operation (e.g. the
5
+ * "processDefinitionKey" column of the process-definition list) can be
6
+ * used as a parameter for another operation (e.g. get-xml). Both the CLI
7
+ * (to pre-fill command args) and the operate frontend (to suggest navigation)
8
+ * import these types and use buildRelations() to compute the graph.
9
+ */
10
+ /** A follow-up link from one operation's output field to another's input param. */
11
+ export interface Relation {
12
+ /** Name of the group containing the target operation */
13
+ groupName: string;
14
+ /** Name of the target operation/command */
15
+ commandName: string;
16
+ /** Human-readable description of the follow-up action */
17
+ description: string;
18
+ /** How to map source fields to target params */
19
+ params: Array<{
20
+ field: string;
21
+ param: string;
22
+ }>;
23
+ }
24
+ /**
25
+ * Generic descriptor for an operation in the relation graph.
26
+ * CLI commands and operate views can both be described as RelationSources.
27
+ */
28
+ export interface RelationSource {
29
+ groupName: string;
30
+ commandName: string;
31
+ description: string;
32
+ /** Field names this operation produces (e.g. list column keys). */
33
+ outputFields: string[];
34
+ /** Parameter names this operation accepts as input (e.g. arg names). */
35
+ inputParams: string[];
36
+ }
37
+ /**
38
+ * Build a map of relations for all provided sources.
39
+ * For each source with output fields, finds other sources whose input
40
+ * params match those fields. Returns a map keyed by "groupName/commandName".
41
+ */
42
+ export declare function buildRelations(sources: RelationSource[]): Map<string, Relation[]>;
43
+ //# sourceMappingURL=relations.d.ts.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Framework-agnostic relation graph between API operations.
3
+ *
4
+ * A Relation declares that a field produced by one operation (e.g. the
5
+ * "processDefinitionKey" column of the process-definition list) can be
6
+ * used as a parameter for another operation (e.g. get-xml). Both the CLI
7
+ * (to pre-fill command args) and the operate frontend (to suggest navigation)
8
+ * import these types and use buildRelations() to compute the graph.
9
+ */
10
+ /**
11
+ * Build a map of relations for all provided sources.
12
+ * For each source with output fields, finds other sources whose input
13
+ * params match those fields. Returns a map keyed by "groupName/commandName".
14
+ */
15
+ export function buildRelations(sources) {
16
+ // Build index: inputParam → sources that accept it
17
+ const paramIndex = new Map();
18
+ for (const src of sources) {
19
+ for (const param of src.inputParams) {
20
+ const existing = paramIndex.get(param) ?? [];
21
+ existing.push(src);
22
+ paramIndex.set(param, existing);
23
+ }
24
+ }
25
+ const result = new Map();
26
+ for (const src of sources) {
27
+ if (src.outputFields.length === 0)
28
+ continue;
29
+ const relations = [];
30
+ const seen = new Set();
31
+ for (const field of src.outputFields) {
32
+ const targets = paramIndex.get(field) ?? [];
33
+ for (const target of targets) {
34
+ if (target === src)
35
+ continue;
36
+ const key = `${target.groupName}/${target.commandName}`;
37
+ if (seen.has(key))
38
+ continue;
39
+ seen.add(key);
40
+ relations.push({
41
+ groupName: target.groupName,
42
+ commandName: target.commandName,
43
+ description: target.description,
44
+ params: [{ field, param: field }],
45
+ });
46
+ }
47
+ }
48
+ if (relations.length > 0) {
49
+ result.set(`${src.groupName}/${src.commandName}`, relations);
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+ //# sourceMappingURL=relations.js.map
@@ -0,0 +1,14 @@
1
+ import type { RetryConfig } from "./types.js";
2
+ export interface RetryContext {
3
+ attempt: number;
4
+ maxAttempts: number;
5
+ delayMs: number;
6
+ reason: string;
7
+ }
8
+ export type OnRetry = (ctx: RetryContext) => void;
9
+ export declare function withRetry<T>(fn: () => Promise<T>, config: RetryConfig | undefined, shouldRetry: (error: unknown) => {
10
+ retry: boolean;
11
+ reason: string;
12
+ statusCode?: number;
13
+ }, onRetry?: OnRetry): Promise<T>;
14
+ //# sourceMappingURL=retry.d.ts.map
@@ -0,0 +1,41 @@
1
+ const DEFAULTS = {
2
+ maxAttempts: 3,
3
+ initialDelay: 100,
4
+ maxDelay: 30_000,
5
+ backoffFactor: 2,
6
+ retryOn: [429, 500, 502, 503, 504],
7
+ };
8
+ export async function withRetry(fn, config, shouldRetry, onRetry) {
9
+ const cfg = { ...DEFAULTS, ...config };
10
+ let delay = cfg.initialDelay;
11
+ for (let attempt = 1; attempt <= cfg.maxAttempts; attempt++) {
12
+ try {
13
+ return await fn();
14
+ }
15
+ catch (err) {
16
+ if (attempt === cfg.maxAttempts)
17
+ throw err;
18
+ const { retry, reason, statusCode } = shouldRetry(err);
19
+ // If status code given, check against retryOn list
20
+ if (!retry || (statusCode !== undefined && !cfg.retryOn.includes(statusCode))) {
21
+ throw err;
22
+ }
23
+ const jitter = Math.random() * 0.2 * delay;
24
+ const actualDelay = Math.min(delay + jitter, cfg.maxDelay);
25
+ onRetry?.({
26
+ attempt,
27
+ maxAttempts: cfg.maxAttempts,
28
+ delayMs: actualDelay,
29
+ reason,
30
+ });
31
+ await sleep(actualDelay);
32
+ delay = Math.min(delay * cfg.backoffFactor, cfg.maxDelay);
33
+ }
34
+ }
35
+ // Unreachable, but satisfies TS
36
+ throw new Error("Retry loop exhausted");
37
+ }
38
+ function sleep(ms) {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+ //# sourceMappingURL=retry.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=retry.test.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { CamundaHttpError, CamundaNetworkError } from "./errors.js";
3
+ import { withRetry } from "./retry.js";
4
+ const alwaysRetry = (err) => {
5
+ if (err instanceof CamundaHttpError)
6
+ return { retry: true, reason: `HTTP ${err.status}`, statusCode: err.status };
7
+ if (err instanceof CamundaNetworkError)
8
+ return { retry: true, reason: "network" };
9
+ return { retry: false, reason: "unknown" };
10
+ };
11
+ describe("withRetry", () => {
12
+ it("returns result on success", async () => {
13
+ const fn = vi.fn().mockResolvedValue("ok");
14
+ const result = await withRetry(fn, undefined, alwaysRetry);
15
+ expect(result).toBe("ok");
16
+ expect(fn).toHaveBeenCalledOnce();
17
+ });
18
+ it("retries on retryable error and eventually succeeds", async () => {
19
+ const err = new CamundaNetworkError("timeout");
20
+ const fn = vi.fn().mockRejectedValueOnce(err).mockResolvedValue("ok");
21
+ const onRetry = vi.fn();
22
+ const result = await withRetry(fn, { maxAttempts: 3, initialDelay: 0 }, alwaysRetry, onRetry);
23
+ expect(result).toBe("ok");
24
+ expect(fn).toHaveBeenCalledTimes(2);
25
+ expect(onRetry).toHaveBeenCalledOnce();
26
+ });
27
+ it("throws after maxAttempts", async () => {
28
+ const err = new CamundaNetworkError("fail");
29
+ const fn = vi.fn().mockRejectedValue(err);
30
+ await expect(withRetry(fn, { maxAttempts: 3, initialDelay: 0 }, alwaysRetry)).rejects.toThrow("fail");
31
+ expect(fn).toHaveBeenCalledTimes(3);
32
+ });
33
+ it("does not retry non-retryable errors", async () => {
34
+ const err = new TypeError("bad input");
35
+ const fn = vi.fn().mockRejectedValue(err);
36
+ await expect(withRetry(fn, { maxAttempts: 3, initialDelay: 0 }, alwaysRetry)).rejects.toThrow("bad input");
37
+ expect(fn).toHaveBeenCalledOnce();
38
+ });
39
+ it("does not retry when status code not in retryOn list", async () => {
40
+ const err = new CamundaHttpError("not found", 404, null, "http://test");
41
+ const fn = vi.fn().mockRejectedValue(err);
42
+ await expect(withRetry(fn, { maxAttempts: 3, initialDelay: 0, retryOn: [500] }, alwaysRetry)).rejects.toThrow();
43
+ expect(fn).toHaveBeenCalledOnce();
44
+ });
45
+ });
46
+ //# sourceMappingURL=retry.test.js.map
@@ -0,0 +1,42 @@
1
+ import type { CachedToken, TokenStore } from "./types.js";
2
+ /** Returns the OS-appropriate application config directory. */
3
+ export declare function osConfigDir(): string;
4
+ /** Default path for the token cache file. */
5
+ export declare function defaultTokenCachePath(): string;
6
+ /**
7
+ * Builds a cache key for an OAuth2 client.
8
+ * Incorporates clientId, tokenUrl, and optional scope to support
9
+ * multiple clients/clusters from a single cache file.
10
+ */
11
+ export declare function buildTokenCacheKey(clientId: string, tokenUrl: string, scope?: string, audience?: string): string;
12
+ /**
13
+ * Persists OAuth2 tokens to a JSON file in the OS config directory.
14
+ * Multiple clients sharing the same file are safe because Node.js is
15
+ * single-threaded and each write is atomic (synchronous).
16
+ */
17
+ export declare class FileTokenStore implements TokenStore {
18
+ #private;
19
+ constructor(filePath?: string);
20
+ get(key: string): Promise<CachedToken | null>;
21
+ set(key: string, token: CachedToken): Promise<void>;
22
+ get filePath(): string;
23
+ }
24
+ /** No-op store used when the token cache is disabled. */
25
+ export declare class NullTokenStore implements TokenStore {
26
+ get(_key: string): Promise<null>;
27
+ set(_key: string, _token: CachedToken): Promise<void>;
28
+ }
29
+ /**
30
+ * Resolves the effective token store from the config.
31
+ *
32
+ * Priority:
33
+ * 1. `config.store` — custom implementation (Redis, DB, …)
34
+ * 2. `config.disabled` — NullTokenStore (in-memory only)
35
+ * 3. FileTokenStore — default, persists to the OS config dir
36
+ */
37
+ export declare function resolveTokenStore(config: {
38
+ disabled?: boolean;
39
+ filePath?: string;
40
+ store?: TokenStore;
41
+ } | undefined): TokenStore;
42
+ //# sourceMappingURL=token-cache.d.ts.map
@@ -0,0 +1,104 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ /** Returns the OS-appropriate application config directory. */
5
+ export function osConfigDir() {
6
+ if (process.platform === "win32") {
7
+ return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
8
+ }
9
+ if (process.platform === "darwin") {
10
+ return join(homedir(), "Library", "Application Support");
11
+ }
12
+ // Linux / other — respect XDG
13
+ return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
14
+ }
15
+ /** Default path for the token cache file. */
16
+ export function defaultTokenCachePath() {
17
+ return join(osConfigDir(), "camunda-api", "token-cache.json");
18
+ }
19
+ /**
20
+ * Builds a cache key for an OAuth2 client.
21
+ * Incorporates clientId, tokenUrl, and optional scope to support
22
+ * multiple clients/clusters from a single cache file.
23
+ */
24
+ export function buildTokenCacheKey(clientId, tokenUrl, scope, audience) {
25
+ let key = `${clientId}@${tokenUrl}`;
26
+ if (scope)
27
+ key += `:${scope}`;
28
+ if (audience)
29
+ key += `#${audience}`;
30
+ return key;
31
+ }
32
+ // ─── Implementations ──────────────────────────────────────────────────────────
33
+ /**
34
+ * Persists OAuth2 tokens to a JSON file in the OS config directory.
35
+ * Multiple clients sharing the same file are safe because Node.js is
36
+ * single-threaded and each write is atomic (synchronous).
37
+ */
38
+ export class FileTokenStore {
39
+ #filePath;
40
+ constructor(filePath) {
41
+ this.#filePath = filePath ?? defaultTokenCachePath();
42
+ }
43
+ async get(key) {
44
+ try {
45
+ const content = readFileSync(this.#filePath, "utf8");
46
+ const cache = JSON.parse(content);
47
+ const entry = cache[key];
48
+ if (!entry)
49
+ return null;
50
+ // Treat tokens as expired 60 s early so we never hand out a nearly-stale one
51
+ if (Date.now() >= entry.expiresAt - 60_000)
52
+ return null;
53
+ return entry;
54
+ }
55
+ catch {
56
+ // File absent or malformed — treat as cache miss
57
+ return null;
58
+ }
59
+ }
60
+ async set(key, token) {
61
+ try {
62
+ let cache = {};
63
+ try {
64
+ cache = JSON.parse(readFileSync(this.#filePath, "utf8"));
65
+ }
66
+ catch {
67
+ // Start with an empty cache if the file doesn't exist yet
68
+ }
69
+ cache[key] = token;
70
+ mkdirSync(dirname(this.#filePath), { recursive: true });
71
+ writeFileSync(this.#filePath, JSON.stringify(cache, null, 2), "utf8");
72
+ }
73
+ catch (err) {
74
+ // Non-fatal — the in-memory token still works for this session
75
+ process.stderr.write(`[camunda-api] Warning: could not write token cache to ${this.#filePath}: ${err}\n`);
76
+ }
77
+ }
78
+ get filePath() {
79
+ return this.#filePath;
80
+ }
81
+ }
82
+ /** No-op store used when the token cache is disabled. */
83
+ export class NullTokenStore {
84
+ async get(_key) {
85
+ return null;
86
+ }
87
+ async set(_key, _token) { }
88
+ }
89
+ /**
90
+ * Resolves the effective token store from the config.
91
+ *
92
+ * Priority:
93
+ * 1. `config.store` — custom implementation (Redis, DB, …)
94
+ * 2. `config.disabled` — NullTokenStore (in-memory only)
95
+ * 3. FileTokenStore — default, persists to the OS config dir
96
+ */
97
+ export function resolveTokenStore(config) {
98
+ if (config?.store)
99
+ return config.store;
100
+ if (config?.disabled === true)
101
+ return new NullTokenStore();
102
+ return new FileTokenStore(config?.filePath);
103
+ }
104
+ //# sourceMappingURL=token-cache.js.map