hanzio 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +70 -4
  2. package/dist/api-wrapper/builders.d.ts +13 -2
  3. package/dist/api-wrapper/errors.d.ts +1 -0
  4. package/dist/api-wrapper/headers.d.ts +2 -1
  5. package/dist/api-wrapper/transport.d.ts +8 -5
  6. package/dist/api-wrapper/types.d.ts +1 -0
  7. package/dist/cache/index.d.ts +26 -0
  8. package/dist/secrets/core.d.ts +47 -0
  9. package/dist/secrets/deadline.d.ts +6 -0
  10. package/dist/secrets/environment.d.ts +9 -0
  11. package/dist/secrets/errors.d.ts +11 -0
  12. package/dist/secrets/index.d.ts +13 -25
  13. package/dist/secrets/infisical-cli.d.ts +6 -0
  14. package/dist/secrets/infisical-config.d.ts +17 -0
  15. package/dist/secrets/infisical-session.d.ts +4 -0
  16. package/dist/secrets/infisical.d.ts +8 -13
  17. package/dist/secrets/loaders.d.ts +5 -12
  18. package/dist/secrets/vite.d.ts +7 -3
  19. package/dist/secrets/worker-loader.d.ts +9 -0
  20. package/dist/secrets/worker.d.ts +3 -0
  21. package/dist/src/api-wrapper/index.js +152 -122
  22. package/dist/src/index.js +3173 -2713
  23. package/dist/src/jwt/index.js +2 -2
  24. package/dist/src/p-queue/index.js +3 -3
  25. package/dist/src/secrets/index.js +670 -402
  26. package/dist/src/secrets/infisical.js +451 -168
  27. package/dist/src/secrets/loaders.js +467 -174
  28. package/dist/src/secrets/vite.js +33 -11
  29. package/dist/src/secrets/worker.js +275 -0
  30. package/dist/src/sitemap/index.js +1 -1
  31. package/dist/src/zod/index.js +7 -7
  32. package/dist/typedswitch/index.d.ts +9 -5
  33. package/package.json +12 -3
  34. package/src/api-wrapper/builders.ts +68 -43
  35. package/src/api-wrapper/create-client.ts +82 -62
  36. package/src/api-wrapper/errors.ts +10 -0
  37. package/src/api-wrapper/headers.ts +14 -1
  38. package/src/api-wrapper/redaction.ts +1 -0
  39. package/src/api-wrapper/transport.ts +34 -38
  40. package/src/api-wrapper/types.ts +1 -0
  41. package/src/cache/index.ts +228 -61
  42. package/src/secrets/core.ts +348 -0
  43. package/src/secrets/deadline.ts +69 -0
  44. package/src/secrets/environment.ts +69 -0
  45. package/src/secrets/errors.ts +29 -0
  46. package/src/secrets/index.ts +79 -210
  47. package/src/secrets/infisical-cli.ts +99 -0
  48. package/src/secrets/infisical-config.ts +70 -0
  49. package/src/secrets/infisical-session.ts +42 -0
  50. package/src/secrets/infisical.ts +214 -66
  51. package/src/secrets/loaders.ts +24 -48
  52. package/src/secrets/vite.ts +44 -15
  53. package/src/secrets/worker-loader.ts +56 -0
  54. package/src/secrets/worker.ts +4 -0
  55. package/src/typedswitch/index.ts +80 -69
@@ -1,19 +1,41 @@
1
+ // src/secrets/errors.ts
2
+ class SecretLoadError extends Error {
3
+ code;
4
+ operation;
5
+ status;
6
+ constructor(code, message, details = {}) {
7
+ super(message);
8
+ this.name = "SecretLoadError";
9
+ this.code = code;
10
+ this.operation = details.operation;
11
+ this.status = details.status;
12
+ }
13
+ }
14
+
1
15
  // src/secrets/vite.ts
