@lovrozagar/flare 0.7.0 → 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/src/server/index.ts +2 -7
- package/src/server-fn/index.ts +39 -36
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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface ServerBuilder<
|
|
|
54
54
|
callerData?: unknown[];
|
|
55
55
|
env: TEnv;
|
|
56
56
|
request: Request;
|
|
57
|
-
serverContext:
|
|
57
|
+
serverContext: TServerContext;
|
|
58
58
|
url: URL;
|
|
59
59
|
}) => TNewAuth | null | Promise<TNewAuth | null>,
|
|
60
60
|
) => ServerBuilder<TExcluded | "authenticateFn", TNewAuth, TServerContext, TEnv>;
|
|
@@ -72,12 +72,7 @@ export interface ServerBuilder<
|
|
|
72
72
|
: (
|
|
73
73
|
config:
|
|
74
74
|
| SecurityConfig
|
|
75
|
-
| ((ctx: {
|
|
76
|
-
env: TEnv;
|
|
77
|
-
nonce: string;
|
|
78
|
-
request: Request;
|
|
79
|
-
serverContext: Record<string, unknown>;
|
|
80
|
-
}) => SecurityConfig),
|
|
75
|
+
| ((ctx: { env: TEnv; nonce: string; request: Request; serverContext: TServerContext }) => SecurityConfig),
|
|
81
76
|
) => ServerBuilder<TExcluded | "security", TAuth, TServerContext, TEnv>;
|
|
82
77
|
serverContext: "serverContext" extends TExcluded
|
|
83
78
|
? never
|
package/src/server-fn/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ResolvedAuth, ResolvedEnv, ResolvedServerContext } from "../route-builder/register.ts";
|
|
1
2
|
import { FORM_FN_FIELD, parseServerFnPathname, serverFnPath } from "../protocol.ts";
|
|
2
3
|
import { callServerFnOverHttp } from "./rpc.ts";
|
|
3
4
|
import {
|
|
@@ -39,7 +40,8 @@ export interface PiggybackedQuery {
|
|
|
39
40
|
key: unknown[];
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
/* TEnv defaults to the app's registered env (`createServer<Env>()`) */
|
|
44
|
+
export interface HandlerContext<TAuth, TInput, TEnv = ResolvedEnv> {
|
|
43
45
|
auth: TAuth;
|
|
44
46
|
env: TEnv;
|
|
45
47
|
input: TInput;
|
|
@@ -56,15 +58,17 @@ export interface HandlerContext<TAuth, TInput, TEnv = unknown> {
|
|
|
56
58
|
piggyback: (key: unknown[], data: unknown) => void;
|
|
57
59
|
request: Request;
|
|
58
60
|
revalidate: RevalidateFn;
|
|
59
|
-
serverContext
|
|
61
|
+
/* the app's `.serverContext()` result, typed through FlareRegister */
|
|
62
|
+
serverContext: ResolvedServerContext;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
export interface StreamContext<TAuth, TInput, TEnv =
|
|
65
|
+
export interface StreamContext<TAuth, TInput, TEnv = ResolvedEnv> {
|
|
63
66
|
auth: TAuth;
|
|
64
67
|
env: TEnv;
|
|
65
68
|
input: TInput;
|
|
66
69
|
request: Request;
|
|
67
|
-
serverContext
|
|
70
|
+
/* the app's `.serverContext()` result, typed through FlareRegister */
|
|
71
|
+
serverContext: ResolvedServerContext;
|
|
68
72
|
signal: AbortSignal;
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -78,12 +82,12 @@ export interface ServerFnRegistrationBase {
|
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
export interface ServerFnHandlerRegistration extends ServerFnRegistrationBase {
|
|
81
|
-
fn: (ctx: HandlerContext<unknown, unknown>) => unknown | Promise<unknown>;
|
|
85
|
+
fn: (ctx: HandlerContext<unknown, unknown, unknown>) => unknown | Promise<unknown>;
|
|
82
86
|
stream?: false;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
export interface ServerFnStreamRegistration extends ServerFnRegistrationBase {
|
|
86
|
-
fn: (ctx: StreamContext<unknown, unknown>) => AsyncGenerator<unknown>;
|
|
90
|
+
fn: (ctx: StreamContext<unknown, unknown, unknown>) => AsyncGenerator<unknown>;
|
|
87
91
|
stream: true;
|
|
88
92
|
}
|
|
89
93
|
|
|
@@ -99,22 +103,24 @@ interface BuilderState {
|
|
|
99
103
|
name: string;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
interface ServerFnBuilderTerminal<TAuth, TInput
|
|
103
|
-
handler
|
|
104
|
-
|
|
106
|
+
interface ServerFnBuilderTerminal<TAuth, TInput> {
|
|
107
|
+
/* the output type is the handler's: callers get it without restating it */
|
|
108
|
+
handler<TOutput>(fn: (ctx: HandlerContext<TAuth, TInput>) => TOutput | Promise<TOutput>): ServerFn<TInput, TOutput>;
|
|
109
|
+
input<T>(validator: Validator<T>): ServerFnBuilderTerminal<TAuth, T>;
|
|
105
110
|
stream<TChunk>(fn: (ctx: StreamContext<TAuth, TInput>) => AsyncGenerator<TChunk>): StreamFn<TInput, TChunk>;
|
|
106
111
|
}
|
|
107
112
|
|
|
108
|
-
interface ServerFnBuilderAfterAuth<TAuth, TInput
|
|
113
|
+
interface ServerFnBuilderAfterAuth<TAuth, TInput> extends ServerFnBuilderTerminal<TAuth, TInput> {
|
|
109
114
|
authorize(
|
|
110
115
|
fn: (ctx: { auth: TAuth; input: TInput }) => boolean | Promise<boolean>,
|
|
111
|
-
): ServerFnBuilderTerminal<TAuth, TInput
|
|
112
|
-
input<T>(validator: Validator<T>): ServerFnBuilderAfterAuth<TAuth, T
|
|
116
|
+
): ServerFnBuilderTerminal<TAuth, TInput>;
|
|
117
|
+
input<T>(validator: Validator<T>): ServerFnBuilderAfterAuth<TAuth, T>;
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
interface ServerFnBuilder<TAuth, TInput
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
interface ServerFnBuilder<TAuth, TInput> extends ServerFnBuilderAfterAuth<TAuth, TInput> {
|
|
121
|
+
/* the app's `authenticateFn` result, typed through FlareRegister */
|
|
122
|
+
authenticate(): ServerFnBuilderAfterAuth<ResolvedAuth<true>, TInput>;
|
|
123
|
+
input<T>(validator: Validator<T>): ServerFnBuilder<TAuth, T>;
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
export type ServerFn<_TInput, TOutput> = ((_input: _TInput) => Promise<TOutput>) & {
|
|
@@ -128,15 +134,13 @@ export type StreamFn<_TInput, TChunk> = ((
|
|
|
128
134
|
_registration?: ServerFnRegistration;
|
|
129
135
|
};
|
|
130
136
|
|
|
131
|
-
function createBuilderTerminal<TAuth, TInput,
|
|
132
|
-
state: BuilderState,
|
|
133
|
-
): ServerFnBuilderTerminal<TAuth, TInput, TOutput> {
|
|
137
|
+
function createBuilderTerminal<TAuth, TInput>(state: BuilderState): ServerFnBuilderTerminal<TAuth, TInput> {
|
|
134
138
|
return {
|
|
135
|
-
handler(fn) {
|
|
139
|
+
handler<TOutput>(fn: (ctx: HandlerContext<TAuth, TInput>) => TOutput | Promise<TOutput>) {
|
|
136
140
|
const registration: ServerFnHandlerRegistration = {
|
|
137
141
|
authenticate: state.authenticate,
|
|
138
142
|
authorizeFn: state.authorizeFn,
|
|
139
|
-
fn: fn as (ctx: HandlerContext<unknown, unknown>) => unknown | Promise<unknown>,
|
|
143
|
+
fn: fn as (ctx: HandlerContext<unknown, unknown, unknown>) => unknown | Promise<unknown>,
|
|
140
144
|
id: state.__id && state.__id.length > 0 ? state.__id : state.name,
|
|
141
145
|
input: state.input,
|
|
142
146
|
method: state.method,
|
|
@@ -168,7 +172,8 @@ function createBuilderTerminal<TAuth, TInput, TOutput>(
|
|
|
168
172
|
};
|
|
169
173
|
return await fn({
|
|
170
174
|
auth: null as TAuth,
|
|
171
|
-
|
|
175
|
+
/* direct server-side invocation runs outside a request: there are no bindings */
|
|
176
|
+
env: {} as ResolvedEnv,
|
|
172
177
|
input: validated,
|
|
173
178
|
piggyback: () => {},
|
|
174
179
|
request: new Request("http://localhost"),
|
|
@@ -180,7 +185,7 @@ function createBuilderTerminal<TAuth, TInput, TOutput>(
|
|
|
180
185
|
return serverFn;
|
|
181
186
|
},
|
|
182
187
|
input<T>(validator: Validator<T>) {
|
|
183
|
-
return createBuilderTerminal<TAuth, T
|
|
188
|
+
return createBuilderTerminal<TAuth, T>({
|
|
184
189
|
...state,
|
|
185
190
|
input: validator as Validator<unknown>,
|
|
186
191
|
});
|
|
@@ -189,7 +194,7 @@ function createBuilderTerminal<TAuth, TInput, TOutput>(
|
|
|
189
194
|
const registration: ServerFnStreamRegistration = {
|
|
190
195
|
authenticate: state.authenticate,
|
|
191
196
|
authorizeFn: state.authorizeFn,
|
|
192
|
-
fn: fn as (ctx: StreamContext<unknown, unknown>) => AsyncGenerator<unknown>,
|
|
197
|
+
fn: fn as (ctx: StreamContext<unknown, unknown, unknown>) => AsyncGenerator<unknown>,
|
|
193
198
|
id: state.__id && state.__id.length > 0 ? state.__id : state.name,
|
|
194
199
|
input: state.input,
|
|
195
200
|
method: state.method,
|
|
@@ -218,7 +223,7 @@ function createBuilderTerminal<TAuth, TInput, TOutput>(
|
|
|
218
223
|
const validated = (await validatedPromise) as TInput;
|
|
219
224
|
generator = fn({
|
|
220
225
|
auth: null as TAuth,
|
|
221
|
-
env: {} as
|
|
226
|
+
env: {} as ResolvedEnv,
|
|
222
227
|
input: validated,
|
|
223
228
|
request: new Request("http://localhost"),
|
|
224
229
|
serverContext: getServerContext(),
|
|
@@ -243,19 +248,17 @@ function createBuilderTerminal<TAuth, TInput, TOutput>(
|
|
|
243
248
|
};
|
|
244
249
|
}
|
|
245
250
|
|
|
246
|
-
function createBuilderAfterAuth<TAuth, TInput,
|
|
247
|
-
state: BuilderState,
|
|
248
|
-
): ServerFnBuilderAfterAuth<TAuth, TInput, TOutput> {
|
|
251
|
+
function createBuilderAfterAuth<TAuth, TInput>(state: BuilderState): ServerFnBuilderAfterAuth<TAuth, TInput> {
|
|
249
252
|
return {
|
|
250
|
-
...createBuilderTerminal<TAuth, TInput
|
|
253
|
+
...createBuilderTerminal<TAuth, TInput>(state),
|
|
251
254
|
authorize(fn) {
|
|
252
|
-
return createBuilderTerminal<TAuth, TInput
|
|
255
|
+
return createBuilderTerminal<TAuth, TInput>({
|
|
253
256
|
...state,
|
|
254
257
|
authorizeFn: fn as (ctx: { auth: unknown; input: unknown }) => boolean | Promise<boolean>,
|
|
255
258
|
});
|
|
256
259
|
},
|
|
257
260
|
input<T>(validator: Validator<T>) {
|
|
258
|
-
return createBuilderAfterAuth<TAuth, T
|
|
261
|
+
return createBuilderAfterAuth<TAuth, T>({
|
|
259
262
|
...state,
|
|
260
263
|
input: validator as Validator<unknown>,
|
|
261
264
|
});
|
|
@@ -263,17 +266,17 @@ function createBuilderAfterAuth<TAuth, TInput, TOutput>(
|
|
|
263
266
|
};
|
|
264
267
|
}
|
|
265
268
|
|
|
266
|
-
function createBuilder<TAuth, TInput
|
|
269
|
+
function createBuilder<TAuth, TInput>(state: BuilderState): ServerFnBuilder<TAuth, TInput> {
|
|
267
270
|
return {
|
|
268
|
-
...createBuilderAfterAuth<TAuth, TInput
|
|
271
|
+
...createBuilderAfterAuth<TAuth, TInput>(state),
|
|
269
272
|
authenticate() {
|
|
270
|
-
return createBuilderAfterAuth<
|
|
273
|
+
return createBuilderAfterAuth<ResolvedAuth<true>, TInput>({
|
|
271
274
|
...state,
|
|
272
275
|
authenticate: true,
|
|
273
276
|
});
|
|
274
277
|
},
|
|
275
278
|
input<T>(validator: Validator<T>) {
|
|
276
|
-
return createBuilder<TAuth, T
|
|
279
|
+
return createBuilder<TAuth, T>({
|
|
277
280
|
...state,
|
|
278
281
|
input: validator as Validator<unknown>,
|
|
279
282
|
});
|
|
@@ -281,8 +284,8 @@ function createBuilder<TAuth, TInput, TOutput>(state: BuilderState): ServerFnBui
|
|
|
281
284
|
};
|
|
282
285
|
}
|
|
283
286
|
|
|
284
|
-
export function createServerFn(config: ServerFnConfig): ServerFnBuilder<null, void
|
|
285
|
-
return createBuilder<null, void
|
|
287
|
+
export function createServerFn(config: ServerFnConfig): ServerFnBuilder<null, void> {
|
|
288
|
+
return createBuilder<null, void>({
|
|
286
289
|
__client: config.__client,
|
|
287
290
|
__id: config.__id,
|
|
288
291
|
authenticate: false,
|