@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,281 @@
|
|
|
1
|
+
import type { JsonlRecord, TimedChunk } from "./types.js"
|
|
2
|
+
|
|
3
|
+
export function isSSEResponse(headers: Headers): boolean {
|
|
4
|
+
const contentType = headers.get("content-type") ?? ""
|
|
5
|
+
return contentType.includes("text/event-stream")
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Represents an extracted SSE item from raw chunk data */
|
|
9
|
+
type SSEItem =
|
|
10
|
+
| { type: "data"; payload: string }
|
|
11
|
+
| { type: "comment"; raw: string }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse SSE events from a chunk, extracting both data payloads and comment lines.
|
|
15
|
+
* SSE format:
|
|
16
|
+
* - "data: <payload>\n\n" - data event
|
|
17
|
+
* - ": <comment>\n\n" - comment line (keep-alive, status, etc.)
|
|
18
|
+
*
|
|
19
|
+
* A single network chunk may contain multiple SSE events batched together.
|
|
20
|
+
* Returns an array of SSE items in the order they appear.
|
|
21
|
+
*/
|
|
22
|
+
function extractAllSSEItems(chunk: string): Array<SSEItem> {
|
|
23
|
+
const items: Array<SSEItem> = []
|
|
24
|
+
|
|
25
|
+
// Split by SSE event boundaries (double newline)
|
|
26
|
+
// An SSE event can span multiple lines before the double newline
|
|
27
|
+
const events = chunk.split(/\n\n/)
|
|
28
|
+
|
|
29
|
+
for (const event of events) {
|
|
30
|
+
if (!event.trim()) continue
|
|
31
|
+
|
|
32
|
+
// Check for data lines
|
|
33
|
+
const dataMatch = event.match(/^data:\s*(.*)$/m)
|
|
34
|
+
if (dataMatch) {
|
|
35
|
+
items.push({ type: "data", payload: dataMatch[1] ?? "" })
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check for comment lines (start with colon)
|
|
40
|
+
// SSE spec: lines starting with ':' are comments
|
|
41
|
+
if (event.trimStart().startsWith(":")) {
|
|
42
|
+
// Store the raw comment including the original format
|
|
43
|
+
items.push({ type: "comment", raw: event + "\n\n" })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return items
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Try to parse a string as JSON.
|
|
52
|
+
* Returns the parsed value or null if parsing fails.
|
|
53
|
+
*/
|
|
54
|
+
function tryParseJson(str: string): unknown | null {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(str)
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function recordStreamWithTiming(stream: ReadableStream<Uint8Array>): Promise<Array<TimedChunk>> {
|
|
63
|
+
const reader = stream.getReader()
|
|
64
|
+
const decoder = new TextDecoder()
|
|
65
|
+
const chunks: Array<TimedChunk> = []
|
|
66
|
+
let lastChunkTime = Date.now()
|
|
67
|
+
|
|
68
|
+
while (true) {
|
|
69
|
+
const { done, value } = await reader.read()
|
|
70
|
+
if (done) {
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
const now = Date.now()
|
|
74
|
+
const delay_ms = now - lastChunkTime
|
|
75
|
+
lastChunkTime = now
|
|
76
|
+
chunks.push({
|
|
77
|
+
data: decoder.decode(value, {
|
|
78
|
+
stream: true
|
|
79
|
+
}),
|
|
80
|
+
delay_ms
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return chunks
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function replayStreamWithTiming(timedChunks: Array<TimedChunk>): ReadableStream<Uint8Array> {
|
|
88
|
+
const encoder = new TextEncoder()
|
|
89
|
+
let chunkIndex = 0
|
|
90
|
+
|
|
91
|
+
return new ReadableStream<Uint8Array>({
|
|
92
|
+
async pull(controller) {
|
|
93
|
+
if (chunkIndex >= timedChunks.length) {
|
|
94
|
+
controller.close()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Non-null assertion is safe here because we've already verified chunkIndex < timedChunks.length
|
|
99
|
+
const chunk = timedChunks[chunkIndex]!
|
|
100
|
+
|
|
101
|
+
if (chunk.delay_ms > 0 && chunkIndex > 0) {
|
|
102
|
+
const cappedDelay = Math.min(chunk.delay_ms, 30000)
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, cappedDelay))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
controller.enqueue(encoder.encode(chunk.data))
|
|
107
|
+
chunkIndex++
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Replay stream from an async iterable of timed chunks (for generator transforms) */
|
|
113
|
+
export function replayStreamFromAsyncIterable(
|
|
114
|
+
chunks: AsyncIterable<TimedChunk>
|
|
115
|
+
): ReadableStream<Uint8Array> {
|
|
116
|
+
const encoder = new TextEncoder()
|
|
117
|
+
let iterator: AsyncIterator<TimedChunk> | null = null
|
|
118
|
+
let isFirst = true
|
|
119
|
+
|
|
120
|
+
return new ReadableStream<Uint8Array>({
|
|
121
|
+
async pull(controller) {
|
|
122
|
+
if (!iterator) {
|
|
123
|
+
iterator = chunks[Symbol.asyncIterator]()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { done, value } = await iterator.next()
|
|
127
|
+
if (done) {
|
|
128
|
+
controller.close()
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (value.delay_ms > 0 && !isFirst) {
|
|
133
|
+
const cappedDelay = Math.min(value.delay_ms, 30000)
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, cappedDelay))
|
|
135
|
+
}
|
|
136
|
+
isFirst = false
|
|
137
|
+
|
|
138
|
+
controller.enqueue(encoder.encode(value.data))
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function timedChunksToJsonl(chunks: Array<TimedChunk>): string {
|
|
144
|
+
const lines: Array<string> = []
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
147
|
+
const chunk = chunks[i]!
|
|
148
|
+
const items = extractAllSSEItems(chunk.data)
|
|
149
|
+
|
|
150
|
+
if (items.length > 0) {
|
|
151
|
+
// Process each SSE item in this chunk
|
|
152
|
+
for (const item of items) {
|
|
153
|
+
if (item.type === "comment") {
|
|
154
|
+
// Store comment lines with "comment" field
|
|
155
|
+
lines.push(JSON.stringify({ comment: item.raw }))
|
|
156
|
+
} else {
|
|
157
|
+
// item.type === "data"
|
|
158
|
+
const parsed = tryParseJson(item.payload)
|
|
159
|
+
if (parsed !== null) {
|
|
160
|
+
lines.push(JSON.stringify({ json: parsed }))
|
|
161
|
+
} else {
|
|
162
|
+
lines.push(JSON.stringify({ text: item.payload }))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
// Fallback: check if the chunk is a raw comment line
|
|
168
|
+
if (chunk.data.trimStart().startsWith(":")) {
|
|
169
|
+
lines.push(JSON.stringify({ comment: chunk.data }))
|
|
170
|
+
} else {
|
|
171
|
+
// Store raw data as text if no SSE payload found
|
|
172
|
+
lines.push(JSON.stringify({ text: chunk.data }))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Add timing line after all events from this chunk
|
|
177
|
+
// This represents the delay before the next network chunk
|
|
178
|
+
// Skip timing for the last chunk since there's nothing after it
|
|
179
|
+
if (i < chunks.length - 1) {
|
|
180
|
+
const nextChunk = chunks[i + 1]!
|
|
181
|
+
lines.push(JSON.stringify({ delay_ms: nextChunk.delay_ms }))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return lines.join("\n")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function jsonlToTimedChunks(jsonl: string): Array<TimedChunk> {
|
|
189
|
+
const lines = jsonl.split("\n").filter((line) => line.trim().length > 0)
|
|
190
|
+
const chunks: Array<TimedChunk> = []
|
|
191
|
+
let pendingDelay = 0
|
|
192
|
+
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
const parsed = JSON.parse(line) as
|
|
195
|
+
| JsonlRecord
|
|
196
|
+
| {
|
|
197
|
+
data: string
|
|
198
|
+
__timing: {
|
|
199
|
+
delay_ms: number
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Handle new format: separate timing lines
|
|
204
|
+
if ("delay_ms" in parsed && !("__timing" in parsed)) {
|
|
205
|
+
// This is a timing line - store for the next data chunk
|
|
206
|
+
pendingDelay = parsed.delay_ms
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Handle new format: {json: ...}, {text: ...}, or {comment: ...} (without __timing)
|
|
211
|
+
if ("json" in parsed && !("__timing" in parsed)) {
|
|
212
|
+
chunks.push({
|
|
213
|
+
data: `data: ${JSON.stringify(parsed.json)}\n\n`,
|
|
214
|
+
delay_ms: pendingDelay
|
|
215
|
+
})
|
|
216
|
+
pendingDelay = 0
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if ("text" in parsed && !("__timing" in parsed)) {
|
|
220
|
+
chunks.push({
|
|
221
|
+
data: `data: ${parsed.text}\n\n`,
|
|
222
|
+
delay_ms: pendingDelay
|
|
223
|
+
})
|
|
224
|
+
pendingDelay = 0
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
// Handle SSE comment lines - replay without data: prefix
|
|
228
|
+
if ("comment" in parsed && !("__timing" in parsed)) {
|
|
229
|
+
chunks.push({
|
|
230
|
+
data: parsed.comment,
|
|
231
|
+
delay_ms: pendingDelay
|
|
232
|
+
})
|
|
233
|
+
pendingDelay = 0
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Handle legacy format with __timing embedded
|
|
238
|
+
if ("json" in parsed && "__timing" in parsed) {
|
|
239
|
+
const legacy = parsed as {
|
|
240
|
+
json: unknown
|
|
241
|
+
__timing: {
|
|
242
|
+
delay_ms: number
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
chunks.push({
|
|
246
|
+
data: `data: ${JSON.stringify(legacy.json)}\n\n`,
|
|
247
|
+
delay_ms: legacy.__timing.delay_ms
|
|
248
|
+
})
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
if ("text" in parsed && "__timing" in parsed) {
|
|
252
|
+
const legacy = parsed as {
|
|
253
|
+
text: string
|
|
254
|
+
__timing: {
|
|
255
|
+
delay_ms: number
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
chunks.push({
|
|
259
|
+
data: `data: ${legacy.text}\n\n`,
|
|
260
|
+
delay_ms: legacy.__timing.delay_ms
|
|
261
|
+
})
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Handle oldest legacy format: {data: string, __timing: ...}
|
|
266
|
+
if ("data" in parsed) {
|
|
267
|
+
const oldest = parsed as {
|
|
268
|
+
data: string
|
|
269
|
+
__timing: {
|
|
270
|
+
delay_ms: number
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
chunks.push({
|
|
274
|
+
data: oldest.data,
|
|
275
|
+
delay_ms: oldest.__timing.delay_ms
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return chunks
|
|
281
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** Request data used for hashing and storage */
|
|
2
|
+
export interface HashableRequest {
|
|
3
|
+
url: string
|
|
4
|
+
method: string
|
|
5
|
+
headers: Record<string, string>
|
|
6
|
+
body?: string | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Storage Abstraction
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
/** Key type for cache storage: [cacheKey, optionalRequestContext] */
|
|
14
|
+
export type CacheKey = [key: string, request?: HashableRequest]
|
|
15
|
+
|
|
16
|
+
/** Generic key-value storage interface */
|
|
17
|
+
export interface KV<K, V> {
|
|
18
|
+
get(key: K): Promise<V | null>
|
|
19
|
+
set(key: K, value: V): Promise<void>
|
|
20
|
+
has(key: K): Promise<boolean>
|
|
21
|
+
delete?(key: K): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Generic streaming key-value storage interface */
|
|
25
|
+
export interface KVStream<K, V> {
|
|
26
|
+
get(key: K): AsyncIterable<V>
|
|
27
|
+
set(key: K, values: Array<V> | AsyncIterable<V>): Promise<void>
|
|
28
|
+
has(key: K): Promise<boolean>
|
|
29
|
+
delete?(key: K): Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Composed cache storage using specialized KV stores for each data type */
|
|
33
|
+
export interface CacheStorage {
|
|
34
|
+
requests: KV<CacheKey, CachedRequest>
|
|
35
|
+
responseMeta: KV<CacheKey, CachedResponseMeta>
|
|
36
|
+
responseBody: KV<CacheKey, string>
|
|
37
|
+
binaryBody: KV<CacheKey, Uint8Array>
|
|
38
|
+
sseChunks: KVStream<CacheKey, TimedChunk>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Transform hook that can be sync or async */
|
|
42
|
+
export type TransformHook<T> = (value: T) => T | Promise<T>
|
|
43
|
+
|
|
44
|
+
/** Generator transform hook for streaming data */
|
|
45
|
+
export type GeneratorTransformHook<T> = (
|
|
46
|
+
chunks: AsyncIterable<T>
|
|
47
|
+
) => AsyncIterable<T>
|
|
48
|
+
|
|
49
|
+
/** Response data for storage hooks */
|
|
50
|
+
export interface StorableResponse {
|
|
51
|
+
body?: string | undefined
|
|
52
|
+
meta: CachedResponseMeta
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CacheOptions {
|
|
56
|
+
cacheDir?: string
|
|
57
|
+
recordOnly?: boolean
|
|
58
|
+
replayOnly?: boolean
|
|
59
|
+
/** Custom storage implementation. Defaults to filesystem storage. */
|
|
60
|
+
storage?: CacheStorage
|
|
61
|
+
/** Fetch function for internal use (dev-fs-logs). Defaults to baseFetch. */
|
|
62
|
+
internalFetch?: typeof globalThis.fetch
|
|
63
|
+
/** Transform request data before hashing (affects cache key). Defaults to identity. */
|
|
64
|
+
beforeHash?: TransformHook<HashableRequest>
|
|
65
|
+
/** Transform request data before storing to disk. Defaults to identity. */
|
|
66
|
+
beforeStoreRequest?: TransformHook<HashableRequest>
|
|
67
|
+
/** Transform response data before storing to disk. Defaults to identity. */
|
|
68
|
+
beforeStoreResponse?: TransformHook<StorableResponse>
|
|
69
|
+
/** Transform response data after loading from cache. Defaults to identity. */
|
|
70
|
+
afterLoadResponse?: TransformHook<StorableResponse>
|
|
71
|
+
/** Transform SSE chunks during replay. Async generator receives and yields TimedChunks. */
|
|
72
|
+
transformSSEChunk?: GeneratorTransformHook<TimedChunk>
|
|
73
|
+
/** Transform the cache key after hashing. Use to add product-specific prefixes/suffixes to filenames. */
|
|
74
|
+
transformCacheKey?: (cacheKey: string, request: HashableRequest) => string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CachedRequestBody {
|
|
78
|
+
json?: unknown
|
|
79
|
+
text?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Raw request data before body parsing (internal use) */
|
|
83
|
+
export interface RawCachedRequest {
|
|
84
|
+
url: string
|
|
85
|
+
method: string
|
|
86
|
+
headers: Record<string, string>
|
|
87
|
+
body?: string | undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Cached request with parsed body (stored to disk) */
|
|
91
|
+
export interface CachedRequest {
|
|
92
|
+
url: string
|
|
93
|
+
method: string
|
|
94
|
+
headers: Record<string, string>
|
|
95
|
+
body?: CachedRequestBody | undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface TimedChunk {
|
|
99
|
+
data: string
|
|
100
|
+
delay_ms: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* JSONL format for SSE cache files.
|
|
105
|
+
* Each line is one of:
|
|
106
|
+
* - {json: unknown} - parsed JSON payload from SSE data field
|
|
107
|
+
* - {text: string} - raw text payload when JSON parsing fails (e.g., "[DONE]")
|
|
108
|
+
* - {comment: string} - SSE comment line (starts with ':'), used for keep-alive/status
|
|
109
|
+
* - {delay_ms: number} - timing between events (can be stripped with grep -v delay_ms)
|
|
110
|
+
*/
|
|
111
|
+
export type JsonlRecord =
|
|
112
|
+
| {
|
|
113
|
+
json: unknown
|
|
114
|
+
}
|
|
115
|
+
| {
|
|
116
|
+
text: string
|
|
117
|
+
}
|
|
118
|
+
| {
|
|
119
|
+
comment: string
|
|
120
|
+
}
|
|
121
|
+
| {
|
|
122
|
+
delay_ms: number
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface CachedResponseMeta {
|
|
126
|
+
status: number
|
|
127
|
+
statusText: string
|
|
128
|
+
headers: Record<string, string>
|
|
129
|
+
ttfb_ms: number
|
|
130
|
+
total_ms: number
|
|
131
|
+
is_sse: boolean
|
|
132
|
+
is_binary: boolean
|
|
133
|
+
cached_at: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface BinaryResponseMeta extends CachedResponseMeta {
|
|
137
|
+
is_binary: true
|
|
138
|
+
content_type: string
|
|
139
|
+
size: number
|
|
140
|
+
}
|