2
- function getViteDefine(secretSet) {
3
- return Object.fromEntries(Object.entries(secretSet.secrets()).map(([key, value]) => [
4
- `import.meta.env.${key}`,
5
- JSON.stringify(value)
6
- ]));
16
+ function getViteDefine(secretSet, options = {}) {
17
+ const values = secretSet.secrets();
18
+ const keys = options.publicKeys ?? Object.keys(values);
19
+ if (!options.publicKeys && keys.some((key) => !key.startsWith("VITE_"))) {
20
+ throw new SecretLoadError("CONFIGURATION", "Vite secret sets must contain only VITE_ keys, or provide an explicit publicKeys list.");
21
+ }
22
+ for (const key of keys) {
23
+ if (!Object.hasOwn(values, key) || !/^[A-Za-z_$][\w$]*$/.test(key)) {
24
+ throw new SecretLoadError("CONFIGURATION", "Vite publicKeys must reference configured keys with valid JavaScript identifiers.");
25
+ }
26
+ if (["MODE", "BASE_URL", "PROD", "DEV", "SSR"].includes(key)) {
27
+ throw new SecretLoadError("CONFIGURATION", "Vite built-in environment constants cannot be overridden by secrets.");
28
+ }
29
+ }
30
+ return Object.fromEntries(keys.map((key) => [`import.meta.env.${key}`, JSON.stringify(values[key])]));
7
31
  }
8
- function viteSecretSetPlugin(secretSet) {
32
+ function viteSecretSetPlugin(secretSet, options = {}) {
9
33
  return {
10
34
  name: "hanzio-secret-set",
11
- config: () => ({
12
- define: getViteDefine(secretSet)
13
- })
35
+ config: () => ({ define: getViteDefine(secretSet, options) })
14
36
  };
15
37
  }
16
38
  export {
17
- viteSecretSetPlugin,
18
- getViteDefine
39
+ getViteDefine,
40
+ viteSecretSetPlugin
19
41
  };
@@ -0,0 +1,275 @@
1
+ // src/secrets/errors.ts
2
+ class SecretLoadError extends Error {
3
+ code;
4
+ operation;
5
+ status;
6
+ constructor(code, message, details = {}) {
7
+ super(message);
8
+ this.name = "SecretLoadError";
9
+ this.code = code;
10
+ this.operation = details.operation;
11
+ this.status = details.status;
12
+ }
13
+ }
14
+
15
+ // src/secrets/deadline.ts
16
+ async function withSecretDeadline(options, operation) {
17
+ const timeoutMs = options.timeoutMs ?? 30000;
18
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) {
19
+ throw new SecretLoadError("CONFIGURATION", "Secret timeoutMs must be a positive finite timer duration.");
20
+ }
21
+ const controller = new AbortController;
22
+ const cancel = () => controller.abort(new SecretLoadError("ABORTED", "Secret loading was cancelled."));
23
+ if (options.signal?.aborted) {
24
+ cancel();
25
+ } else {
26
+ options.signal?.addEventListener("abort", cancel, { once: true });
27
+ }
28
+ const timer = setTimeout(() => controller.abort(new SecretLoadError("TIMEOUT", `Secret loading timed out after ${timeoutMs}ms.`)), timeoutMs);
29
+ let rejectOnAbort = () => {};
30
+ try {
31
+ const aborted = new Promise((_, reject) => {
32
+ rejectOnAbort = () => reject(controller.signal.reason);
33
+ controller.signal.addEventListener("abort", rejectOnAbort, { once: true });
34
+ });
35
+ if (controller.signal.aborted) {
36
+ throw controller.signal.reason;
37
+ }
38
+ return await Promise.race([operation(controller.signal), aborted]);
39
+ } finally {
40
+ clearTimeout(timer);
41
+ options.signal?.removeEventListener("abort", cancel);
42
+ controller.signal.removeEventListener("abort", rejectOnAbort);
43
+ }
44
+ }
45
+
46
+ // src/secrets/environment.ts
47
+ function getProcessEnvironment() {
48
+ return typeof process === "undefined" ? undefined : process.env;
49
+ }
50
+ function resolveSecretEnvironment(option, env) {
51
+ const explicit = typeof option === "function" ? option() : option;
52
+ if (typeof explicit === "object" && explicit !== null) {
53
+ if (typeof explicit.slug !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(explicit.slug)) {
54
+ throw new SecretLoadError("CONFIGURATION", "Custom secret environment requires a valid slug.");
55
+ }
56
+ return explicit.slug;
57
+ }
58
+ const selected = explicit ?? env?.SECRETS_ENV;
59
+ if (selected !== undefined) {
60
+ if (selected === "production" || selected === "prod") {
61
+ return "prod";
62
+ }
63
+ if (selected === "development" || selected === "dev") {
64
+ return "dev";
65
+ }
66
+ if (selected === "staging") {
67
+ return "staging";
68
+ }
69
+ throw new SecretLoadError("CONFIGURATION", "Invalid secret environment. Use dev, staging, prod, or an explicit { slug } option.");
70
+ }
71
+ if (env?.NODE_ENV === "production" || env?.NODE_ENV === "prod") {
72
+ return "prod";
73
+ }
74
+ if (env?.NODE_ENV === "staging") {
75
+ return "staging";
76
+ }
77
+ return "dev";
78
+ }
79
+
80
+ // src/secrets/core.ts
81
+ var registryKey = Symbol.for("hanzio.secrets.environmentWrites.v1");
82
+ var shared = globalThis;
83
+ var environmentWrites = shared[registryKey] ?? new WeakMap;
84
+ shared[registryKey] = environmentWrites;
85
+ var defaultLogger = {
86
+ info: (message, attrs) => console.log(`${message} environment=${attrs.environment} count=${attrs.count} source=${attrs.source} durationMs=${attrs.durationMs}`, { sources: attrs.sources }),
87
+ error: (message, attrs) => console.error(message, attrs)
88
+ };
89
+ function readOverrides(keys, env) {
90
+ if (!env) {
91
+ return {};
92
+ }
93
+ const writes = environmentWrites.get(env);
94
+ return Object.fromEntries(keys.flatMap((key) => {
95
+ const value = Object.hasOwn(env, key) ? env[key] : undefined;
96
+ if (writes?.has(key)) {
97
+ if (writes.get(key) === value) {
98
+ return [];
99
+ }
100
+ writes.delete(key);
101
+ }
102
+ return value ? [[key, value]] : [];
103
+ }));
104
+ }
105
+ async function defineSecretSet(keys, options, environmentVariables) {
106
+ const keySet = [...new Set(keys)];
107
+ const environment = resolveSecretEnvironment(options.environment, environmentVariables);
108
+ const logger = options.logger === false ? undefined : options.logger ?? defaultLogger;
109
+ let cached;
110
+ let parsed;
111
+ let sources;
112
+ let inFlight;
113
+ const log = (level, message, attributes) => {
114
+ try {
115
+ logger?.[level](message, attributes);
116
+ } catch {}
117
+ };
118
+ const loadSnapshot = async (request, signal) => {
119
+ const local = readOverrides(keySet, environmentVariables);
120
+ const missing = keySet.filter((key) => !Object.hasOwn(local, key));
121
+ let loaderSource = options.loader.source ?? "custom loader";
122
+ let loaded = {};
123
+ if (missing.length) {
124
+ try {
125
+ loaded = await options.loader({
126
+ keys: missing,
127
+ environment,
128
+ signal,
129
+ timeoutMs: request.timeoutMs ?? options.timeoutMs ?? 30000,
130
+ reportSource: (source) => {
131
+ loaderSource = source;
132
+ }
133
+ });
134
+ } catch (error) {
135
+ if (error instanceof SecretLoadError) {
136
+ throw error;
137
+ }
138
+ throw new SecretLoadError("LOADER", "Secret loader failed.");
139
+ }
140
+ }
141
+ const overrides = readOverrides(keySet, environmentVariables);
142
+ const next = Object.fromEntries(keySet.map((key) => {
143
+ if (Object.hasOwn(overrides, key)) {
144
+ return [key, overrides[key]];
145
+ }
146
+ if (Object.hasOwn(loaded, key)) {
147
+ return [key, loaded[key]];
148
+ }
149
+ return [key, undefined];
150
+ }));
151
+ const missingKeys = keySet.filter((key) => typeof next[key] !== "string" || !next[key]);
152
+ if (missingKeys.length) {
153
+ throw new SecretLoadError("MISSING_SECRETS", `Missing secrets: ${missingKeys.join(", ")}`);
154
+ }
155
+ let nextParsed;
156
+ try {
157
+ nextParsed = options.schema ? await options.schema.parseAsync({ ...next }) : { ...next };
158
+ nextParsed = structuredClone(nextParsed);
159
+ } catch {
160
+ throw new SecretLoadError("VALIDATION", "Secret schema validation failed. Check the configured schema; values are omitted.");
161
+ }
162
+ signal.throwIfAborted();
163
+ const nextSources = Object.fromEntries(keySet.map((key) => [
164
+ key,
165
+ Object.hasOwn(overrides, key) ? "process.env" : loaderSource
166
+ ]));
167
+ if (options.writeToProcessEnv !== false && environmentVariables) {
168
+ let writes = environmentWrites.get(environmentVariables);
169
+ if (!writes) {
170
+ writes = new Map;
171
+ environmentWrites.set(environmentVariables, writes);
172
+ }
173
+ for (const key of keySet) {
174
+ if (Object.hasOwn(overrides, key)) {
175
+ continue;
176
+ }
177
+ environmentVariables[key] = next[key];
178
+ writes.set(key, next[key]);
179
+ }
180
+ }
181
+ cached = next;
182
+ parsed = nextParsed;
183
+ sources = nextSources;
184
+ };
185
+ const load = (request = {}) => {
186
+ if (inFlight) {
187
+ return inFlight;
188
+ }
189
+ inFlight = (async () => {
190
+ const start = Date.now();
191
+ try {
192
+ await withSecretDeadline({
193
+ timeoutMs: request.timeoutMs ?? options.timeoutMs,
194
+ signal: request.signal ?? options.signal
195
+ }, (signal) => loadSnapshot(request, signal));
196
+ const sourceCounts = new Map;
197
+ for (const key of keySet) {
198
+ const source = sources[key];
199
+ sourceCounts.set(source, (sourceCounts.get(source) ?? 0) + 1);
200
+ }
201
+ const counts = Object.fromEntries(sourceCounts);
202
+ log("info", "Loaded secrets", {
203
+ environment,
204
+ count: keySet.length,
205
+ durationMs: Date.now() - start,
206
+ source: Object.keys(counts).join(" + ") || "none",
207
+ sources: counts
208
+ });
209
+ } catch (error) {
210
+ const safe = error instanceof SecretLoadError ? error : new SecretLoadError("LOADER", "Secret loading failed.");
211
+ log("error", "Secret loading failed", {
212
+ environment,
213
+ count: keySet.length,
214
+ durationMs: Date.now() - start,
215
+ code: safe.code
216
+ });
217
+ throw safe;
218
+ }
219
+ })().finally(() => {
220
+ inFlight = undefined;
221
+ });
222
+ return inFlight;
223
+ };
224
+ const secretSet = {
225
+ secret(key) {
226
+ if (!Object.hasOwn(cached, key)) {
227
+ throw new SecretLoadError("MISSING_SECRETS", `Secret ${key} not found`);
228
+ }
229
+ return cached[key];
230
+ },
231
+ secrets: () => ({ ...cached }),
232
+ parsed: () => structuredClone(parsed),
233
+ sources: () => ({ ...sources }),
234
+ async reload(request) {
235
+ await load(request);
236
+ return secretSet;
237
+ }
238
+ };
239
+ await load();
240
+ return secretSet;
241
+ }
242
+ // src/secrets/worker-loader.ts
243
+ function cloudflareWorkerEnvLoader(workerEnv) {
244
+ const loader = async (ctx) => {
245
+ const out = Object.create(null);
246
+ for (const key of ctx.keys) {
247
+ const str = workerBindingToString(Object.hasOwn(workerEnv, key) ? workerEnv[key] : undefined);
248
+ if (str !== undefined) {
249
+ out[key] = str;
250
+ }
251
+ }
252
+ return out;
253
+ };
254
+ return Object.assign(loader, { source: "Cloudflare Worker bindings" });
255
+ }
256
+ function workerBindingToString(value) {
257
+ if (value === undefined || value === null) {
258
+ return;
259
+ }
260
+ if (typeof value === "string") {
261
+ return value === "" ? undefined : value;
262
+ }
263
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
264
+ return String(value);
265
+ }
266
+ if (typeof value === "object") {
267
+ return JSON.stringify(value);
268
+ }
269
+ return String(value);
270
+ }
271
+ export {
272
+ SecretLoadError,
273
+ cloudflareWorkerEnvLoader,
274
+ defineSecretSet
275
+ };
@@ -28,7 +28,7 @@ class PriorityQueue {
28
28
  return this.#queue.filter((item) => Object.entries(options).every(([key, value]) => item[key] === value)).map((item) => item.run);
29
29
  }
