toolcraft-openapi 0.0.139 → 0.0.141

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 CHANGED
@@ -50,6 +50,45 @@ export const client = defineGeneratedClient({
50
50
  Generated lower-level group and operation exports remain available when callers
51
51
  want a curated command surface with `defineClient()`.
52
52
 
53
+ ## Live runtime clients
54
+
55
+ `defineClientFromSpec()` materializes the same OpenAPI command model at process startup without
56
+ writing generated source files:
57
+
58
+ ```ts
59
+ const client = await defineClientFromSpec("https://api.example.com/openapi.json", {
60
+ name: "internal-agent",
61
+ baseUrl: "https://api.example.com",
62
+ auth,
63
+ cache: {
64
+ onFallback: (message) => process.stderr.write(`${message}\n`)
65
+ }
66
+ });
67
+ ```
68
+
69
+ HTTP sources use a cross-process disk cache by default when the built-in `fetch` is used.
70
+ Successful documents remain fresh for the server's `Cache-Control: max-age`, or five
71
+ minutes when the server does not provide one. Stale entries revalidate with `If-None-Match` when
72
+ an ETag is available. Fetching and reading the response have a three-second timeout; a transport
73
+ failure or timeout uses the last successfully materialized document. HTTP errors and invalid live
74
+ documents still fail visibly.
75
+
76
+ Runtime source options:
77
+
78
+ - `cache: false` - disables HTTP source caching and offline fallback.
79
+ - `cache.directory` - absolute cache directory override.
80
+ - `cache.maxAgeMs` - fallback freshness when the response does not define cache behavior.
81
+ - `cache.onFallback(message)` - reports when a stale document is used after a transport failure.
82
+ - `onTimeout({ source, timeoutMs, usingCachedDocument })` - application hook for presenting
83
+ network-access guidance without coupling the library to a specific network or VPN.
84
+ - `timeoutMs` - total HTTP fetch/body timeout. Defaults to `3000`; `0` disables it.
85
+
86
+ Supplying a custom `fetch` disables automatic caching unless `cache` is explicitly provided. Use a
87
+ different `cache.directory` for each identity when explicitly caching authenticated custom fetches.
88
+ A custom filesystem participates when it provides `realpath` and the cache write operations;
89
+ read-only filesystems still load the live document without persisting it. New cache entries are
90
+ committed only after the command tree materializes successfully.
91
+
53
92
  ### CI drift check
54
93
 
55
94
  ```sh
@@ -79,6 +118,9 @@ toolcraft-openapi-generate --check
79
118
  - `AUTH_BACKEND` - forwarded to `auth-store` to select `file` or `keychain` storage.
80
119
  - `TOOLCRAFT_OPENAPI_ENV` - selects a configured OpenAPI environment when using
81
120
  `resolveOpenApiBaseUrl`.
121
+ - `TOOLCRAFT_OPENAPI_CACHE_DIR` - absolute directory for live HTTP OpenAPI cache entries.
122
+ - `TOOLCRAFT_OPENAPI_CACHE` - set to `0` or `false` to disable the default live HTTP cache.
123
+ - `XDG_CACHE_HOME` - cache root fallback; defaults to `~/.cache`.
82
124
 
83
125
  ## Configuration
84
126
 
@@ -38,12 +38,12 @@
38
38
  },
39
39
  {
40
40
  "name": "toolcraft-openapi",
41
- "version": "0.0.139",
41
+ "version": "0.0.141",
42
42
  "license": "MIT"
43
43
  },
44
44
  {
45
45
  "name": "toolcraft-schema",
46
- "version": "0.0.139",
46
+ "version": "0.0.141",
47
47
  "license": "MIT"
48
48
  },
49
49
  {
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export type { InspectOpenApiSourceOptions, OpenApiInspectionSource } from "./ins
14
14
  export { renderOpenApiInspection } from "./render-inspection.js";
15
15
  export { commandsFromSpec, defineClientFromSpec, resolveOpenApiBaseUrl } from "./runtime.js";
16
16
  export type { CommandsFromSpecOptions, DefineClientFromSpecOptions, OpenApiDocumentSource } from "./runtime.js";
17
+ export type { OpenApiSpecCacheFileSystem, OpenApiSpecCacheOptions, OpenApiTimeoutContext } from "./spec-cache.js";
17
18
  export type { DefineClientOptions, DefinedClient, OpenApiClientServices } from "./define-client.js";
18
19
  export type { AuthProvider, CommandContributor, TokenSource } from "./auth/types.js";
19
20
  export { bearerTokenAuth } from "./auth/bearer-token-auth.js";
@@ -24,6 +24,12 @@ export function classifyNetworkError(error, url) {
24
24
  return new UserError(`Temporary DNS failure for ${host}. Network may be down.`, {
25
25
  cause: error
26
26
  });
27
+ case "UND_ERR_SOCKET":
28
+ return new UserError(`Network connection failed: ${redactedUrl}.`, { cause: error });
29
+ case "UND_ERR_CONNECT_TIMEOUT":
30
+ case "UND_ERR_HEADERS_TIMEOUT":
31
+ case "UND_ERR_BODY_TIMEOUT":
32
+ return new UserError(`Request timed out: ${redactedUrl}.`, { cause: error });
27
33
  }
28
34
  if (findAbortError(error) !== null) {
29
35
  return new UserError(`Request aborted: ${redactedUrl}.`, { cause: error });
package/dist/runtime.d.ts CHANGED
@@ -2,12 +2,15 @@ import { type CommandNode } from "toolcraft";
2
2
  import { type DefineClientOptions, type DefinedClient, type OpenApiClientServices } from "./define-client.js";
3
3
  import { type OpenApiDocument } from "./generate.js";
4
4
  import type { ToolcraftConfig } from "./config.js";
5
- import { type OpenApiSourceFileSystem } from "./spec-source.js";
5
+ import { type OpenApiSpecCacheFileSystem, type OpenApiSpecCacheOptions, type OpenApiTimeoutContext } from "./spec-cache.js";
6
6
  export type OpenApiDocumentSource = OpenApiDocument | string | URL;
7
7
  export interface CommandsFromSpecOptions {
8
8
  cwd?: string;
9
9
  fetch?: typeof globalThis.fetch;
10
- fs?: OpenApiSourceFileSystem;
10
+ fs?: OpenApiSpecCacheFileSystem;
11
+ cache?: false | OpenApiSpecCacheOptions;
12
+ onTimeout?: (context: OpenApiTimeoutContext) => void | Promise<void>;
13
+ timeoutMs?: number;
11
14
  config?: ToolcraftConfig;
12
15
  }
13
16
  export type DefineClientFromSpecOptions<TServices extends object = Record<string, never>> = Omit<DefineClientOptions<TServices>, "baseUrl" | "commands"> & CommandsFromSpecOptions & {
package/dist/runtime.js CHANGED
@@ -5,15 +5,27 @@ import { collectTagDescriptions, collectSchemaOptionEntries, collectGeneratedCom
5
5
  import { groupByNoun } from "./group-by-noun.js";
6
6
  import { prepareMultipartFileInputs, requestJson, writeBinaryResponseOutput } from "./http.js";
7
7
  import { buildRequestShape, executePreflightBlocks } from "./interpreter.js";
8
+ import { DEFAULT_OPENAPI_FETCH_TIMEOUT_MS, loadCachedOpenApiSource } from "./spec-cache.js";
8
9
  import { parseOpenApiDocument, readOpenApiSourceText } from "./spec-source.js";
9
10
  const RUNTIME_COMMAND_SCOPE = ["cli", "mcp", "sdk"];
10
11
  export async function commandsFromSpec(source, options = {}) {
11
- const document = await resolveDocument(source, options);
12
- return createRuntimeNodes(document, options.config);
12
+ const resolved = await resolveDocument(source, options);
13
+ const commands = createRuntimeNodes(resolved.document, options.config);
14
+ await resolved.commit?.();
15
+ return commands;
13
16
  }
14
17
  export async function defineClientFromSpec(spec, options) {
15
- const { cwd, fetch, fs: specFs, config, baseUrl, environment, env, ...clientOptions } = options;
16
- const document = await resolveDocument(spec, { cwd, fetch, fs: specFs, config });
18
+ const { cwd, fetch, fs: specFs, cache, onTimeout, timeoutMs, config, baseUrl, environment, env, ...clientOptions } = options;
19
+ const resolved = await resolveDocument(spec, {
20
+ cwd,
21
+ fetch,
22
+ fs: specFs,
23
+ cache,
24
+ onTimeout,
25
+ timeoutMs,
26
+ config
27
+ });
28
+ const document = resolved.document;
17
29
  const resolvedBaseUrl = baseUrl ??
18
30
  resolveOpenApiBaseUrl({
19
31
  document,
@@ -25,18 +37,51 @@ export async function defineClientFromSpec(spec, options) {
25
37
  throw new UserError("defineClientFromSpec could not resolve a base URL. Pass baseUrl, configure an environment, or define OpenAPI servers[0].url.");
26
38
  }
27
39
  const commands = createRuntimeNodes(document, config);
28
- return defineClient({ ...clientOptions, baseUrl: resolvedBaseUrl, commands });
40
+ const client = defineClient({ ...clientOptions, baseUrl: resolvedBaseUrl, commands });
41
+ await resolved.commit?.();
42
+ return client;
29
43
  }
30
44
  async function resolveDocument(source, options) {
31
45
  if (typeof source !== "string" && !(source instanceof URL)) {
32
- return source;
46
+ return { document: source };
47
+ }
48
+ const sourceUrl = source instanceof URL ? source : tryParseUrl(source);
49
+ if (sourceUrl !== null && (sourceUrl.protocol === "http:" || sourceUrl.protocol === "https:")) {
50
+ const loaded = await loadCachedOpenApiSource(sourceUrl, {
51
+ cache: options.cache ?? resolveDefaultCache(options),
52
+ fetch: options.fetch ?? globalThis.fetch,
53
+ fs: options.fs ?? fs,
54
+ onTimeout: options.onTimeout,
55
+ timeoutMs: options.timeoutMs ?? DEFAULT_OPENAPI_FETCH_TIMEOUT_MS
56
+ });
57
+ return {
58
+ document: loaded.document,
59
+ ...(loaded.commit === undefined ? {} : { commit: loaded.commit })
60
+ };
33
61
  }
34
62
  const sourceText = await readOpenApiSourceText(source, {
35
63
  cwd: options.cwd ?? process.cwd(),
36
64
  fetch: options.fetch ?? globalThis.fetch,
37
65
  fs: options.fs ?? fs
38
66
  });
39
- return parseOpenApiDocument(sourceText, source);
67
+ return { document: parseOpenApiDocument(sourceText, source) };
68
+ }
69
+ function resolveDefaultCache(options) {
70
+ if (options.fetch !== undefined) {
71
+ return false;
72
+ }
73
+ const configured = Object.prototype.hasOwnProperty.call(process.env, "TOOLCRAFT_OPENAPI_CACHE")
74
+ ? process.env.TOOLCRAFT_OPENAPI_CACHE?.trim().toLowerCase()
75
+ : undefined;
76
+ return configured === "0" || configured === "false" ? false : {};
77
+ }
78
+ function tryParseUrl(value) {
79
+ try {
80
+ return new URL(value);
81
+ }
82
+ catch {
83
+ return null;
84
+ }
40
85
  }
41
86
  export function resolveOpenApiBaseUrl(options) {
42
87
  const environments = options.environments;
@@ -0,0 +1,41 @@
1
+ import { type OpenApiSourceFileSystem } from "./spec-source.js";
2
+ import type { OpenApiDocument } from "./generate.js";
3
+ export declare const DEFAULT_OPENAPI_FETCH_TIMEOUT_MS = 3000;
4
+ export declare const DEFAULT_OPENAPI_CACHE_MAX_AGE_MS: number;
5
+ export interface OpenApiSpecCacheOptions {
6
+ directory?: string;
7
+ maxAgeMs?: number;
8
+ onFallback?: (message: string) => void | Promise<void>;
9
+ }
10
+ export interface LoadCachedOpenApiSourceOptions {
11
+ cache: false | OpenApiSpecCacheOptions;
12
+ fetch: typeof globalThis.fetch;
13
+ fs: OpenApiSpecCacheFileSystem;
14
+ onTimeout?: (context: OpenApiTimeoutContext) => void | Promise<void>;
15
+ timeoutMs?: number;
16
+ }
17
+ export interface OpenApiTimeoutContext {
18
+ source: string;
19
+ timeoutMs: number;
20
+ usingCachedDocument: boolean;
21
+ }
22
+ export interface OpenApiSpecCacheFileSystem extends OpenApiSourceFileSystem {
23
+ writeFile?(filePath: string, contents: string, options?: {
24
+ encoding?: BufferEncoding;
25
+ flag?: string;
26
+ mode?: number;
27
+ }): Promise<void>;
28
+ rename?(fromPath: string, toPath: string): Promise<void>;
29
+ mkdir?(directoryPath: string, options?: {
30
+ recursive?: boolean;
31
+ mode?: number;
32
+ }): Promise<unknown>;
33
+ unlink?(filePath: string): Promise<void>;
34
+ realpath?(filePath: string): Promise<string>;
35
+ }
36
+ export interface LoadedOpenApiSource {
37
+ sourceText: string;
38
+ document: OpenApiDocument;
39
+ commit?: () => Promise<void>;
40
+ }
41
+ export declare function loadCachedOpenApiSource(inputUrl: URL, options: LoadCachedOpenApiSourceOptions): Promise<LoadedOpenApiSource>;
@@ -0,0 +1,376 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { UserError } from "toolcraft";
5
+ import { hasOwnErrorCode } from "./error-codes.js";
6
+ import { fetchOpenApiHttpSource, OpenApiTimeoutError, OpenApiTransportError, parseOpenApiDocument } from "./spec-source.js";
7
+ import { redactSensitiveQueryValues } from "./redaction.js";
8
+ export const DEFAULT_OPENAPI_FETCH_TIMEOUT_MS = 3_000;
9
+ export const DEFAULT_OPENAPI_CACHE_MAX_AGE_MS = 5 * 60_000;
10
+ export async function loadCachedOpenApiSource(inputUrl, options) {
11
+ validateTimeout(options.timeoutMs);
12
+ if (options.cache === false) {
13
+ let result;
14
+ try {
15
+ result = await fetchOpenApiHttpSource(inputUrl, options.fetch, {
16
+ timeoutMs: options.timeoutMs
17
+ });
18
+ }
19
+ catch (error) {
20
+ notifyTimeout(options, inputUrl, error, false);
21
+ throw error;
22
+ }
23
+ if (result.status === "not-modified") {
24
+ throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached document.`);
25
+ }
26
+ return {
27
+ sourceText: result.sourceText,
28
+ document: parseOpenApiDocument(result.sourceText, inputUrl)
29
+ };
30
+ }
31
+ validateMaxAge(options.cache.maxAgeMs);
32
+ const directory = resolveCacheDirectory(options.cache.directory);
33
+ const cachePath = resolveCachePath(inputUrl, directory);
34
+ const cached = await readCacheEntry(cachePath, options.fs);
35
+ const now = Date.now();
36
+ const freshnessMs = cached?.entry.maxAgeMs;
37
+ if (cached !== null &&
38
+ cached.entry.validatedAt <= now &&
39
+ freshnessMs !== undefined &&
40
+ now - cached.entry.validatedAt < freshnessMs) {
41
+ return {
42
+ sourceText: cached.entry.sourceText,
43
+ document: cached.document
44
+ };
45
+ }
46
+ let result;
47
+ try {
48
+ result = await fetchOpenApiHttpSource(inputUrl, options.fetch, {
49
+ ...(cached?.entry.etag === undefined ? {} : { etag: cached.entry.etag }),
50
+ timeoutMs: options.timeoutMs
51
+ });
52
+ }
53
+ catch (error) {
54
+ notifyTimeout(options, inputUrl, error, cached !== null);
55
+ if (!(error instanceof OpenApiTransportError) || cached === null) {
56
+ throw error;
57
+ }
58
+ notifyFallback(options.cache, inputUrl, error);
59
+ return {
60
+ sourceText: cached.entry.sourceText,
61
+ document: cached.document
62
+ };
63
+ }
64
+ const writableFs = getWritableFileSystem(options.fs);
65
+ if (result.status === "not-modified") {
66
+ if (cached === null) {
67
+ throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached document.`);
68
+ }
69
+ if (cached.entry.etag === undefined) {
70
+ throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached validator.`);
71
+ }
72
+ const policy = result.cacheControl === undefined
73
+ ? {
74
+ store: true,
75
+ maxAgeMs: Math.max(0, cached.entry.maxAgeMs - (parseDeltaSeconds(result.age ?? "") ?? 0) * 1_000)
76
+ }
77
+ : resolveCachePolicy(result.cacheControl, result.age, options.cache.maxAgeMs, cached.entry.maxAgeMs);
78
+ if (!policy.store) {
79
+ return {
80
+ sourceText: cached.entry.sourceText,
81
+ document: cached.document,
82
+ ...(writableFs === null ? {} : { commit: () => removeCacheEntry(cachePath, writableFs) })
83
+ };
84
+ }
85
+ const entry = {
86
+ ...cached.entry,
87
+ validatedAt: now,
88
+ maxAgeMs: policy.maxAgeMs,
89
+ ...(result.etag === undefined ? {} : { etag: result.etag })
90
+ };
91
+ return {
92
+ sourceText: cached.entry.sourceText,
93
+ document: cached.document,
94
+ ...(writableFs === null
95
+ ? {}
96
+ : { commit: () => persistCacheEntry(cachePath, entry, writableFs) })
97
+ };
98
+ }
99
+ const policy = resolveCachePolicy(result.cacheControl, result.age, options.cache.maxAgeMs, DEFAULT_OPENAPI_CACHE_MAX_AGE_MS);
100
+ const document = parseOpenApiDocument(result.sourceText, inputUrl);
101
+ if (!policy.store) {
102
+ return {
103
+ sourceText: result.sourceText,
104
+ document,
105
+ ...(writableFs === null ? {} : { commit: () => removeCacheEntry(cachePath, writableFs) })
106
+ };
107
+ }
108
+ const entry = {
109
+ version: 1,
110
+ sourceText: result.sourceText,
111
+ validatedAt: now,
112
+ maxAgeMs: policy.maxAgeMs,
113
+ ...(result.etag === undefined ? {} : { etag: result.etag })
114
+ };
115
+ return {
116
+ sourceText: result.sourceText,
117
+ document,
118
+ ...(writableFs === null
119
+ ? {}
120
+ : { commit: () => persistCacheEntry(cachePath, entry, writableFs) })
121
+ };
122
+ }
123
+ function notifyTimeout(options, inputUrl, error, usingCachedDocument) {
124
+ if (!(error instanceof OpenApiTimeoutError) || options.onTimeout === undefined) {
125
+ return;
126
+ }
127
+ try {
128
+ void Promise.resolve(options.onTimeout({
129
+ source: redactSensitiveQueryValues(inputUrl.toString()),
130
+ timeoutMs: error.timeoutMs,
131
+ usingCachedDocument
132
+ })).catch(() => undefined);
133
+ }
134
+ catch {
135
+ // A presentation hook cannot change source-loading behavior.
136
+ }
137
+ }
138
+ function resolveCachePolicy(cacheControl, age, configuredMaxAgeMs, fallbackMaxAgeMs) {
139
+ const directives = (cacheControl ?? "")
140
+ .split(",")
141
+ .map((directive) => directive.trim().toLowerCase());
142
+ if (directives.includes("no-store")) {
143
+ return { store: false, maxAgeMs: 0 };
144
+ }
145
+ if (directives.includes("no-cache")) {
146
+ return { store: true, maxAgeMs: 0 };
147
+ }
148
+ for (const directive of directives) {
149
+ if (!directive.startsWith("max-age=")) {
150
+ continue;
151
+ }
152
+ const seconds = parseDeltaSeconds(directive.slice("max-age=".length));
153
+ if (seconds !== null) {
154
+ const responseAge = parseDeltaSeconds(age ?? "") ?? 0;
155
+ return { store: true, maxAgeMs: Math.max(0, seconds - responseAge) * 1_000 };
156
+ }
157
+ }
158
+ return {
159
+ store: true,
160
+ maxAgeMs: configuredMaxAgeMs ?? fallbackMaxAgeMs
161
+ };
162
+ }
163
+ function parseDeltaSeconds(value) {
164
+ const trimmed = value.trim();
165
+ const unquoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')
166
+ ? trimmed.slice(1, -1)
167
+ : trimmed;
168
+ if (unquoted.length === 0) {
169
+ return null;
170
+ }
171
+ const seconds = Number(unquoted);
172
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
173
+ }
174
+ function resolveCacheDirectory(configuredDirectory) {
175
+ const envDirectory = readOwnEnvValue(process.env, "TOOLCRAFT_OPENAPI_CACHE_DIR");
176
+ const xdgCacheHome = readOwnEnvValue(process.env, "XDG_CACHE_HOME");
177
+ const directory = configuredDirectory ?? envDirectory;
178
+ if (directory !== undefined) {
179
+ if (!path.isAbsolute(directory)) {
180
+ throw new UserError("OpenAPI cache directory must be an absolute path.");
181
+ }
182
+ return directory;
183
+ }
184
+ const cacheRoot = xdgCacheHome ?? path.join(os.homedir(), ".cache");
185
+ if (!path.isAbsolute(cacheRoot)) {
186
+ throw new UserError("XDG_CACHE_HOME must be an absolute path.");
187
+ }
188
+ return path.join(cacheRoot, "toolcraft-openapi", "specs");
189
+ }
190
+ function resolveCachePath(inputUrl, directory) {
191
+ const canonicalUrl = new URL(inputUrl);
192
+ canonicalUrl.hash = "";
193
+ const key = createHash("sha256").update(canonicalUrl.toString()).digest("hex");
194
+ return path.join(directory, `${key}.json`);
195
+ }
196
+ async function readCacheEntry(cachePath, fs) {
197
+ if (!hasRealpath(fs)) {
198
+ return null;
199
+ }
200
+ try {
201
+ await assertSafeCachePath(cachePath, fs);
202
+ const parsed = JSON.parse(await fs.readFile(cachePath, "utf8"));
203
+ if (!isCacheEntry(parsed)) {
204
+ return null;
205
+ }
206
+ return {
207
+ entry: sanitizeCacheEntry(parsed),
208
+ document: parseOpenApiDocument(parsed.sourceText, cachePath)
209
+ };
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ function sanitizeCacheEntry(entry) {
216
+ if (entry.etag === undefined || isValidEtag(entry.etag)) {
217
+ return entry;
218
+ }
219
+ const withoutEtag = { ...entry };
220
+ delete withoutEtag.etag;
221
+ return withoutEtag;
222
+ }
223
+ function isValidEtag(etag) {
224
+ try {
225
+ new Headers({ "If-None-Match": etag });
226
+ return true;
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
232
+ async function persistCacheEntry(cachePath, entry, fs) {
233
+ try {
234
+ await assertSafeCachePath(cachePath, fs);
235
+ await fs.mkdir(path.dirname(cachePath), { recursive: true, mode: 0o700 });
236
+ await assertSafeCachePath(cachePath, fs);
237
+ for (let attempt = 0; attempt < 3; attempt += 1) {
238
+ const temporaryPath = `${cachePath}.${randomUUID()}.tmp`;
239
+ let temporaryCreated = false;
240
+ try {
241
+ await fs.writeFile(temporaryPath, JSON.stringify(entry), {
242
+ encoding: "utf8",
243
+ flag: "wx",
244
+ mode: 0o600
245
+ });
246
+ temporaryCreated = true;
247
+ await fs.rename(temporaryPath, cachePath);
248
+ return;
249
+ }
250
+ catch (error) {
251
+ if (temporaryCreated || !hasOwnErrorCode(error, "EEXIST")) {
252
+ await fs.unlink(temporaryPath).catch(() => undefined);
253
+ }
254
+ if (!hasOwnErrorCode(error, "EEXIST")) {
255
+ return;
256
+ }
257
+ }
258
+ }
259
+ }
260
+ catch {
261
+ // Cache writes are best-effort; the materialized live document remains usable.
262
+ }
263
+ }
264
+ async function removeCacheEntry(cachePath, fs) {
265
+ try {
266
+ await assertSafeCachePath(cachePath, fs);
267
+ await fs.unlink(cachePath);
268
+ }
269
+ catch {
270
+ // Cache removal is best-effort; the materialized live document remains usable.
271
+ }
272
+ }
273
+ function getWritableFileSystem(fs) {
274
+ if (typeof fs.writeFile !== "function" ||
275
+ typeof fs.rename !== "function" ||
276
+ typeof fs.mkdir !== "function" ||
277
+ typeof fs.unlink !== "function" ||
278
+ typeof fs.realpath !== "function") {
279
+ return null;
280
+ }
281
+ return fs;
282
+ }
283
+ function hasRealpath(fs) {
284
+ return typeof fs.realpath === "function";
285
+ }
286
+ async function assertSafeCachePath(cachePath, fs) {
287
+ const directory = path.resolve(path.dirname(cachePath));
288
+ let existingPath = directory;
289
+ while (true) {
290
+ try {
291
+ if (path.resolve(await fs.realpath(existingPath)) !== existingPath) {
292
+ throw new UserError("OpenAPI cache path must remain inside its configured directory.");
293
+ }
294
+ break;
295
+ }
296
+ catch (error) {
297
+ if (!hasOwnErrorCode(error, "ENOENT")) {
298
+ throw error;
299
+ }
300
+ const parentPath = path.dirname(existingPath);
301
+ if (parentPath === existingPath) {
302
+ throw error;
303
+ }
304
+ existingPath = parentPath;
305
+ }
306
+ }
307
+ try {
308
+ if (path.resolve(await fs.realpath(directory)) !== directory) {
309
+ throw new UserError("OpenAPI cache path must remain inside its configured directory.");
310
+ }
311
+ }
312
+ catch (error) {
313
+ if (hasOwnErrorCode(error, "ENOENT")) {
314
+ return;
315
+ }
316
+ throw error;
317
+ }
318
+ try {
319
+ const canonicalCachePath = path.resolve(await fs.realpath(cachePath));
320
+ if (canonicalCachePath !== path.resolve(cachePath)) {
321
+ throw new UserError("OpenAPI cache path must remain inside its configured directory.");
322
+ }
323
+ }
324
+ catch (error) {
325
+ if (!hasOwnErrorCode(error, "ENOENT")) {
326
+ throw error;
327
+ }
328
+ }
329
+ }
330
+ function isCacheEntry(value) {
331
+ if (typeof value !== "object" || value === null) {
332
+ return false;
333
+ }
334
+ const entry = value;
335
+ return (Object.hasOwn(entry, "version") &&
336
+ entry.version === 1 &&
337
+ Object.hasOwn(entry, "sourceText") &&
338
+ typeof entry.sourceText === "string" &&
339
+ Object.hasOwn(entry, "validatedAt") &&
340
+ typeof entry.validatedAt === "number" &&
341
+ Number.isFinite(entry.validatedAt) &&
342
+ Object.hasOwn(entry, "maxAgeMs") &&
343
+ typeof entry.maxAgeMs === "number" &&
344
+ Number.isFinite(entry.maxAgeMs) &&
345
+ entry.maxAgeMs >= 0 &&
346
+ (!Object.hasOwn(entry, "etag") || typeof entry.etag === "string"));
347
+ }
348
+ function notifyFallback(cache, inputUrl, error) {
349
+ if (cache.onFallback === undefined) {
350
+ return;
351
+ }
352
+ const message = error instanceof Error ? error.message : String(error);
353
+ try {
354
+ void Promise.resolve(cache.onFallback(`Using cached OpenAPI document for ${JSON.stringify(redactSensitiveQueryValues(inputUrl.toString()))} because refresh failed: ${message}`)).catch(() => undefined);
355
+ }
356
+ catch {
357
+ // Cache fallback remains usable even if a presentation callback fails.
358
+ }
359
+ }
360
+ function validateTimeout(timeoutMs) {
361
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0)) {
362
+ throw new UserError("OpenAPI fetch timeout must be a finite non-negative number.");
363
+ }
364
+ }
365
+ function validateMaxAge(maxAgeMs) {
366
+ if (maxAgeMs !== undefined && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {
367
+ throw new UserError("OpenAPI cache maxAgeMs must be a finite non-negative number.");
368
+ }
369
+ }
370
+ function readOwnEnvValue(env, key) {
371
+ if (!Object.prototype.hasOwnProperty.call(env, key)) {
372
+ return undefined;
373
+ }
374
+ const value = env[key]?.trim();
375
+ return value === undefined || value.length === 0 ? undefined : value;
376
+ }
@@ -1,3 +1,4 @@
1
+ import { UserError } from "toolcraft";
1
2
  import type { OpenApiDocument } from "./generate.js";
2
3
  export interface OpenApiSourceFileSystem {
3
4
  readFile(filePath: string, encoding: BufferEncoding): Promise<string>;
@@ -7,5 +8,30 @@ export interface OpenApiSourceServices {
7
8
  fetch: typeof globalThis.fetch;
8
9
  fs: OpenApiSourceFileSystem;
9
10
  }
11
+ export interface OpenApiHttpSourceOptions {
12
+ etag?: string;
13
+ timeoutMs?: number;
14
+ }
15
+ export type OpenApiHttpSourceResult = {
16
+ status: "modified";
17
+ sourceText: string;
18
+ etag?: string;
19
+ cacheControl?: string;
20
+ age?: string;
21
+ } | {
22
+ status: "not-modified";
23
+ etag?: string;
24
+ cacheControl?: string;
25
+ age?: string;
26
+ };
27
+ export declare class OpenApiHttpStatusError extends UserError {
28
+ }
29
+ export declare class OpenApiTransportError extends UserError {
30
+ }
31
+ export declare class OpenApiTimeoutError extends OpenApiTransportError {
32
+ readonly timeoutMs: number;
33
+ constructor(message: string, timeoutMs: number, options?: ErrorOptions);
34
+ }
10
35
  export declare function readOpenApiSourceText(input: string | URL, services: OpenApiSourceServices): Promise<string>;
36
+ export declare function fetchOpenApiHttpSource(inputUrl: URL, fetch: typeof globalThis.fetch, options?: OpenApiHttpSourceOptions): Promise<OpenApiHttpSourceResult>;
11
37
  export declare function parseOpenApiDocument(sourceText: string, input: string | URL): OpenApiDocument;
@@ -4,6 +4,18 @@ import { parse as parseYaml } from "yaml";
4
4
  import { UserError } from "toolcraft";
5
5
  import { renderSourceSnippet } from "toolcraft/source-snippet";
6
6
  import { classifyNetworkError } from "./network-error.js";
7
+ import { redactSensitiveQueryValues } from "./redaction.js";
8
+ export class OpenApiHttpStatusError extends UserError {
9
+ }
10
+ export class OpenApiTransportError extends UserError {
11
+ }
12
+ export class OpenApiTimeoutError extends OpenApiTransportError {
13
+ timeoutMs;
14
+ constructor(message, timeoutMs, options) {
15
+ super(message, options);
16
+ this.timeoutMs = timeoutMs;
17
+ }
18
+ }
7
19
  export async function readOpenApiSourceText(input, services) {
8
20
  const inputUrl = input instanceof URL ? input : tryParseUrl(input);
9
21
  const sourceLabel = formatSourceLabel(input);
@@ -20,29 +32,118 @@ export async function readOpenApiSourceText(input, services) {
20
32
  if (inputUrl.protocol !== "http:" && inputUrl.protocol !== "https:") {
21
33
  throw new UserError(`Unsupported OpenAPI input URL protocol ${JSON.stringify(inputUrl.protocol)}.`);
22
34
  }
35
+ const result = await fetchOpenApiHttpSource(inputUrl, services.fetch);
36
+ if (result.status === "not-modified") {
37
+ throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached document.`);
38
+ }
39
+ return result.sourceText;
40
+ }
41
+ catch (error) {
42
+ if (error instanceof UserError) {
43
+ throw error;
44
+ }
45
+ throw new UserError(`Failed to read OpenAPI document ${JSON.stringify(sourceLabel)}: ${getErrorMessage(error)}`);
46
+ }
47
+ }
48
+ export async function fetchOpenApiHttpSource(inputUrl, fetch, options = {}) {
49
+ validateTimeout(options.timeoutMs);
50
+ const url = inputUrl.toString();
51
+ const timeoutMs = options.timeoutMs;
52
+ const controller = timeoutMs === undefined || timeoutMs === 0 ? undefined : new AbortController();
53
+ const requestInit = createOpenApiRequestInit(options.etag, controller?.signal);
54
+ const request = async () => {
23
55
  let response;
24
56
  try {
25
- response = await services.fetch(inputUrl.toString());
57
+ response = requestInit === undefined ? await fetch(url) : await fetch(url, requestInit);
26
58
  }
27
59
  catch (error) {
28
- throw classifyNetworkError(error, inputUrl.toString()) ?? error;
60
+ throw toTransportError(error, url) ?? error;
61
+ }
62
+ if (response.status === 304) {
63
+ return {
64
+ status: "not-modified",
65
+ ...readResponseCacheHeaders(response)
66
+ };
29
67
  }
30
68
  if (!response.ok) {
31
69
  const contentType = response.headers.get("content-type") ?? "";
32
70
  const text = await response.text().catch(() => "");
33
71
  const snippet = text.length === 0 ? "" : `\n body: ${truncate(text, 500)}`;
34
- throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: ` +
72
+ throw new OpenApiHttpStatusError(`Failed to fetch ${JSON.stringify(url)}: ` +
35
73
  `${response.status} ${response.statusText}` +
36
74
  (contentType ? ` (content-type: ${contentType})` : "") +
37
75
  snippet);
38
76
  }
39
- return await response.text();
77
+ try {
78
+ return {
79
+ status: "modified",
80
+ sourceText: await response.text(),
81
+ ...readResponseCacheHeaders(response)
82
+ };
83
+ }
84
+ catch (error) {
85
+ throw (toTransportError(error, url) ??
86
+ new OpenApiTransportError(`Failed to read the OpenAPI response body from ${JSON.stringify(redactSensitiveQueryValues(url))}.`, { cause: error }));
87
+ }
88
+ };
89
+ if (controller === undefined || timeoutMs === undefined) {
90
+ return await request();
40
91
  }
41
- catch (error) {
42
- if (error instanceof UserError) {
43
- throw error;
92
+ const timeoutCause = Object.assign(new Error("OpenAPI request timed out"), {
93
+ code: "ETIMEDOUT",
94
+ timeout: timeoutMs
95
+ });
96
+ const classified = classifyNetworkError(timeoutCause, url);
97
+ const timeoutError = new OpenApiTimeoutError(classified?.message ?? `OpenAPI request timed out after ${timeoutMs}ms.`, timeoutMs, { cause: timeoutCause });
98
+ let timeout;
99
+ try {
100
+ return await Promise.race([
101
+ request(),
102
+ new Promise((_resolve, reject) => {
103
+ timeout = setTimeout(() => {
104
+ reject(timeoutError);
105
+ controller.abort(timeoutError);
106
+ }, timeoutMs);
107
+ })
108
+ ]);
109
+ }
110
+ finally {
111
+ if (timeout !== undefined) {
112
+ clearTimeout(timeout);
44
113
  }
45
- throw new UserError(`Failed to read OpenAPI document ${JSON.stringify(sourceLabel)}: ${getErrorMessage(error)}`);
114
+ }
115
+ }
116
+ function toTransportError(error, url) {
117
+ if (error instanceof OpenApiTransportError) {
118
+ return error;
119
+ }
120
+ const classified = classifyNetworkError(error, url);
121
+ return classified === null
122
+ ? null
123
+ : new OpenApiTransportError(classified.message, { cause: error });
124
+ }
125
+ function createOpenApiRequestInit(etag, signal) {
126
+ if (etag === undefined && signal === undefined) {
127
+ return undefined;
128
+ }
129
+ return {
130
+ ...(etag === undefined ? {} : { headers: { "If-None-Match": etag } }),
131
+ ...(signal === undefined ? {} : { signal })
132
+ };
133
+ }
134
+ function readResponseCacheHeaders(response) {
135
+ const etag = response.headers.get("etag") ?? undefined;
136
+ const cacheControl = response.headers.get("cache-control") ?? undefined;
137
+ const age = response.headers.get("age") ?? undefined;
138
+ return {
139
+ ...(etag === undefined ? {} : { etag }),
140
+ ...(cacheControl === undefined ? {} : { cacheControl }),
141
+ ...(age === undefined ? {} : { age })
142
+ };
143
+ }
144
+ function validateTimeout(timeoutMs) {
145
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0)) {
146
+ throw new UserError("OpenAPI fetch timeout must be a finite non-negative number.");
46
147
  }
47
148
  }
48
149
  export function parseOpenApiDocument(sourceText, input) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft-schema",
3
- "version": "0.0.139",
3
+ "version": "0.0.141",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft-openapi",
3
- "version": "0.0.139",
3
+ "version": "0.0.141",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,7 +30,7 @@
30
30
  "toolcraft-openapi-generate": "dist/bin/generate.js"
31
31
  },
32
32
  "dependencies": {
33
- "toolcraft": "0.0.139",
33
+ "toolcraft": "0.0.141",
34
34
  "auth-store": "^0.0.1",
35
35
  "fast-string-width": "^3.0.2",
36
36
  "fast-wrap-ansi": "^0.2.0",
@@ -46,7 +46,7 @@
46
46
  "directory": "packages/toolcraft-openapi"
47
47
  },
48
48
  "optionalDependencies": {
49
- "toolcraft-schema": "0.0.139",
49
+ "toolcraft-schema": "0.0.141",
50
50
  "toolcraft-design": "*",
51
51
  "@poe-code/frontmatter": "*"
52
52
  },