Skip to content

Commit c8addb9

Browse files
committed
feat: add a per-request context store shared across hooks
Adds `options.context` — a per-request key-value store available in every hook as `context.context`, kept stable even when a hook replaces the request context (cf. Hono's c.set/c.get and ky's options.context). The logger now stores the request start time there instead of a WeakMap keyed by the replaceable context object, so duration survives when other plugins rewrite the context. Also dedups the logger via formatLine/parseBody helpers.
1 parent a1ce209 commit c8addb9

4 files changed

Lines changed: 151 additions & 77 deletions

File tree

packages/better-fetch/src/fetch.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BetterFetchError } from "./error";
2-
import { initializePlugins } from "./plugins";
2+
import { type RequestContext, initializePlugins } from "./plugins";
33
import { createRetryStrategy } from "./retry";
44
import type { StandardSchemaV1 } from "./standard-schema";
55
import type { BetterFetchOption, BetterFetchResponse } from "./types";
@@ -44,13 +44,19 @@ export const betterFetch = async <
4444
const headers = await getHeaders(opts);
4545
const body = getBody(opts, headers);
4646
const method = getMethod(__url, opts);
47-
let context = {
47+
// fresh per request so concurrent requests never share state
48+
const store: Record<string, unknown> = Object.assign(
49+
Object.create(null),
50+
opts.context,
51+
);
52+
let context: RequestContext = {
4853
...opts,
4954
url: _url,
5055
headers,
5156
body,
5257
method,
5358
signal,
59+
context: store,
5460
};
5561
/**
5662
* Run all on request hooks
@@ -60,6 +66,7 @@ export const betterFetch = async <
6066
const res = await onRequest(context);
6167
if (typeof res === "object" && res !== null) {
6268
context = res;
69+
context.context = store; // re-anchor after replacement
6370
}
6471
}
6572
}

packages/better-fetch/src/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { StandardSchemaV1 } from "./standard-schema";
55
import type { Prettify, StringLiteralUnion } from "./type-utils";
66

77
type CommonHeaders = {
8-
accept?: StringLiteralUnion<"application/json" | "text/plain" | "application/octet-stream">;
8+
accept?: StringLiteralUnion<
9+
"application/json" | "text/plain" | "application/octet-stream"
10+
>;
911
"content-type"?: StringLiteralUnion<
1012
| "application/json"
1113
| "text/plain"
@@ -144,6 +146,8 @@ export type BetterFetchOption<
144146
* Abort signal
145147
*/
146148
signal?: AbortSignal | null;
149+
/** Per-request store shared across hooks; stable across context replacement. */
150+
context?: Record<string, unknown>;
147151
}
148152
>;
149153

packages/logger/src/index.ts

Lines changed: 50 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,37 @@ const defaultConsole: ConsoleEsque = {
6464
},
6565
};
6666

67+
const START = "@better-fetch/logger.start";
68+
6769
function formatPrefix(method: string, url: string | URL): string {
6870
return `[${method.toUpperCase()}] ${url.toString()}`;
6971
}
7072