30
30
  setPriority(id, priority) {
31
- const index = this.#queue.findIndex((item2) => item2.id === id);
31
+ const index = this.#queue.findIndex((item) => item.id === id);
32
32
  if (index === -1) {
33
33
  throw new ReferenceError(`No promise function with the id "${id}" exists in the queue.`);
34
34
  }
@@ -199,12 +199,12 @@ function normalizeSingleOrArray(value) {
199
199
  return [value];
200
200
  }
201
201
  export {
202
- zodToTypeString,
203
- zodToExample,
204
- zodMinMaxFilter,
205
- zSingleOrArray,
206
- toGteLteFilter,
207
- normalizeSingleOrArray,
202
+ createZId,
208
203
  jsonToZodSchemaMapper,
209
- createZId
204
+ normalizeSingleOrArray,
205
+ toGteLteFilter,
206
+ zSingleOrArray,
207
+ zodMinMaxFilter,
208
+ zodToExample,
209
+ zodToTypeString
210
210
  };
@@ -67,7 +67,11 @@
67
67
  type RawHandlerReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
68
68
  /** Get union of all raw return types from a cases object's values */
69
69
  type RawCasesReturnType<Cases> = {
70
- [K in keyof Cases]: Cases[K] extends undefined ? never : RawHandlerReturnType<Cases[K]>;
70
+ [K in keyof Cases]: RawHandlerReturnType<Cases[K]>;
71
+ }[keyof Cases];
72
+ /** Only required, defined handlers guarantee that a case is handled. */
73
+ type HandledCaseKeys<Cases> = {
74
+ [K in keyof Cases]-?: {} extends Pick<Cases, K> ? never : undefined extends Cases[K] ? never : K;
71
75
  }[keyof Cases];
72
76
  /** Cases for string input - all cases required */
73
77
  export type StringSwitchCases<T extends string, R> = {
@@ -90,13 +94,13 @@ export declare function typedSwitch<T extends string, Cases extends {
90
94
  }>(value: T, cases: Cases): RawCasesReturnType<Cases>;
91
95
  export declare function typedSwitch<T extends string, Cases extends {
92
96
  [K in T]?: (value: K) => unknown;
93
- }, DefaultCase extends (value: T) => unknown>(value: T, cases: Cases, defaultCase: DefaultCase): RawCasesReturnType<Cases> | RawHandlerReturnType<DefaultCase>;
94
- export declare function typedSwitch<T extends Record<string, unknown>, K extends keyof T, TKey extends T[K] & string, Cases extends {
97
+ }, DefaultCase extends (value: Exclude<T, HandledCaseKeys<Cases>>) => unknown>(value: T, cases: Cases, defaultCase: DefaultCase): RawCasesReturnType<Cases> | RawHandlerReturnType<DefaultCase>;
98
+ export declare function typedSwitch<T extends object, K extends keyof T, TKey extends T[K] & string, Cases extends {
95
99
  [V in TKey]: (value: Extract<T, Record<K, V>>) => unknown;
96
100
  }>(value: T, key: K, cases: Cases): RawCasesReturnType<Cases>;
97
- export declare function typedSwitch<T extends Record<string, unknown>, K extends keyof T, TKey extends T[K] & string, Cases extends {
101
+ export declare function typedSwitch<T extends object, K extends keyof T, TKey extends T[K] & string, Cases extends {
98
102
  [V in TKey]?: (value: Extract<T, Record<K, V>>) => unknown;
99
- }, DefaultCase extends (value: T) => unknown>(value: T, key: K, cases: Cases, defaultCase: DefaultCase): RawCasesReturnType<Cases> | RawHandlerReturnType<DefaultCase>;
103
+ }, DefaultCase extends (value: Exclude<T, Record<K, HandledCaseKeys<Cases> & string>>) => unknown>(value: T, key: K, cases: Cases, defaultCase: DefaultCase): RawCasesReturnType<Cases> | RawHandlerReturnType<DefaultCase>;
100
104
  export declare function typedSwitch<Constraint, T extends string = string>(value: T, cases: {
101
105
  [K in T]: (value: K) => Constraint;
102
106
  }): Constraint;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanzio",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A lightweight set of utility functions for TypeScript",
5
5
  "type": "module",
6
6
  "module": "./dist/src/index.js",
@@ -46,11 +46,17 @@
46
46
  "bun": "./src/secrets/vite.ts",
47
47
  "import": "./dist/src/secrets/vite.js",
48
48
  "default": "./dist/src/secrets/vite.js"
49
+ },
50
+ "./secrets/worker": {
51
+ "types": "./dist/secrets/worker.d.ts",
52
+ "bun": "./src/secrets/worker.ts",
53
+ "import": "./dist/src/secrets/worker.js",
54
+ "default": "./dist/src/secrets/worker.js"
49
55
  }
50
56
  },
