hanzio 1.0.10 → 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 (71) hide show
  1. package/README.md +70 -4
  2. package/dist/api-wrapper/actions-impl.d.ts +4 -0
  3. package/dist/api-wrapper/builders.d.ts +23 -0
  4. package/dist/api-wrapper/create-client.d.ts +6 -0
  5. package/dist/api-wrapper/error-reporting.d.ts +7 -0
  6. package/dist/api-wrapper/errors.d.ts +64 -0
  7. package/dist/api-wrapper/headers.d.ts +10 -0
  8. package/dist/api-wrapper/index.d.ts +5 -84
  9. package/dist/api-wrapper/redaction.d.ts +2 -0
  10. package/dist/api-wrapper/shared.d.ts +16 -0
  11. package/dist/api-wrapper/size.d.ts +1 -0
  12. package/dist/api-wrapper/test-helpers.d.ts +6 -0
  13. package/dist/api-wrapper/transport.d.ts +55 -0
  14. package/dist/api-wrapper/types.d.ts +154 -0
  15. package/dist/api-wrapper/zod-issues.d.ts +17 -0
  16. package/dist/cache/index.d.ts +26 -0
  17. package/dist/secrets/core.d.ts +47 -0
  18. package/dist/secrets/deadline.d.ts +6 -0
  19. package/dist/secrets/environment.d.ts +9 -0
  20. package/dist/secrets/errors.d.ts +11 -0
  21. package/dist/secrets/index.d.ts +14 -23
  22. package/dist/secrets/infisical-cli.d.ts +6 -0
  23. package/dist/secrets/infisical-config.d.ts +17 -0
  24. package/dist/secrets/infisical-session.d.ts +4 -0
  25. package/dist/secrets/infisical.d.ts +14 -2
  26. package/dist/secrets/loaders.d.ts +9 -0
  27. package/dist/secrets/vite.d.ts +7 -3
  28. package/dist/secrets/worker-loader.d.ts +9 -0
  29. package/dist/secrets/worker.d.ts +3 -0
  30. package/dist/src/api-wrapper/index.js +1059 -177
  31. package/dist/src/index.js +2807 -1397
  32. package/dist/src/jwt/index.js +2 -2
  33. package/dist/src/p-queue/index.js +3 -3
  34. package/dist/src/secrets/index.js +1610 -407
  35. package/dist/src/secrets/infisical.js +1352 -214
  36. package/dist/src/secrets/loaders.js +1529 -0
  37. package/dist/src/secrets/vite.js +33 -11
  38. package/dist/src/secrets/worker.js +275 -0
  39. package/dist/src/sitemap/index.js +1 -1
  40. package/dist/src/zod/index.js +7 -7
  41. package/dist/typedswitch/index.d.ts +9 -5
  42. package/package.json +12 -3
  43. package/src/api-wrapper/actions-impl.ts +100 -0
  44. package/src/api-wrapper/builders.ts +200 -0
  45. package/src/api-wrapper/create-client.ts +393 -0
  46. package/src/api-wrapper/error-reporting.ts +31 -0
  47. package/src/api-wrapper/errors.ts +231 -0
  48. package/src/api-wrapper/headers.ts +79 -0
  49. package/src/api-wrapper/index.ts +5 -492
  50. package/src/api-wrapper/redaction.ts +62 -0
  51. package/src/api-wrapper/shared.ts +25 -0
  52. package/src/api-wrapper/size.ts +19 -0
  53. package/src/api-wrapper/test-helpers.ts +25 -0
  54. package/src/api-wrapper/transport.ts +350 -0
  55. package/src/api-wrapper/types.ts +259 -0
  56. package/src/api-wrapper/zod-issues.ts +157 -0
  57. package/src/cache/index.ts +228 -61
  58. package/src/secrets/core.ts +348 -0
  59. package/src/secrets/deadline.ts +69 -0
  60. package/src/secrets/environment.ts +69 -0
  61. package/src/secrets/errors.ts +29 -0
  62. package/src/secrets/index.ts +81 -140
  63. package/src/secrets/infisical-cli.ts +99 -0
  64. package/src/secrets/infisical-config.ts +70 -0
  65. package/src/secrets/infisical-session.ts +42 -0
  66. package/src/secrets/infisical.ts +219 -35
  67. package/src/secrets/loaders.ts +43 -0
  68. package/src/secrets/vite.ts +44 -15
  69. package/src/secrets/worker-loader.ts +56 -0
  70. package/src/secrets/worker.ts +4 -0
  71. 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.0.10",
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/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": {
@@ -0,0 +1,100 @@
1
+ import type { ActionCache, DefineAction } from './types'
2
+
3
+ type CacheEntry = {
4
+ value?: unknown
5
+ hasValue: boolean
6
+ expiresAt?: number
7
+ inflight?: Promise<unknown>
8
+ }
9
+
10
+ export function createActionCache(): ActionCache {
11
+ const store = new Map<string, CacheEntry>()
12
+
13
+ const isExpired = (entry: CacheEntry) =>
14
+ entry.expiresAt !== undefined && entry.expiresAt <= Date.now()
15
+
16
+ const cache = (async <T>(
17
+ key: string,
18
+ fn: () => Promise<T> | T,
19
+ options?: { ttlMs?: number }
20
+ ): Promise<T> => {
21
+ const existing = store.get(key)
22
+ if (existing && !isExpired(existing)) {
23
+ if (existing.inflight) return existing.inflight as Promise<T>
24
+ if (existing.hasValue) return existing.value as T
25
+ }
26
+
27
+ const promise = (async () => fn())()
28
+ const entry: CacheEntry = {
29
+ hasValue: false,
30
+ inflight: promise,
31
+ expiresAt:
32
+ options?.ttlMs !== undefined ? Date.now() + options.ttlMs : undefined
33
+ }
34
+ store.set(key, entry)
35
+
36
+ try {
37
+ const value = await promise
38
+ entry.value = value
39
+ entry.hasValue = true
40
+ entry.inflight = undefined
41
+ return value
42
+ } catch (error) {
43
+ store.delete(key)
44
+ throw error
45
+ }
46
+ }) as ActionCache
47
+
48
+ cache.get = <T>(key: string): T | undefined => {
49
+ const entry = store.get(key)
50
+ if (!entry || !entry.hasValue) return undefined
51
+ if (isExpired(entry)) {
52
+ store.delete(key)
53
+ return undefined
54
+ }
55
+ return entry.value as T
56
+ }
57
+
58
+ cache.set = <T>(
59
+ key: string,
60
+ value: T,
61
+ options?: { ttlMs?: number }
62
+ ): void => {
63
+ store.set(key, {
64
+ value,
65
+ hasValue: true,
66
+ expiresAt:
67
+ options?.ttlMs !== undefined ? Date.now() + options.ttlMs : undefined
68
+ })
69
+ }
70
+
71
+ cache.invalidate = (key: string): void => {
72
+ store.delete(key)
73
+ }
74
+
75
+ cache.clear = (): void => {
76
+ store.clear()
77
+ }
78
+
79
+ return cache
80
+ }
81
+
82
+ export function makeDefineAction<TApi>(): DefineAction<TApi> {
83
+ function defineAction(...args: unknown[]): unknown {
84
+ if (args.length === 0) {
85
+ return (def: { handler: unknown }) => ({
86
+ ...def,
87
+ noRuntimeInput: false as const
88
+ })
89
+ }
90
+ const def = args[0] as { handler: unknown }
91
+ return {
92
+ ...def,
93
+ noRuntimeInput: true as const
94
+ }
95
+ }
96
+ return defineAction as DefineAction<TApi>
97
+ }
98
+
99
+ // biome-ignore lint/suspicious/noExplicitAny: top-level form keeps ctx.api as any
100
+ export const defineAction: DefineAction<any> = makeDefineAction<any>()