71-
function formatDuration(startTime: number | undefined): string {
72-
if (startTime === undefined) return "";
73-
const ms = Date.now() - startTime;
74-
return ` (${ms}ms)`;
73+
function formatLine(
74+
request: {
75+
method: string;
76+
url: string | URL;
77+
context?: Record<string, unknown>;
78+
},
79+
response: Response,
80+
): string {
81+
const status = response.status;
82+
const statusText = response.statusText || getStatusText(status);
83+
const start = request.context?.[START];
84+
const duration =
85+
typeof start === "number" ? ` (${Date.now() - start}ms)` : "";
86+
return `${formatPrefix(
87+
request.method,
88+
request.url,
89+
)}${status} ${statusText}${duration}`;
90+
}
91+
92+
async function parseBody(response: Response): Promise<unknown> {
93+
try {
94+
return await response.clone().json();
95+
} catch {
96+
return undefined;
97+
}
7598
}
7699

77100
export const logger = (options?: LoggerOptions) => {
@@ -83,7 +106,6 @@ export const logger = (options?: LoggerOptions) => {
83106
};
84107
const { enabled } = opts;
85108
const isLegacy = opts.logFormat === "legacy";
86-
const startTimes = new WeakMap<object, number>();
87109

88110
return {
89111
id: "logger",
@@ -92,17 +114,14 @@ export const logger = (options?: LoggerOptions) => {
92114
hooks: {
93115
onRequest(context) {
94116
if (!enabled) return;
95-
startTimes.set(context, Date.now());
117+
if (context.context) {
118+
context.context[START] = Date.now();
119+
}
96120
if (isLegacy) {
97-
opts.console.log(
98-
"Request being sent to:",
99-
context.url.toString(),
100-
);
121+
opts.console.log("Request being sent to:", context.url.toString());
101122
return;
102123
}
103-
opts.console.log(
104-
formatPrefix(context.method, context.url),
105-
);
124+
opts.console.log(formatPrefix(context.method, context.url));
106125
},
107126
async onSuccess(context) {
108127
if (!enabled) return;
@@ -111,81 +130,42 @@ export const logger = (options?: LoggerOptions) => {
111130
log("Request succeeded", context.data);
112131
return;
113132
}
114-
const duration = formatDuration(
115-
startTimes.get(context.request),
116-
);
117-
const status = context.response.status;
118-
const statusText =
119-
context.response.statusText || getStatusText(status);
120-
log(
121-
`${formatPrefix(context.request.method, context.request.url)}${status} ${statusText}${duration}`,
122-
);
133+
log(formatLine(context.request, context.response));
123134
if (opts.verbose) {
124135
opts.console.log(context.data);
125136
}
126137
},
127138
onRetry(response) {
128139
if (!enabled) return;
129140
const log = opts.console.warn || opts.console.log;
141+
const attempt = (response.request.retryAttempt || 0) + 1;
130142
if (isLegacy) {
131-
log(
132-
"Retrying request...",
133-
"Attempt:",
134-
(response.request.retryAttempt || 0) + 1,
135-
);
143+
log("Retrying request...", "Attempt:", attempt);
136144
return;
137145
}
138-
const attempt = (response.request.retryAttempt || 0) + 1;
139146
log(
140-
`${formatPrefix(response.request.method, response.request.url)} — Retry attempt #${attempt}`,
147+
`${formatPrefix(
148+
response.request.method,
149+
response.request.url,
150+
)} — Retry attempt #${attempt}`,
141151
);
142152
},
143153
async onError(context) {
144154
if (!enabled) return;
145155
const log = opts.console.fail || opts.console.error;
156+
const body = opts.verbose
157+
? await parseBody(context.response)
158+
: undefined;
146159
if (isLegacy) {
147-
let obj: any;
148-
try {
149-
if (opts.verbose) {
150-
const res = context.response.clone();
151-
const json = await res.json();
152-
if (json) {
153-
obj = json;
154-
}
155-
}
156-
} catch (e) {}
157-
log(
158-
"Request failed with status: ",
159-
context.response.status,
160-
`(${
161-
context.response.statusText ||
162-
getStatusText(context.response.status)
163-
})`,
164-
);
165-
opts.verbose && obj && opts.console.error(obj);
166-
return;
160+
const status = context.response.status;
161+
const statusText =
162+
context.response.statusText || getStatusText(status);
163+
log("Request failed with status: ", status, `(${statusText})`);
164+
} else {
165+
log(formatLine(context.request, context.response));
167166
}
168-
const duration = formatDuration(
169-
startTimes.get(context.request),
170-
);
171-
const status = context.response.status;
172-
const statusText =
173-
context.response.statusText || getStatusText(status);
174-
log(
175-
`${formatPrefix(context.request.method, context.request.url)}${status} ${statusText}${duration}`,
176-
);
177-
if (opts.verbose) {
178-
let obj: any;
179-
try {
180-
const res = context.response.clone();
181-
const json = await res.json();
182-
if (json) {
183-
obj = json;
184-
}
185-
} catch (e) {}
186-
if (obj) {
187-
opts.console.error(obj);
188-
}
167+
if (body) {
168+
opts.console.error(body);
189169
}
190170
},
191171
},

packages/logger/test/logger.test.ts

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { type BetterFetchPlugin, createFetch } from "@better-fetch/fetch";
12
import { describe, expect, it, vi } from "vitest";
2-
import { createFetch } from "@better-fetch/fetch";
33
import { logger } from "../src/index";
44

55
function mockConsole() {
@@ -82,9 +82,7 @@ describe("logger - default format", () => {
8282

8383
await $fetch("/users", { method: "POST", body: { name: "test" } });
8484

85-
expect(cons.log).toHaveBeenCalledWith(
86-
expect.stringContaining("[POST]"),
87-
);
85+
expect(cons.log).toHaveBeenCalledWith(expect.stringContaining("[POST]"));
8886
expect(cons.success).toHaveBeenCalledWith(
8987
expect.stringContaining("[POST]"),
9088
);
@@ -233,3 +231,88 @@ describe("logger - disabled", () => {
233231
expect(cons.error).not.toHaveBeenCalled();
234232
});
235233
});
234+
235+
describe("logger - per-request store", () => {
236+
it("keeps duration when another plugin replaces the request context", async () => {
237+
const cons = mockConsole();
238+
// onRequest returns a fresh context that drops the store
239+
const replaceContext = {
240+
id: "replace",
241+
name: "replace",
242+
hooks: {
243+
onRequest(context) {
244+
return { ...context, context: undefined };
245+
},
246+
},
247+
} satisfies BetterFetchPlugin;
248+
const $fetch = createFetch({
249+
baseURL: "http://localhost:3000",
250+
plugins: [logger({ console: cons }), replaceContext],
251+
customFetchImpl: createMockFetch(200, { ok: true }),
252+
});
253+
254+
await $fetch("/users");
255+
256+
expect(cons.success).toHaveBeenCalledTimes(1);
257+
const msg = cons.success.mock.calls[0][0] as string;
258+
expect(msg).toMatch(/\(\d+ms\)/);
259+
});
260+
261+
it("isolates the store per request even with a shared default context", async () => {
262+
const seen: string[] = [];
263+
const probe = {
264+
id: "probe",
265+
name: "probe",
266+
hooks: {
267+
onRequest(context) {
268+
if (context.context) {
269+
context.context.path = context.url.toString();
270+
}
271+
},
272+
onSuccess(context) {
273+
seen.push(context.request.context?.path as string);
274+
},
275+
},
276+
} satisfies BetterFetchPlugin;
277+
const $fetch = createFetch({
278+
baseURL: "http://localhost:3000",
279+
context: {}, // shared default
280+
plugins: [probe],
281+
customFetchImpl: async (input) => {
282+
if (input.toString().includes("/a")) {
283+
await new Promise((r) => setTimeout(r, 30));
284+
}
285+
return new Response(null, { status: 200 });
286+
},
287+
});
288+
289+
await Promise.all([$fetch("/a"), $fetch("/b")]);
290+
291+
expect([...seen].sort()).toEqual([
292+
"http://localhost:3000/a",
293+
"http://localhost:3000/b",
294+
]);
295+
});
296+
297+
it("exposes a null-prototype store with no inherited keys", async () => {
298+
let inherited: unknown = "sentinel";
299+
const probe = {
300+
id: "proto",
301+
name: "proto",
302+
hooks: {
303+
onRequest(context) {
304+
inherited = context.context?.toString;
305+
},
306+
},
307+
} satisfies BetterFetchPlugin;
308+
const $fetch = createFetch({
309+
baseURL: "http://localhost:3000",
310+
plugins: [probe],
311+
customFetchImpl: createMockFetch(200, { ok: true }),
312+
});
313+
314+
await $fetch("/x");
315+
316+
expect(inherited).toBeUndefined();
317+
});
318+
});

0 commit comments

Comments
 (0)