51
57
  "scripts": {
52
58
  "build": "bun run build:js && bun run build:types",
53
- "build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/secrets/index.ts ./src/secrets/infisical.ts ./src/secrets/loaders.ts ./src/secrets/vite.ts --outdir ./dist --target node --packages external",
59
+ "build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/secrets/index.ts ./src/secrets/infisical.ts ./src/secrets/loaders.ts ./src/secrets/vite.ts ./src/secrets/worker.ts --outdir ./dist --target node --packages external",
54
60
  "build:types": "tsgo -p tsconfig.build.json",
55
61
  "prepublishOnly": "bun run build",
56
62
  "publish:patch": "npm version patch && npm publish",
@@ -60,14 +66,17 @@
60
66
  "lint": "bun run typecheck && bun biome check",
61
67
  "lint:write": "bun run typecheck && bun biome check --write",
62
68
  "typecheck": "tsgo --noEmit",
63
- "prepare": "husky"
69
+ "prepare": "husky",
70
+ "test:secrets:runtime": "bun run build && node scripts/verify-secrets.mjs"
64
71
  },
65
72
  "devDependencies": {
66
73
  "@biomejs/biome": "^2.4.6",
67
74
  "@types/bun": "latest",
68
75
  "@typescript/native-preview": "^7.0.0-dev.20260306.1",
69
76
  "husky": "^9.1.7",
77
+ "miniflare": "^4.20260730.0",
70
78
  "typescript": "^5.7.0",
79
+ "vite": "^8.2.2",
71
80
  "zod": "^4.3.6"
72
81
  },
