@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 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.7.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
+ }
@@ -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
- const EXCLUDED_HEADERS = new Set(["traceparent", "tracestate", "x-request-id", "x-correlation-id"]);
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
+ }
@@ -54,7 +54,7 @@ export interface ServerBuilder<
54
54
  callerData?: unknown[];
55
55
  env: TEnv;
56
56
  request: Request;
57
- serverContext: Record<string, unknown>;
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
@@ -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
- export interface HandlerContext<TAuth, TInput, TEnv = unknown> {
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: Record<string, unknown>;
61
+ /* the app's `.serverContext()` result, typed through FlareRegister */
62
+ serverContext: ResolvedServerContext;
60
63
  }
61
64
 
62
- export interface StreamContext<TAuth, TInput, TEnv = unknown> {
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: Record<string, unknown>;
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, TOutput> {
103
- handler(fn: (ctx: HandlerContext<TAuth, TInput>) => TOutput | Promise<TOutput>): ServerFn<TInput, TOutput>;
104
- input<T>(validator: Validator<T>): ServerFnBuilderTerminal<TAuth, T, TOutput>;
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, TOutput> extends ServerFnBuilderTerminal<TAuth, TInput, TOutput> {
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, TOutput>;
112
- input<T>(validator: Validator<T>): ServerFnBuilderAfterAuth<TAuth, T, TOutput>;
116
+ ): ServerFnBuilderTerminal<TAuth, TInput>;
117
+ input<T>(validator: Validator<T>): ServerFnBuilderAfterAuth<TAuth, T>;
113
118
  }
114
119
 
115
- interface ServerFnBuilder<TAuth, TInput, TOutput> extends ServerFnBuilderAfterAuth<TAuth, TInput, TOutput> {
116
- authenticate(): ServerFnBuilderAfterAuth<unknown, TInput, TOutput>;
117
- input<T>(validator: Validator<T>): ServerFnBuilder<TAuth, T, TOutput>;
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, TOutput>(
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
- env: {},
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, TOutput>({
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 unknown,
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, TOutput>(
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, TOutput>(state),
253
+ ...createBuilderTerminal<TAuth, TInput>(state),
251
254
  authorize(fn) {
252
- return createBuilderTerminal<TAuth, TInput, TOutput>({
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, TOutput>({
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, TOutput>(state: BuilderState): ServerFnBuilder<TAuth, TInput, TOutput> {
269
+ function createBuilder<TAuth, TInput>(state: BuilderState): ServerFnBuilder<TAuth, TInput> {
267
270
  return {
268
- ...createBuilderAfterAuth<TAuth, TInput, TOutput>(state),
271
+ ...createBuilderAfterAuth<TAuth, TInput>(state),
269
272
  authenticate() {
270
- return createBuilderAfterAuth<unknown, TInput, TOutput>({
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, TOutput>({
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, unknown> {
285
- return createBuilder<null, void, unknown>({
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,