@lovrozagar/flare 0.7.1 → 0.8.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.
- package/README.md +19 -0
- package/package.json +2 -1
- package/src/dedupe/fetch.ts +404 -0
- package/src/dedupe/index.ts +1 -118
- package/src/fetch-dedupe.ts +36 -0
package/README.md
CHANGED
|
@@ -426,6 +426,24 @@ Pipeline: authenticate → authorize → preloaders (parent → child) → loade
|
|
|
426
426
|
|
|
427
427
|
`<Await>` also accepts `error` and `onError`. Missing deferred data must not throw in `.then` — the component treats a missing promise as pending.
|
|
428
428
|
|
|
429
|
+
### Fetch dedupe
|
|
430
|
+
|
|
431
|
+
During a server request, identical `GET` / `HEAD` fetches share one upstream call: concurrent callers wait on it, and later callers in the same request reuse it. The key is method, URL, headers (minus `traceparent`, `tracestate`, `x-request-id`, `x-correlation-id`), and the request's mode fields such as `redirect`. `globalThis.fetch` is covered automatically.
|
|
432
|
+
|
|
433
|
+
A fetch you pass around yourself, such as an SDK over a Workers service binding, opts in with `withFetchDedupe`. Pass the binding itself, not an arrow around it. Wrappers over the same target share one cache, so building the SDK per request still dedupes.
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
import { withFetchDedupe } from "@lovrozagar/flare/fetch-dedupe";
|
|
437
|
+
|
|
438
|
+
const sdk = new ApiClient({ fetch: withFetchDedupe(env.API) });
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
- Each caller gets its own `Response` with the upstream status, headers, `url`, and `redirected`.
|
|
442
|
+
- A caller's `AbortSignal` cancels only that caller. The upstream fetch is aborted once every caller has aborted.
|
|
443
|
+
- Any other method passes through and clears the request's memoized responses.
|
|
444
|
+
- Bodies over `maxBytes` (default 1 MiB, `withFetchDedupe(target, { maxBytes })`) and `text/event-stream` responses still stream but are not reused by later callers.
|
|
445
|
+
- Outside a request (browser, module scope, queue consumers) it is a plain passthrough.
|
|
446
|
+
|
|
429
447
|
## Hooks
|
|
430
448
|
|
|
431
449
|
From `@lovrozagar/flare` (route-builder barrel) / the same names on the provider:
|
|
@@ -1124,6 +1142,7 @@ Import features from their path.
|
|
|
1124
1142
|
| `@lovrozagar/flare/lazy` | `lazy`, `clientLazy` |
|
|
1125
1143
|
| `@lovrozagar/flare/server` | `createServer` |
|
|
1126
1144
|
| `@lovrozagar/flare/server-context` | ALS, `background` |
|
|
1145
|
+
| `@lovrozagar/flare/fetch-dedupe` | `withFetchDedupe` |
|
|
1127
1146
|
| `@lovrozagar/flare/server-only` | `createServerOnlyFn` |
|
|
1128
1147
|
| `@lovrozagar/flare/client-only` | `createClientOnlyFn` |
|
|
1129
1148
|
| `@lovrozagar/flare/isomorphic` | `createIsomorphicFn` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovrozagar/flare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Solid meta-framework. Server-driven, NDJSON streaming, renderToStream.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"flare",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./codegen": "./src/codegen/index.ts",
|
|
35
35
|
"./direction": "./src/direction.ts",
|
|
36
36
|
"./errors": "./src/errors/index.ts",
|
|
37
|
+
"./fetch-dedupe": "./src/fetch-dedupe.ts",
|
|
37
38
|
"./fonts": "./src/fonts/index.ts",
|
|
38
39
|
"./fonts/*": "./src/fonts/*.ts",
|
|
39
40
|
"./form": "./src/form/index.tsx",
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { getServerRequestContext } from "@lovrozagar/flare/server-context";
|
|
2
|
+
|
|
3
|
+
export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
4
|
+
|
|
5
|
+
const FETCH_CACHE_KEY = "__flare_fetch_dedupe";
|
|
6
|
+
/** Bodies above this are streamed to their caller but never memoized. */
|
|
7
|
+
export const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
const SHAREABLE_METHODS = new Set(["GET", "HEAD"]);
|
|
10
|
+
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
11
|
+
const EXCLUDED_HEADERS = new Set(["traceparent", "tracestate", "x-correlation-id", "x-request-id"]);
|
|
12
|
+
|
|
13
|
+
function isRequest(input: RequestInfo | URL): input is Request {
|
|
14
|
+
return typeof input === "object" && !(input instanceof URL) && "method" in input && "url" in input;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function methodOf(input: RequestInfo | URL, init: RequestInit | undefined): string {
|
|
18
|
+
return (init?.method ?? (isRequest(input) ? input.method : "GET")).toUpperCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* `new Request` normalizes the URL and header names the same way the runtime's fetch will. */
|
|
22
|
+
function keyOf(scope: string, request: Request): string {
|
|
23
|
+
const headers: Array<[string, string]> = [];
|
|
24
|
+
request.headers.forEach((value, name) => {
|
|
25
|
+
if (!EXCLUDED_HEADERS.has(name)) headers.push([name, value]);
|
|
26
|
+
});
|
|
27
|
+
headers.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
28
|
+
return JSON.stringify([
|
|
29
|
+
scope,
|
|
30
|
+
request.method,
|
|
31
|
+
request.url,
|
|
32
|
+
headers,
|
|
33
|
+
request.redirect,
|
|
34
|
+
request.cache,
|
|
35
|
+
request.credentials,
|
|
36
|
+
request.integrity,
|
|
37
|
+
request.mode,
|
|
38
|
+
request.referrer,
|
|
39
|
+
request.referrerPolicy,
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requestCache(): Map<string, SharedFetch> | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const ctx = getServerRequestContext();
|
|
46
|
+
let cache = ctx.get<Map<string, SharedFetch>>(FETCH_CACHE_KEY);
|
|
47
|
+
if (!cache) {
|
|
48
|
+
cache = new Map();
|
|
49
|
+
ctx.set(FETCH_CACHE_KEY, cache);
|
|
50
|
+
}
|
|
51
|
+
return cache;
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isShareable(response: Response, maxBytes: number): boolean {
|
|
58
|
+
if (response.status < 200 || response.status > 599) return false;
|
|
59
|
+
const type = response.headers.get("content-type");
|
|
60
|
+
if (type && type.trim().toLowerCase().startsWith("text/event-stream")) return false;
|
|
61
|
+
const length = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
62
|
+
return !(length > maxBytes);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Head {
|
|
66
|
+
headers: Headers;
|
|
67
|
+
redirected: boolean;
|
|
68
|
+
status: number;
|
|
69
|
+
statusText: string;
|
|
70
|
+
url: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function build(head: Head, body: ReadableStream<Uint8Array> | null): Response {
|
|
74
|
+
const response = new Response(body, { headers: head.headers, status: head.status, statusText: head.statusText });
|
|
75
|
+
/* Constructed responses have no url / redirected; keep the upstream values callers rely on. */
|
|
76
|
+
if (head.url) Object.defineProperty(response, "url", { value: head.url });
|
|
77
|
+
if (head.redirected) Object.defineProperty(response, "redirected", { value: true });
|
|
78
|
+
return response;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Errors the caller's body with its abort reason, as a native fetch body would. */
|
|
82
|
+
function abortable(body: ReadableStream<Uint8Array>, signal: AbortSignal | undefined): ReadableStream<Uint8Array> {
|
|
83
|
+
if (!signal) return body;
|
|
84
|
+
const reader = body.getReader();
|
|
85
|
+
let finished = false;
|
|
86
|
+
let onAbort: (() => void) | undefined;
|
|
87
|
+
const finish = () => {
|
|
88
|
+
finished = true;
|
|
89
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
90
|
+
};
|
|
91
|
+
return new ReadableStream<Uint8Array>(
|
|
92
|
+
{
|
|
93
|
+
cancel(reason) {
|
|
94
|
+
finish();
|
|
95
|
+
return reader.cancel(reason);
|
|
96
|
+
},
|
|
97
|
+
async pull(controller) {
|
|
98
|
+
const { done, value } = await reader.read();
|
|
99
|
+
if (finished) return;
|
|
100
|
+
if (done) {
|
|
101
|
+
finish();
|
|
102
|
+
controller.close();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
controller.enqueue(value);
|
|
106
|
+
},
|
|
107
|
+
start(controller) {
|
|
108
|
+
onAbort = () => {
|
|
109
|
+
if (finished) return;
|
|
110
|
+
finish();
|
|
111
|
+
controller.error(signal.reason);
|
|
112
|
+
reader.cancel(signal.reason).catch(() => {});
|
|
113
|
+
};
|
|
114
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{ highWaterMark: 0 },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reads one tee branch eagerly and replays it to every later caller. Past `maxBytes` the entry is
|
|
123
|
+
* evicted and, once no replay reader needs the rest, the branch is canceled so the first caller's
|
|
124
|
+
* branch keeps native backpressure. No branch is ever left unread.
|
|
125
|
+
*/
|
|
126
|
+
class Replay {
|
|
127
|
+
private chunks: Uint8Array[] = [];
|
|
128
|
+
private bytes = 0;
|
|
129
|
+
private done = false;
|
|
130
|
+
private failed = false;
|
|
131
|
+
private error: unknown;
|
|
132
|
+
private overflowed = false;
|
|
133
|
+
private readers = 0;
|
|
134
|
+
private wakers: Array<() => void> = [];
|
|
135
|
+
|
|
136
|
+
constructor(
|
|
137
|
+
source: ReadableStream<Uint8Array>,
|
|
138
|
+
private readonly maxBytes: number,
|
|
139
|
+
private readonly evict: () => void,
|
|
140
|
+
) {
|
|
141
|
+
void this.pump(source.getReader());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private notify(): void {
|
|
145
|
+
const wakers = this.wakers;
|
|
146
|
+
this.wakers = [];
|
|
147
|
+
for (const wake of wakers) wake();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async pump(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
|
|
151
|
+
try {
|
|
152
|
+
for (;;) {
|
|
153
|
+
const { done, value } = await reader.read();
|
|
154
|
+
if (done) break;
|
|
155
|
+
this.bytes += value.byteLength;
|
|
156
|
+
if (this.bytes > this.maxBytes && !this.overflowed) {
|
|
157
|
+
this.overflowed = true;
|
|
158
|
+
this.evict();
|
|
159
|
+
}
|
|
160
|
+
if (this.overflowed && this.readers === 0) {
|
|
161
|
+
this.chunks = [];
|
|
162
|
+
await reader.cancel();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.chunks.push(value);
|
|
166
|
+
this.notify();
|
|
167
|
+
}
|
|
168
|
+
this.done = true;
|
|
169
|
+
} catch (e: unknown) {
|
|
170
|
+
this.failed = true;
|
|
171
|
+
this.error = e;
|
|
172
|
+
this.evict();
|
|
173
|
+
}
|
|
174
|
+
this.notify();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
stream(signal: AbortSignal | undefined): ReadableStream<Uint8Array> {
|
|
178
|
+
this.readers++;
|
|
179
|
+
let index = 0;
|
|
180
|
+
let finished = false;
|
|
181
|
+
let onAbort: (() => void) | undefined;
|
|
182
|
+
const finish = () => {
|
|
183
|
+
if (finished) return;
|
|
184
|
+
finished = true;
|
|
185
|
+
this.readers--;
|
|
186
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
187
|
+
};
|
|
188
|
+
return new ReadableStream<Uint8Array>(
|
|
189
|
+
{
|
|
190
|
+
cancel: finish,
|
|
191
|
+
pull: async (controller) => {
|
|
192
|
+
for (;;) {
|
|
193
|
+
if (finished) return;
|
|
194
|
+
const chunk = this.chunks[index];
|
|
195
|
+
if (chunk) {
|
|
196
|
+
index++;
|
|
197
|
+
/* Each caller owns its bytes; a consumer that detaches a buffer cannot corrupt another. */
|
|
198
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (this.failed) {
|
|
202
|
+
finish();
|
|
203
|
+
controller.error(this.error);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (this.done) {
|
|
207
|
+
finish();
|
|
208
|
+
controller.close();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
await new Promise<void>((resolve) => this.wakers.push(resolve));
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
start: (controller) => {
|
|
215
|
+
if (!signal) return;
|
|
216
|
+
onAbort = () => {
|
|
217
|
+
if (finished) return;
|
|
218
|
+
finish();
|
|
219
|
+
controller.error(signal.reason);
|
|
220
|
+
this.notify();
|
|
221
|
+
};
|
|
222
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{ highWaterMark: 0 },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface Waiter {
|
|
231
|
+
reject: (reason: unknown) => void;
|
|
232
|
+
resolve: (response: Response | Promise<Response>) => void;
|
|
233
|
+
signal: AbortSignal | undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One upstream fetch shared by every caller in the request with the same key. */
|
|
237
|
+
class SharedFetch {
|
|
238
|
+
private readonly upstream = new AbortController();
|
|
239
|
+
private waiters: Waiter[] = [];
|
|
240
|
+
/** A caller without a signal can never abort, so the upstream fetch must run to completion. */
|
|
241
|
+
private pinned = false;
|
|
242
|
+
private head: Head | undefined;
|
|
243
|
+
private body: Replay | null | undefined;
|
|
244
|
+
|
|
245
|
+
constructor(
|
|
246
|
+
private readonly cache: Map<string, SharedFetch>,
|
|
247
|
+
private readonly key: string,
|
|
248
|
+
private readonly call: FetchFn,
|
|
249
|
+
private readonly input: RequestInfo | URL,
|
|
250
|
+
private readonly init: RequestInit | undefined,
|
|
251
|
+
private readonly maxBytes: number,
|
|
252
|
+
) {
|
|
253
|
+
Promise.resolve()
|
|
254
|
+
.then(() => call(input, { ...init, signal: this.upstream.signal }))
|
|
255
|
+
.then(
|
|
256
|
+
(response) => this.settle(response),
|
|
257
|
+
(error: unknown) => this.fail(error),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private evict(): void {
|
|
262
|
+
if (this.cache.get(this.key) === this) this.cache.delete(this.key);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
join(signal: AbortSignal | undefined): Promise<Response> {
|
|
266
|
+
const head = this.head;
|
|
267
|
+
if (head) return Promise.resolve(build(head, this.body ? this.body.stream(signal) : null));
|
|
268
|
+
return new Promise<Response>((resolve, reject) => {
|
|
269
|
+
const waiter: Waiter = { reject, resolve, signal };
|
|
270
|
+
this.waiters.push(waiter);
|
|
271
|
+
if (!signal) {
|
|
272
|
+
this.pinned = true;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
signal.addEventListener(
|
|
276
|
+
"abort",
|
|
277
|
+
() => {
|
|
278
|
+
const index = this.waiters.indexOf(waiter);
|
|
279
|
+
if (index === -1) return;
|
|
280
|
+
this.waiters.splice(index, 1);
|
|
281
|
+
reject(signal.reason);
|
|
282
|
+
if (!this.pinned && this.waiters.length === 0) {
|
|
283
|
+
this.evict();
|
|
284
|
+
this.upstream.abort(signal.reason);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
{ once: true },
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private fail(error: unknown): void {
|
|
293
|
+
this.evict();
|
|
294
|
+
const waiters = this.waiters;
|
|
295
|
+
this.waiters = [];
|
|
296
|
+
for (const waiter of waiters) waiter.reject(error);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private settle(response: Response): void {
|
|
300
|
+
const waiters = this.waiters;
|
|
301
|
+
this.waiters = [];
|
|
302
|
+
const [first, ...rest] = waiters;
|
|
303
|
+
if (!first) {
|
|
304
|
+
this.evict();
|
|
305
|
+
response.body?.cancel().catch(() => {});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!isShareable(response, this.maxBytes)) {
|
|
310
|
+
this.evict();
|
|
311
|
+
const signal = first.signal;
|
|
312
|
+
if (signal) signal.addEventListener("abort", () => this.upstream.abort(signal.reason), { once: true });
|
|
313
|
+
first.resolve(response);
|
|
314
|
+
for (const waiter of rest) {
|
|
315
|
+
waiter.resolve(this.call(this.input, { ...this.init, signal: waiter.signal }));
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const head: Head = {
|
|
321
|
+
headers: new Headers(response.headers),
|
|
322
|
+
redirected: response.redirected,
|
|
323
|
+
status: response.status,
|
|
324
|
+
statusText: response.statusText,
|
|
325
|
+
url: response.url,
|
|
326
|
+
};
|
|
327
|
+
const source = NULL_BODY_STATUSES.has(response.status) ? null : response.body;
|
|
328
|
+
if (!source) {
|
|
329
|
+
if (response.body) response.body.cancel().catch(() => {});
|
|
330
|
+
this.body = null;
|
|
331
|
+
this.head = head;
|
|
332
|
+
for (const waiter of waiters) waiter.resolve(build(head, null));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const [own, shared] = source.tee();
|
|
337
|
+
this.body = new Replay(shared, this.maxBytes, () => this.evict());
|
|
338
|
+
this.head = head;
|
|
339
|
+
first.resolve(build(head, abortable(own, first.signal)));
|
|
340
|
+
for (const waiter of rest) waiter.resolve(build(head, this.body.stream(waiter.signal)));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Wraps `call` so GET and HEAD requests with the same key share one upstream fetch for the
|
|
346
|
+
* rest of the current server request. Other methods pass through and drop every memoized
|
|
347
|
+
* response. Outside a request (browser, module scope, background jobs) it is a plain passthrough.
|
|
348
|
+
*/
|
|
349
|
+
export function createDedupedFetch(call: FetchFn, scope: string, maxBytes = DEFAULT_MAX_BYTES): FetchFn {
|
|
350
|
+
return (input, init) => {
|
|
351
|
+
const cache = requestCache();
|
|
352
|
+
if (!cache) return call(input, init);
|
|
353
|
+
const method = methodOf(input, init);
|
|
354
|
+
if (!SHAREABLE_METHODS.has(method)) {
|
|
355
|
+
cache.clear();
|
|
356
|
+
return call(input, init);
|
|
357
|
+
}
|
|
358
|
+
const signal = init && "signal" in init ? (init.signal ?? undefined) : isRequest(input) ? input.signal : undefined;
|
|
359
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
360
|
+
|
|
361
|
+
let key: string;
|
|
362
|
+
try {
|
|
363
|
+
key = keyOf(scope, new Request(input, init));
|
|
364
|
+
} catch {
|
|
365
|
+
/* Let the runtime's fetch report an invalid request the way it normally does. */
|
|
366
|
+
return call(input, init);
|
|
367
|
+
}
|
|
368
|
+
let entry = cache.get(key);
|
|
369
|
+
if (!entry) {
|
|
370
|
+
entry = new SharedFetch(cache, key, call, input, init, maxBytes);
|
|
371
|
+
cache.set(key, entry);
|
|
372
|
+
}
|
|
373
|
+
return entry.join(signal);
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let originalFetch: typeof globalThis.fetch | undefined;
|
|
378
|
+
let enableCount = 0;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Reference-counted enable/disable to avoid race conditions
|
|
382
|
+
* when concurrent requests share the same isolate.
|
|
383
|
+
*/
|
|
384
|
+
export function enableFetchDedupe(): void {
|
|
385
|
+
enableCount++;
|
|
386
|
+
if (enableCount === 1) {
|
|
387
|
+
const base = globalThis.fetch;
|
|
388
|
+
originalFetch = base;
|
|
389
|
+
globalThis.fetch = createDedupedFetch((input, init) => base(input, init), "global") as typeof globalThis.fetch;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function disableFetchDedupe(): void {
|
|
394
|
+
enableCount--;
|
|
395
|
+
if (enableCount <= 0) {
|
|
396
|
+
if (originalFetch) globalThis.fetch = originalFetch;
|
|
397
|
+
originalFetch = undefined;
|
|
398
|
+
enableCount = 0;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function isFetchDedupeEnabled(): boolean {
|
|
403
|
+
return enableCount > 0;
|
|
404
|
+
}
|
package/src/dedupe/index.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { warn } from "../logger.ts";
|
|
2
1
|
import { getServerRequestContext } from "@lovrozagar/flare/server-context";
|
|
3
2
|
|
|
4
3
|
const DEDUPE_CACHE_KEY = "__flare_dedupe";
|
|
5
|
-
const FETCH_CACHE_KEY = "__flare_fetch_dedupe";
|
|
6
4
|
|
|
7
5
|
let fnCounter = 0;
|
|
8
6
|
|
|
@@ -16,20 +14,6 @@ function getDedupeCache(): Map<string, Promise<unknown>> {
|
|
|
16
14
|
return cache;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
function getFetchCache(): Map<string, Promise<Response>> | undefined {
|
|
20
|
-
try {
|
|
21
|
-
const ctx = getServerRequestContext();
|
|
22
|
-
let cache = ctx.get<Map<string, Promise<Response>>>(FETCH_CACHE_KEY);
|
|
23
|
-
if (!cache) {
|
|
24
|
-
cache = new Map();
|
|
25
|
-
ctx.set(FETCH_CACHE_KEY, cache);
|
|
26
|
-
}
|
|
27
|
-
return cache;
|
|
28
|
-
} catch {
|
|
29
|
-
return undefined;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
17
|
export function dedupe<TArgs extends unknown[], TResult>(
|
|
34
18
|
fn: (...args: TArgs) => Promise<TResult>,
|
|
35
19
|
): (...args: TArgs) => Promise<TResult> {
|
|
@@ -47,105 +31,4 @@ export function dedupe<TArgs extends unknown[], TResult>(
|
|
|
47
31
|
};
|
|
48
32
|
}
|
|
49
33
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
function getFetchCacheKey(input: RequestInfo | URL, init?: RequestInit): string | undefined {
|
|
53
|
-
let method = "GET";
|
|
54
|
-
let url: string;
|
|
55
|
-
|
|
56
|
-
if (input instanceof Request) {
|
|
57
|
-
method = input.method.toUpperCase();
|
|
58
|
-
url = input.url;
|
|
59
|
-
} else if (input instanceof URL) {
|
|
60
|
-
url = input.toString();
|
|
61
|
-
} else {
|
|
62
|
-
url = input;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (init?.method) {
|
|
66
|
-
method = init.method.toUpperCase();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (method !== "GET" && method !== "HEAD") {
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
let headersKey = "";
|
|
74
|
-
const headers = init?.headers ?? (input instanceof Request ? input.headers : undefined);
|
|
75
|
-
if (headers) {
|
|
76
|
-
const entries: Array<[string, string]> = [];
|
|
77
|
-
if (headers instanceof Headers) {
|
|
78
|
-
headers.forEach((v, k) => {
|
|
79
|
-
if (!EXCLUDED_HEADERS.has(k.toLowerCase())) {
|
|
80
|
-
entries.push([k.toLowerCase(), v]);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
} else if (Array.isArray(headers)) {
|
|
84
|
-
for (const [k, v] of headers) {
|
|
85
|
-
if (!EXCLUDED_HEADERS.has(k.toLowerCase())) {
|
|
86
|
-
entries.push([k.toLowerCase(), v]);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
for (const k of Object.keys(headers)) {
|
|
91
|
-
if (!EXCLUDED_HEADERS.has(k.toLowerCase())) {
|
|
92
|
-
entries.push([k.toLowerCase(), headers[k] as string]);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
97
|
-
headersKey = JSON.stringify(entries);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return `fetch:${method}:${url}:${headersKey}`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
let originalFetch: typeof globalThis.fetch | undefined;
|
|
104
|
-
let enableCount = 0;
|
|
105
|
-
|
|
106
|
-
function createDedupedFetch(baseFetch: typeof globalThis.fetch): typeof globalThis.fetch {
|
|
107
|
-
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
108
|
-
const key = getFetchCacheKey(input, init);
|
|
109
|
-
if (!key) return baseFetch(input, init);
|
|
110
|
-
|
|
111
|
-
const cache = getFetchCache();
|
|
112
|
-
if (!cache) return baseFetch(input, init);
|
|
113
|
-
|
|
114
|
-
const existing = cache.get(key);
|
|
115
|
-
if (existing) return (await existing).clone();
|
|
116
|
-
|
|
117
|
-
const promise = baseFetch(input, init);
|
|
118
|
-
cache.set(key, promise);
|
|
119
|
-
/* Clean up cache on rejection so retries within same request can succeed */
|
|
120
|
-
promise.catch((e: unknown) => {
|
|
121
|
-
warn("dedupe", "fetch failed, clearing cache", e);
|
|
122
|
-
cache.delete(key);
|
|
123
|
-
});
|
|
124
|
-
return (await promise).clone();
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Reference-counted enable/disable to avoid race conditions
|
|
130
|
-
* when concurrent requests share the same isolate.
|
|
131
|
-
*/
|
|
132
|
-
export function enableFetchDedupe(): void {
|
|
133
|
-
enableCount++;
|
|
134
|
-
if (enableCount === 1) {
|
|
135
|
-
originalFetch = globalThis.fetch;
|
|
136
|
-
globalThis.fetch = createDedupedFetch(originalFetch);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function disableFetchDedupe(): void {
|
|
141
|
-
enableCount--;
|
|
142
|
-
if (enableCount <= 0) {
|
|
143
|
-
if (originalFetch) globalThis.fetch = originalFetch;
|
|
144
|
-
originalFetch = undefined;
|
|
145
|
-
enableCount = 0;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function isFetchDedupeEnabled(): boolean {
|
|
150
|
-
return enableCount > 0;
|
|
151
|
-
}
|
|
34
|
+
export { disableFetchDedupe, enableFetchDedupe, isFetchDedupeEnabled } from "./fetch.ts";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createDedupedFetch, DEFAULT_MAX_BYTES, type FetchFn } from "./dedupe/fetch.ts";
|
|
2
|
+
|
|
3
|
+
/** A fetch function, or anything with a `fetch` method such as a Workers service binding. */
|
|
4
|
+
export type FetchTarget = FetchFn | { fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> };
|
|
5
|
+
|
|
6
|
+
export interface FetchDedupeOptions {
|
|
7
|
+
/** Largest body that is memoized for later callers. Larger bodies still stream. Default 1 MiB. */
|
|
8
|
+
maxBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const scopes = new WeakMap<object, string>();
|
|
12
|
+
let scopeCounter = 0;
|
|
13
|
+
|
|
14
|
+
function scopeOf(target: object): string {
|
|
15
|
+
let scope = scopes.get(target);
|
|
16
|
+
if (!scope) {
|
|
17
|
+
scopeCounter++;
|
|
18
|
+
scope = `target:${scopeCounter}`;
|
|
19
|
+
scopes.set(target, scope);
|
|
20
|
+
}
|
|
21
|
+
return scope;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Request-scoped GET / HEAD dedupe for a fetch that is not `globalThis.fetch`, such as an SDK
|
|
26
|
+
* over a service binding. Flare already dedupes `globalThis.fetch` during a request.
|
|
27
|
+
*
|
|
28
|
+
* Wrappers over the same target share one cache, so build the SDK per request and pass the
|
|
29
|
+
* binding itself (`withFetchDedupe(env.API)`), not a new arrow around it. Bindings are called
|
|
30
|
+
* as methods, so `this` stays intact.
|
|
31
|
+
*/
|
|
32
|
+
export function withFetchDedupe(target: FetchTarget, options?: FetchDedupeOptions): FetchFn {
|
|
33
|
+
const call: FetchFn =
|
|
34
|
+
typeof target === "function" ? (input, init) => target(input, init) : (input, init) => target.fetch(input, init);
|
|
35
|
+
return createDedupedFetch(call, scopeOf(target), options?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
36
|
+
}
|