@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.
- package/LICENSE +22 -0
- package/README.md +35 -0
- package/dist/binary-extractor.d.ts +9 -0
- package/dist/binary-extractor.d.ts.map +1 -0
- package/dist/binary-extractor.js +85 -0
- package/dist/binary-extractor.js.map +1 -0
- package/dist/cache-manager.d.ts +8 -0
- package/dist/cache-manager.d.ts.map +1 -0
- package/dist/cache-manager.js +408 -0
- package/dist/cache-manager.js.map +1 -0
- package/dist/environment.d.ts +5 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +25 -0
- package/dist/environment.js.map +1 -0
- package/dist/filesystem-storage.d.ts +10 -0
- package/dist/filesystem-storage.d.ts.map +1 -0
- package/dist/filesystem-storage.js +112 -0
- package/dist/filesystem-storage.js.map +1 -0
- package/dist/flat-file-storage.d.ts +33 -0
- package/dist/flat-file-storage.d.ts.map +1 -0
- package/dist/flat-file-storage.js +153 -0
- package/dist/flat-file-storage.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +183 -0
- package/dist/index.js.map +1 -0
- package/dist/request-hasher.d.ts +5 -0
- package/dist/request-hasher.d.ts.map +1 -0
- package/dist/request-hasher.js +74 -0
- package/dist/request-hasher.js.map +1 -0
- package/dist/sse-handler.d.ts +9 -0
- package/dist/sse-handler.d.ts.map +1 -0
- package/dist/sse-handler.js +225 -0
- package/dist/sse-handler.js.map +1 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -3
- package/src/binary-extractor.ts +104 -0
- package/src/cache-manager.ts +499 -0
- package/src/environment.ts +27 -0
- package/src/filesystem-storage.ts +125 -0
- package/src/flat-file-storage.ts +170 -0
- package/src/index.ts +249 -0
- package/src/request-hasher.ts +86 -0
- package/src/sse-handler.ts +281 -0
- 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
|
+
}
|