@effect-native/fetch-hooks 0.0.1-placeholder → 0.0.2

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/LICENSE +22 -0
  2. package/README.md +35 -0
  3. package/dist/binary-extractor.d.ts +9 -0
  4. package/dist/binary-extractor.d.ts.map +1 -0
  5. package/dist/binary-extractor.js +85 -0
  6. package/dist/binary-extractor.js.map +1 -0
  7. package/dist/cache-manager.d.ts +8 -0
  8. package/dist/cache-manager.d.ts.map +1 -0
  9. package/dist/cache-manager.js +408 -0
  10. package/dist/cache-manager.js.map +1 -0
  11. package/dist/environment.d.ts +5 -0
  12. package/dist/environment.d.ts.map +1 -0
  13. package/dist/environment.js +25 -0
  14. package/dist/environment.js.map +1 -0
  15. package/dist/filesystem-storage.d.ts +10 -0
  16. package/dist/filesystem-storage.d.ts.map +1 -0
  17. package/dist/filesystem-storage.js +112 -0
  18. package/dist/filesystem-storage.js.map +1 -0
  19. package/dist/flat-file-storage.d.ts +33 -0
  20. package/dist/flat-file-storage.d.ts.map +1 -0
  21. package/dist/flat-file-storage.js +153 -0
  22. package/dist/flat-file-storage.js.map +1 -0
  23. package/dist/index.d.ts +9 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +183 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/request-hasher.d.ts +5 -0
  28. package/dist/request-hasher.d.ts.map +1 -0
  29. package/dist/request-hasher.js +74 -0
  30. package/dist/request-hasher.js.map +1 -0
  31. package/dist/sse-handler.d.ts +9 -0
  32. package/dist/sse-handler.d.ts.map +1 -0
  33. package/dist/sse-handler.js +225 -0
  34. package/dist/sse-handler.js.map +1 -0
  35. package/dist/types.d.ts +116 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/package.json +52 -3
  40. package/src/binary-extractor.ts +104 -0
  41. package/src/cache-manager.ts +499 -0
  42. package/src/environment.ts +27 -0
  43. package/src/filesystem-storage.ts +125 -0
  44. package/src/flat-file-storage.ts +170 -0
  45. package/src/index.ts +249 -0
  46. package/src/request-hasher.ts +86 -0
  47. package/src/sse-handler.ts +281 -0
  48. package/src/types.ts +140 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Flat file storage - stores cache files in subdirectories named by the key.
