@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,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
+ }