73
82
  "peerDependencies": {
@@ -9,63 +9,88 @@ import type {
9
9
  } from './shared'
10
10
  import type { ApiEndpoint } from './types'
11
11
 
12
- export function buildUrl(
13
- endpoint: ApiEndpoint,
14
- params: PathParams | undefined,
15
- reqQuery: QueryParams | undefined,
16
- baseApiUrls: Record<string, BaseApiUrl>,
17
- defaultBaseApiUrl: string | undefined,
18
- url: string | undefined,
12
+ export type BuildUrlOptions = {
13
+ endpoint: ApiEndpoint
14
+ params: PathParams | undefined
15
+ queryParams: Record<string, string>
16
+ baseApiUrls: Record<string, BaseApiUrl>
17
+ defaultBaseApiUrl: string | undefined
18
+ url: string | undefined
19
+ doNotEncodeQueryParams?: boolean
19
20
  configError: (msg: string) => ConfigError
20
- ) {
21
+ }
22
+
23
+ export function formatQueryString(
24
+ queryParams: Record<string, string>,
25
+ doNotEncode?: boolean
26
+ ): string {
27
+ if (Object.keys(queryParams).length === 0) return ''
28
+ return doNotEncode
29
+ ? Object.entries(queryParams)
30
+ .map(([key, value]) => `${key}=${value}`)
31
+ .join('&')
32
+ : new URLSearchParams(queryParams).toString()
33
+ }
34
+
35
+ export function buildUrl(options: BuildUrlOptions) {
36
+ const {
37
+ endpoint,
38
+ params,
39
+ queryParams,
40
+ baseApiUrls,
41
+ defaultBaseApiUrl,
42
+ url,
43
+ doNotEncodeQueryParams,
44
+ configError
45
+ } = options
46
+
47
+ let finalUrl: string
21
48
  if (url) {
22
- const finalUrl = replacePathParams(url, params, configError)
23
- const queryString = new URLSearchParams(
24
- buildQueryParamsRaw(endpoint, reqQuery)
25
- ).toString()
26
- return {
27
- finalUrl,
28
- fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
49
+ finalUrl = replacePathParams(url, params, configError)
50
+ } else {
51
+ const defaultBaseUrl = defaultBaseApiUrl ?? Object.keys(baseApiUrls)[0]
52
+ if (!defaultBaseUrl) {
53
+ throw configError('At least one base API URL is required')
29
54
  }
30
- }
31
55
 
32
- const defaultBaseUrl = defaultBaseApiUrl ?? Object.keys(baseApiUrls)[0]
33
- if (!defaultBaseUrl) {
34
- throw configError('At least one base API URL is required')
35
- }
56
+ const baseUrlKey = endpoint.baseApiUrl ?? defaultBaseUrl
57
+ const configBaseUrl = baseApiUrls[baseUrlKey]
58
+ if (!configBaseUrl) {
59
+ throw configError(`Unknown base API URL: ${baseUrlKey}`)
60
+ }
36
61
 
37
- const baseUrlKey = endpoint.baseApiUrl ?? defaultBaseUrl
38
- const configBaseUrl = baseApiUrls[baseUrlKey]
39
- if (!configBaseUrl) {
40
- throw configError(`Unknown base API URL: ${baseUrlKey}`)
62
+ const baseUrl =
63
+ typeof configBaseUrl === 'function' ? configBaseUrl() : configBaseUrl
64
+ const path =
65
+ typeof endpoint.path === 'function'
66
+ ? endpoint.path(baseUrl)
67
+ : endpoint.path
68
+ // Substitute endpoint parameters without interpreting a port or IPv6 host as one.
69
+ finalUrl = `${baseUrl}${replacePathParams(path, params, configError)}`
41
70
  }
42
71
 
43
- const baseUrl =
44
- typeof configBaseUrl === 'function' ? configBaseUrl() : configBaseUrl
45
- const path =
46
- typeof endpoint.path === 'function' ? endpoint.path(baseUrl) : endpoint.path
47
- const finalUrl = replacePathParams(`${baseUrl}${path}`, params, configError)
48
- const queryString = new URLSearchParams(
49
- buildQueryParamsRaw(endpoint, reqQuery)
50
- ).toString()
51
-
72
+ const queryString = formatQueryString(queryParams, doNotEncodeQueryParams)
52
73
  return {
53
74
  finalUrl,
54
75
  fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
55
76
  }
56
77
  }
57
78
 
58
- export function buildQueryParamsRaw(
79
+ export function buildPathParams(
59
80
  endpoint: ApiEndpoint,
60
- query: QueryParams | undefined
61
- ): Record<string, string> {
62
- const defaultParams = endpoint.reqDefaultQueryParams ?? {}
63
- const merged = { ...defaultParams, ...(query ?? {}) }
64
- return Object.fromEntries(
65
- Object.entries(merged)
66
- .filter(([, value]) => value !== undefined)
67
- .map(([key, value]) => [key, String(value)])
68
- )
81
+ params: PathParams | undefined,
82
+ ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
83
+ ): PathParams | undefined {
84
+ if (!endpoint.reqParamsSchema) return params
85
+
86
+ try {
87
+ return endpoint.reqParamsSchema.parse(params) as PathParams
88
+ } catch (error) {
89
+ if (error instanceof ZodError) {
90
+ throw new RequestValidationError(error, 'params', params, ctx())
91
+ }
92
+ throw error
93
+ }
69
94
  }
70
95
 
71
96
  export function buildQueryParams(