3
+ * Each key gets its own folder with standard cache filenames inside:
4
+ * baseDir/001/request.json
5
+ * baseDir/001/response.meta.json
6
+ * baseDir/001/response.jsonl
7
+ * baseDir/002/request.json
8
+ * ...
9
+ *
10
+ * This is similar to filesystem-storage but the key IS the subdirectory name
11
+ * (no hashing), making it suitable for conversation turn folders.
12
+ */
13
+
14
+ import type { CachedRequest, CachedResponseMeta, CacheKey, CacheStorage, KV, KVStream, TimedChunk } from "./types.js"
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
17
+ import { join } from "node:path"
18
+ import { jsonlToTimedChunks, timedChunksToJsonl } from "./sse-handler.js"
19
+
20
+ function ensureDir(dir: string): void {
21
+ if (!existsSync(dir)) {
22
+ try {
23
+ mkdirSync(dir, { recursive: true })
24
+ } catch (error: unknown) {
25
+ if (!(error instanceof Error && (error as NodeJS.ErrnoException).code === "EEXIST")) {
26
+ throw error
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ /** Create a KV store backed by JSON files in subdirectories: baseDir/{key}/{filename} */
33
+ export function createFlatJsonFileKV<T>(baseDir: string, filename: string): KV<CacheKey, T> {
34
+ return {
35
+ async get([key]: CacheKey): Promise<T | null> {
36
+ const filePath = join(baseDir, key, filename)
37
+ if (!existsSync(filePath)) {
38
+ return null
39
+ }
40
+ const content = readFileSync(filePath, "utf-8")
41
+ return JSON.parse(content) as T
42
+ },
43
+
44
+ async set([key]: CacheKey, value: T): Promise<void> {
45
+ const dir = join(baseDir, key)
46
+ ensureDir(dir)
47
+ const filePath = join(dir, filename)
48
+ writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8")
49
+ },
50
+
51
+ async has([key]: CacheKey): Promise<boolean> {
52
+ const filePath = join(baseDir, key, filename)
53
+ return existsSync(filePath)
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Create a KV store backed by plain text files in subdirectories (no JSON serialization) */
59
+ export function createFlatTextFileKV(baseDir: string, filename: string): KV<CacheKey, string> {
60
+ return {
61
+ async get([key]: CacheKey): Promise<string | null> {
62
+ const filePath = join(baseDir, key, filename)
63
+ if (!existsSync(filePath)) {
64
+ return null
65
+ }
66
+ return readFileSync(filePath, "utf-8")
67
+ },
68
+
69
+ async set([key]: CacheKey, value: string): Promise<void> {
70
+ const dir = join(baseDir, key)
71
+ ensureDir(dir)
72
+ const filePath = join(dir, filename)
73
+ writeFileSync(filePath, value, "utf-8")
74
+ },
75
+
76
+ async has([key]: CacheKey): Promise<boolean> {
77
+ const filePath = join(baseDir, key, filename)
78
+ return existsSync(filePath)
79
+ }
80
+ }
81
+ }
82
+
83
+ /** Create a KV store backed by binary files in subdirectories */
84
+ export function createFlatBinaryFileKV(baseDir: string, filename: string): KV<CacheKey, Uint8Array> {
85
+ return {
86
+ async get([key]: CacheKey): Promise<Uint8Array | null> {
87
+ const filePath = join(baseDir, key, filename)
88
+ if (!existsSync(filePath)) {
89
+ return null
90
+ }
91
+ const buffer = readFileSync(filePath)
92
+ return new Uint8Array(buffer)
93
+ },
94
+
95
+ async set([key]: CacheKey, value: Uint8Array): Promise<void> {
96
+ const dir = join(baseDir, key)
97
+ ensureDir(dir)
98
+ const filePath = join(dir, filename)
99
+ writeFileSync(filePath, value)
100
+ },
101
+
102
+ async has([key]: CacheKey): Promise<boolean> {
103
+ const filePath = join(baseDir, key, filename)
104
+ return existsSync(filePath)
105
+ }
106
+ }
107
+ }
108
+
109
+ /** Create a streaming KV store for SSE chunks in subdirectories */
110
+ export function createFlatJsonlFileKVStream(baseDir: string, filename: string): KVStream<CacheKey, TimedChunk> {
111
+ return {
112
+ async *get([key]: CacheKey): AsyncIterable<TimedChunk> {
113
+ const filePath = join(baseDir, key, filename)
114
+ if (!existsSync(filePath)) {
115
+ return
116
+ }
117
+
118
+ const content = readFileSync(filePath, "utf-8")
119
+ const chunks = jsonlToTimedChunks(content)
120
+ for (const chunk of chunks) {
121
+ yield chunk
122
+ }
123
+ },
124
+
125
+ async set([key]: CacheKey, values: Array<TimedChunk> | AsyncIterable<TimedChunk>): Promise<void> {
126
+ const dir = join(baseDir, key)
127
+ ensureDir(dir)
128
+ const filePath = join(dir, filename)
129
+
130
+ let chunks: Array<TimedChunk>
131
+ if (Array.isArray(values)) {
132
+ chunks = values
133
+ } else {
134
+ chunks = []
135
+ for await (const chunk of values) {
136
+ chunks.push(chunk)
137
+ }
138
+ }
139
+
140
+ const jsonl = timedChunksToJsonl(chunks)
141
+ writeFileSync(filePath, jsonl, "utf-8")
142
+ },
143
+
144
+ async has([key]: CacheKey): Promise<boolean> {
145
+ const filePath = join(baseDir, key, filename)
146
+ return existsSync(filePath)
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Create a CacheStorage that stores files in subdirectories named by the key.
153
+ *
154
+ * @example
155
+ * const storage = createFlatFileStorage("/path/to/conversation")
156
+ * // With transformCacheKey returning "001", files will be:
157
+ * // /path/to/conversation/001/request.json
158
+ * // /path/to/conversation/001/response.meta.json
159
+ * // /path/to/conversation/001/response.jsonl
160
+ */
161
+ export function createFlatFileStorage(baseDir: string): CacheStorage {
162
+ return {
163
+ requests: createFlatJsonFileKV<CachedRequest>(baseDir, "request.json"),
164
+ responseMeta: createFlatJsonFileKV<CachedResponseMeta>(baseDir, "response.meta.json"),
165
+ // Use text storage for responseBody to preserve raw JSON without double-escaping
166
+ responseBody: createFlatTextFileKV(baseDir, "response.json"),
167
+ binaryBody: createFlatBinaryFileKV(baseDir, "response.bin"),
168
+ sseChunks: createFlatJsonlFileKVStream(baseDir, "response.jsonl")
169
+ }
170
+ }
package/src/index.ts ADDED
@@ -0,0 +1,249 @@
1
+ import type { CacheOptions, CacheStorage } from "./types.js"
2
+
3
+ import {
4
+ getCachedResponse,
5
+ getCacheKeyFromUrl,
6
+ setInternalFetchForCache,
7
+ storeRequest,
8
+ storeResponse
9
+ } from "./cache-manager.js"
10
+ import { getCacheDir, isCacheEnabled, isProduction, isReplayOnly } from "./environment.js"
11
+ import { createFilesystemStorage } from "./filesystem-storage.js"
12
+ import { hashRequest, headersToRecord } from "./request-hasher.js"
13
+
14
+ // Only export the public API - internal implementation details are not exported
15
+ export type {
16
+ CacheKey,
17
+ CacheOptions,
18
+ CacheStorage,
19
+ GeneratorTransformHook,
20
+ HashableRequest,
21
+ KV,
22
+ KVStream,
23
+ StorableResponse,
24
+ TimedChunk,
25
+ TransformHook
26
+ } from "./types.js"
27
+
28
+ // Export storage factories for custom implementations
29
+ export { createFilesystemStorage } from "./filesystem-storage.js"
30
+ export { createFlatFileStorage } from "./flat-file-storage.js"
31
+
32
+ const CACHE_ENABLED_BANNER = `
33
+ ╔══════════════════════════════════════════════════════════════╗
34
+ ║ DEV-FETCH-CACHE ENABLED ║
35
+ ║ Responses are being cached/replayed from .cache/fetch/ ║
36
+ ║ To disable: DEV_FETCH_CACHE=0 or --no-fetch-cache ║
37
+ ╚══════════════════════════════════════════════════════════════╝
38
+ `
39
+
40
+ let originalFetch: typeof globalThis.fetch | null = null
41
+
42
+ function getUrlFromInput(input: Request | string | URL): string {
43
+ if (typeof input === "string") {
44
+ return input
45
+ }
46
+ if (input instanceof URL) {
47
+ return input.href
48
+ }
49
+ return input.url
50
+ }
51
+
52
+ function normalizeHeadersInit(headersInit: unknown): Record<string, string> {
53
+ if (!headersInit) {
54
+ return {}
55
+ }
56
+ if (headersInit instanceof Headers) {
57
+ return headersToRecord(headersInit)
58
+ }
59
+ if (Array.isArray(headersInit)) {
60
+ return Object.fromEntries(headersInit)
61
+ }
62
+ return headersInit as Record<string, string>
63
+ }
64
+
65
+ async function extractBodyInit(bodyInit: RequestInit["body"]): Promise<string | undefined> {
66
+ if (!bodyInit) {
67
+ return undefined
68
+ }
69
+ if (typeof bodyInit === "string") {
70
+ return bodyInit
71
+ }
72
+ if (bodyInit instanceof URLSearchParams) {
73
+ return bodyInit.toString()
74
+ }
75
+ if (bodyInit instanceof Blob) {
76
+ return bodyInit.text()
77
+ }
78
+ if (bodyInit instanceof FormData) {
79
+ const pairs: Array<string> = []
80
+ for (const [key, value] of bodyInit.entries()) {
81
+ // FormDataEntryValue can be File or string; File needs to be converted to string
82
+ const stringValue = typeof value === "string" ? value : value.name
83
+ pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(stringValue)}`)
84
+ }
85
+ return pairs.join("&")
86
+ }
87
+ if (bodyInit instanceof ArrayBuffer) {
88
+ return new TextDecoder().decode(bodyInit)
89
+ }
90
+ if (ArrayBuffer.isView(bodyInit)) {
91
+ return new TextDecoder().decode(new Uint8Array(bodyInit.buffer, bodyInit.byteOffset, bodyInit.byteLength))
92
+ }
93
+ if (bodyInit instanceof ReadableStream) {
94
+ const reader = bodyInit.getReader()
95
+ const chunks: Array<Uint8Array> = []
96
+ while (true) {
97
+ const { done, value } = await reader.read()
98
+ if (done) break
99
+ if (value) chunks.push(value)
100
+ }
101
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
102
+ const combined = new Uint8Array(totalLength)
103
+ let offset = 0
104
+ for (const chunk of chunks) {
105
+ combined.set(chunk, offset)
106
+ offset += chunk.length
107
+ }
108
+ return new TextDecoder().decode(combined)
109
+ }
110
+ return undefined
111
+ }
112
+
113
+ /** Default identity transform hook */
114
+ const identity = <T>(value: T): T => value
115
+
116
+ export function createCachedFetch(baseFetch: typeof globalThis.fetch, options?: CacheOptions) {
117
+ const replayOnly = options?.replayOnly ?? isReplayOnly()
118
+ const beforeHash = options?.beforeHash ?? identity
119
+ const beforeStoreRequest = options?.beforeStoreRequest ?? identity
120
+ const transformCacheKey = options?.transformCacheKey ?? ((key: string) => key)
121
+
122
+ // Create or use provided storage
123
+ const storage: CacheStorage = options?.storage ?? createFilesystemStorage(getCacheDir())
124
+
125
+ // Set internal fetch for dev-fs-logs communication (defaults to baseFetch)
126
+ setInternalFetchForCache(options?.internalFetch ?? baseFetch)
127
+
128
+ return async function cachedFetch(input: Request | string | URL, init?: RequestInit): Promise<Response> {
129
+ if (isProduction()) {
130
+ return baseFetch(input, init)
131
+ }
132
+
133
+ const url = getUrlFromInput(input)
134
+
135
+ // Skip caching for localhost requests (including dev-fs-logs on port 1090)
136
+ if (url.includes("localhost") || url.includes("127.0.0.1")) {
137
+ return baseFetch(input, init)
138
+ }
139
+
140
+ const method = init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET")
141
+
142
+ const cacheKeyFromUrl = getCacheKeyFromUrl(url)
143
+ if (cacheKeyFromUrl) {
144
+ const cached = await getCachedResponse(
145
+ cacheKeyFromUrl,
146
+ options?.afterLoadResponse,
147
+ options?.transformSSEChunk,
148
+ storage
149
+ )
150
+ if (cached) {
151
+ return cached
152
+ }
153
+ return new Response("Cache entry not found", {
154
+ status: 404
155
+ })
156
+ }
157
+
158
+ const headersInit = init?.headers ?? (typeof input === "object" && "headers" in input ? input.headers : undefined)
159
+ const headers = normalizeHeadersInit(headersInit)
160
+
161
+ let body: string | undefined
162
+
163
+ // Extract body from Request object or init
164
+ if (input instanceof Request && input.body) {
165
+ // Clone the request so we can read the body without consuming the original
166
+ const clonedRequest = input.clone()
167
+ body = await extractBodyInit(clonedRequest.body!)
168
+ } else if (init?.body !== null && init?.body !== undefined) {
169
+ body = await extractBodyInit(init.body)
170
+ }
171
+
172
+ // Apply beforeHash transform to affect cache key generation, then transformCacheKey for custom prefixes/suffixes
173
+ const hashableRequest = await beforeHash({ url, method, headers, body })
174
+ const cacheKey = transformCacheKey(hashRequest(hashableRequest), hashableRequest)
175
+
176
+ const cached = await getCachedResponse(
177
+ cacheKey,
178
+ options?.afterLoadResponse,
179
+ options?.transformSSEChunk,
180
+ storage
181
+ )
182
+ if (cached) {
183
+ return cached
184
+ }
185
+
186
+ if (replayOnly) {
187
+ return new Response("Cache miss in replay-only mode", {
188
+ status: 503
189
+ })
190
+ }
191
+
192
+ const requestToStore = await beforeStoreRequest({ url, method, headers, body })
193
+ await storeRequest(
194
+ {
195
+ url: requestToStore.url,
196
+ method: requestToStore.method,
197
+ headers: requestToStore.headers,
198
+ body: requestToStore.body
199
+ },
200
+ cacheKey,
201
+ options?.beforeStoreRequest !== undefined, // skip header filtering if hook provided
202
+ storage,
203
+ hashableRequest
204
+ )
205
+
206
+ const response = await baseFetch(input, init)
207
+
208
+ return storeResponse(cacheKey, response, options?.beforeStoreResponse, storage, hashableRequest)
209
+ }
210
+ }
211
+
212
+ export function enableDevFetchCache(options?: CacheOptions): void {
213
+ if (isProduction()) {
214
+ return
215
+ }
216
+
217
+ if (!isCacheEnabled()) {
218
+ return
219
+ }
220
+
221
+ if (originalFetch !== null) {
222
+ return
223
+ }
224
+
225
+ originalFetch = globalThis.fetch
226
+ const cachedFetch = createCachedFetch(originalFetch, {
227
+ ...options,
228
+ internalFetch: options?.internalFetch ?? originalFetch
229
+ })
230
+
231
+ // Cast to typeof fetch to include preconnect method
232
+ globalThis.fetch = cachedFetch as typeof globalThis.fetch
233
+
234
+ // biome-ignore lint/suspicious/noConsole: Banner display is intentional for dev tooling
235
+ console.info(CACHE_ENABLED_BANNER)
236
+ }
237
+
238
+ export function disableDevFetchCache(): void {
239
+ if (originalFetch === null) {
240
+ return
241
+ }
242
+
243
+ globalThis.fetch = originalFetch
244
+ originalFetch = null
245
+ }
246
+
247
+ export function isDevFetchCacheEnabled(): boolean {
248
+ return originalFetch !== null
249
+ }
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto"
2
+ import type { RawCachedRequest } from "./types.js"
3
+
4
+ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
5
+
6
+ function toBase62(n: bigint): string {
7
+ if (n === 0n) return "0"
8
+ let result = ""
9
+ while (n > 0n) {
10
+ result = BASE62[Number(n % 62n)] + result
11
+ n = n / 62n
12
+ }
13
+ return result
14
+ }
15
+
16
+ const SENSITIVE_HEADERS = new Set([
17
+ "authorization",
18
+ "x-api-key",
19
+ "api-key",
20
+ "x-openrouter-api-key",
21
+ "cookie",
22
+ "set-cookie",
23
+ "x-auth-token",
24
+ "x-access-token",
25
+ "bearer"
26
+ ])
27
+
28
+ function normalizeUrl(url: string): string {
29
+ const parsed = new URL(url)
30
+ parsed.searchParams.sort()
31
+ const pathname = parsed.pathname.replace(/\/+$/, "") || "/"
32
+ return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}`
33
+ }
34
+
35
+ function filterHeaders(
36
+ headers: Record<string, string>,
37
+ getOutputKey: (originalKey: string, lowerKey: string) => string
38
+ ): Record<string, string> {
39
+ const result: Record<string, string> = {}
40
+ for (const [key, value] of Object.entries(headers)) {
41
+ const lowerKey = key.toLowerCase()
42
+ if (SENSITIVE_HEADERS.has(lowerKey)) {
43
+ continue
44
+ }
45
+ const outputKey = getOutputKey(key, lowerKey)
46
+ result[outputKey] = value
47
+ }
48
+ return result
49
+ }
50
+
51
+ function normalizeHeaders(headers: Record<string, string>): Record<string, string> {
52
+ return filterHeaders(headers, (_originalKey, lowerKey) => lowerKey)
53
+ }
54
+
55
+ function filterSensitiveHeaders(headers: Record<string, string>): Record<string, string> {
56
+ return filterHeaders(headers, (originalKey) => originalKey)
57
+ }
58
+
59
+ export function hashRequest({ body, headers, method, url }: RawCachedRequest): string {
60
+ // Build content string for hashing
61
+ let content = normalizeUrl(url) + "\n" + method.toUpperCase()
62
+ const normalizedHeaders = normalizeHeaders(headers)
63
+ const sortedHeaderKeys = Object.keys(normalizedHeaders).sort()
64
+ for (const key of sortedHeaderKeys) {
65
+ content += `\n${key}:${normalizedHeaders[key]}`
66
+ }
67
+ if (body) {
68
+ content += `\n${body}`
69
+ }
70
+ // Create a SHA256 hash and convert to base62 for a short string
71
+ const hash = createHash("sha256").update(content).digest()
72
+ const hashBigInt = BigInt("0x" + hash.toString("hex"))
73
+ return toBase62(hashBigInt % 62n ** 11n) // Limit to base62 with ~11 chars max
74
+ }
75
+
76
+ export function getStorableHeaders(headers: Record<string, string>): Record<string, string> {
77
+ return filterSensitiveHeaders(headers)
78
+ }
79
+
80
+ export function headersToRecord(headers: Headers): Record<string, string> {
81
+ const record: Record<string, string> = {}
82
+ headers.forEach((value, key) => {
83
+ record[key] = value
84
+ })
85
+ return record
86
+ }