Skip to content

Commit 9ff66ee

Browse files
authored
refactoring: phase 0 (#41)
Minor changes refactor request lifecycle architecture introduce execution context add AbortSignal support (internal + partial public) extract request metadata and controller helpers improve testability of core modules
1 parent 43b51dd commit 9ff66ee

15 files changed

+467
-80
lines changed

.changeset/nine-wasps-fetch.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@dfsync/client': minor
3+
---
4+
5+
Minor changes
6+
7+
- refactor request lifecycle architecture
8+
- introduce execution context
9+
- add AbortSignal support (internal + partial public)
10+
- extract request metadata and controller helpers
11+
- improve testability of core modules

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ vite.config.ts.timestamp-*
140140

141141
.vitest
142142
.DS_Store
143+
.idea/
143144

144145
.tmp/
145146
smoke/**/node_modules/
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { ExecutionContext } from './execution-context';
2+
3+
export function applyRequestMetadata(execution: ExecutionContext): void {
4+
execution.headers['x-request-id'] = execution.headers['x-request-id'] ?? execution.requestId;
5+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export type RequestController = {
2+
signal: AbortSignal;
3+
cleanup: () => void;
4+
};
5+
6+
type CreateRequestControllerParams = {
7+
timeout: number;
8+
signal?: AbortSignal | undefined;
9+
};
10+
11+
export function createRequestController(params: CreateRequestControllerParams): RequestController {
12+
const timeoutController = new AbortController();
13+
14+
const timeoutId = setTimeout(() => {
15+
timeoutController.abort();
16+
}, params.timeout);
17+
18+
if (!params.signal) {
19+
return {
20+
signal: timeoutController.signal,
21+
cleanup: () => {
22+
clearTimeout(timeoutId);
23+
},
24+
};
25+
}
26+
27+
if (params.signal.aborted) {
28+
timeoutController.abort();
29+
}
30+
31+
const abortOnExternalSignal = () => {
32+
timeoutController.abort();
33+
};
34+
35+
params.signal.addEventListener('abort', abortOnExternalSignal, { once: true });
36+
37+
return {
38+
signal: timeoutController.signal,
39+
cleanup: () => {
40+
clearTimeout(timeoutId);
41+
params.signal?.removeEventListener('abort', abortOnExternalSignal);
42+
},
43+
};
44+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { HeadersMap } from '../types/common';
2+
import type { RequestConfig } from '../types/request';
3+
4+
export type ExecutionContext = {
5+
request: RequestConfig;
6+
url: URL;
7+
headers: HeadersMap;
8+
attempt: number;
9+
10+
// future lifecycle fields
11+
requestId: string;
12+
startedAt: number;
13+
};
14+
15+
type CreateExecutionContextParams = {
16+
request: RequestConfig;
17+
url: URL;
18+
headers: HeadersMap;
19+
attempt: number;
20+
};
21+
22+
function generateRequestId(): string {
23+
return Math.random().toString(36).slice(2);
24+
}
25+
26+
export function createExecutionContext(params: CreateExecutionContextParams): ExecutionContext {
27+
return {
28+
request: params.request,
29+
url: params.url,
30+
headers: params.headers,
31+
attempt: params.attempt,
32+
33+
requestId: generateRequestId(),
34+
startedAt: Date.now(),
35+
};
36+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { AfterResponseContext, BeforeRequestContext, ErrorContext } from '../types/hooks';
2+
import type { ExecutionContext } from './execution-context';
3+
4+
export function createBeforeRequestContext(execution: ExecutionContext): BeforeRequestContext {
5+
return {
6+
request: execution.request,
7+
url: execution.url,
8+
headers: execution.headers,
9+
};
10+
}
11+
12+
export function createAfterResponseContext<T>(
13+
execution: ExecutionContext,
14+
response: Response,
15+
data: T,
16+
): AfterResponseContext<T> {
17+
return {
18+
request: execution.request,
19+
url: execution.url,
20+
headers: execution.headers,
21+
response,
22+
data,
23+
};
24+
}
25+
26+
export function createErrorContext(execution: ExecutionContext, error: Error): ErrorContext {
27+
return {
28+
request: execution.request,
29+
url: execution.url,
30+
headers: execution.headers,
31+
error,
32+
};
33+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { HttpError } from '../errors/http-error';
2+
import { NetworkError } from '../errors/network-error';
3+
import { TimeoutError } from '../errors/timeout-error';
4+
5+
export function normalizeError(error: unknown, timeout: number): Error {
6+
if (error instanceof HttpError) {
7+
return error;
8+
}
9+
10+
if (error instanceof Error && error.name === 'AbortError') {
11+
return new TimeoutError(timeout, error);
12+
}
13+
14+
return new NetworkError('Network request failed', error);
15+
}

packages/client/src/core/request.ts

Lines changed: 46 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,31 @@
11
import { HttpError } from '../errors/http-error';
22
import { NetworkError } from '../errors/network-error';
3-
import { TimeoutError } from '../errors/timeout-error';
43
import type { HeadersMap } from '../types/common';
5-
import type { ClientConfig, RetryConfig } from '../types/config';
4+
import type { ClientConfig } from '../types/config';
65
import type { RequestConfig } from '../types/request';
76
import { applyAuth } from './apply-auth';
87
import { buildUrl } from './build-url';
8+
import { applyRequestMetadata } from './apply-request-metadata';
9+
import { createExecutionContext } from './execution-context';
10+
import { createRequestController } from './create-request-controller';
11+
import {
12+
createAfterResponseContext,
13+
createBeforeRequestContext,
14+
createErrorContext,
15+
} from './hook-context';
916
import { getRetryDelay } from './get-retry-delay';
17+
import { normalizeError } from './normalize-error';
1018
import { parseResponse } from './parse-response';
19+
import { resolveRuntimeConfig } from './resolve-runtime-config';
1120
import { runHooks, runHooksSafely } from './run-hooks';
1221
import { shouldRetry } from './should-retry';
13-
14-
const DEFAULT_TIMEOUT = 5000;
15-
16-
const DEFAULT_RETRY: Required<RetryConfig> = {
17-
attempts: 0,
18-
backoff: 'exponential',
19-
baseDelayMs: 300,
20-
retryOn: ['network-error', '5xx'],
21-
retryMethods: ['GET', 'PUT', 'DELETE'],
22-
};
23-
24-
function normalizeError(error: unknown, timeout: number): Error {
25-
if (error instanceof HttpError) {
26-
return error;
27-
}
28-
29-
if (error instanceof Error && error.name === 'AbortError') {
30-
return new TimeoutError(timeout, error);
31-
}
32-
33-
return new NetworkError('Network request failed', error);
34-
}
35-
36-
function sleep(ms: number): Promise<void> {
37-
return new Promise((resolve) => {
38-
setTimeout(resolve, ms);
39-
});
40-
}
22+
import { sleep } from './sleep';
4123

4224
export async function request<T>(
4325
clientConfig: ClientConfig,
4426
requestConfig: RequestConfig,
4527
): Promise<T> {
46-
const fetchImpl = clientConfig.fetch ?? globalThis.fetch;
47-
48-
if (!fetchImpl) {
49-
throw new Error('No fetch implementation available');
50-
}
51-
52-
const timeout = requestConfig.timeout ?? clientConfig.timeout ?? DEFAULT_TIMEOUT;
53-
54-
const retry: Required<RetryConfig> = {
55-
...DEFAULT_RETRY,
56-
...(clientConfig.retry ?? {}),
57-
...(requestConfig.retry ?? {}),
58-
};
28+
const { fetchImpl, timeout, retry } = resolveRuntimeConfig(clientConfig, requestConfig);
5929

6030
const url = new URL(buildUrl(clientConfig.baseUrl, requestConfig.path, requestConfig.query));
6131

@@ -68,50 +38,55 @@ export async function request<T>(
6838
...(requestConfig.headers ?? {}),
6939
};
7040

71-
await applyAuth({
72-
auth: clientConfig.auth,
41+
const execution = createExecutionContext({
7342
request: requestConfig,
7443
url,
7544
headers,
45+
attempt,
7646
});
7747

78-
await runHooks(clientConfig.hooks?.beforeRequest, {
79-
request: requestConfig,
80-
url,
81-
headers,
48+
applyRequestMetadata(execution);
49+
50+
await applyAuth({
51+
auth: clientConfig.auth,
52+
request: execution.request,
53+
url: execution.url,
54+
headers: execution.headers,
8255
});
8356

57+
await runHooks(clientConfig.hooks?.beforeRequest, createBeforeRequestContext(execution));
58+
8459
let body: BodyInit | undefined;
8560

86-
if (requestConfig.body !== undefined) {
87-
if (typeof requestConfig.body === 'string') {
88-
body = requestConfig.body;
61+
if (execution.request.body !== undefined) {
62+
if (typeof execution.request.body === 'string') {
63+
body = execution.request.body;
8964
} else {
90-
headers['content-type'] = headers['content-type'] ?? 'application/json';
91-
body = JSON.stringify(requestConfig.body);
65+
execution.headers['content-type'] = execution.headers['content-type'] ?? 'application/json';
66+
body = JSON.stringify(execution.request.body);
9267
}
9368
}
9469

95-
const controller = new AbortController();
96-
const timeoutId = setTimeout(() => {
97-
controller.abort();
98-
}, timeout);
70+
const requestController = createRequestController({
71+
timeout,
72+
signal: execution.request.signal,
73+
});
9974

10075
let response: Response;
10176
let data: unknown;
10277

10378
try {
10479
const init: RequestInit = {
105-
method: requestConfig.method,
106-
headers,
107-
signal: controller.signal,
80+
method: execution.request.method,
81+
headers: execution.headers,
82+
signal: requestController.signal,
10883
};
10984

11085
if (body !== undefined) {
11186
init.body = body;
11287
}
11388

114-
response = await fetchImpl(url.toString(), init);
89+
response = await fetchImpl(execution.url.toString(), init);
11590
data = await parseResponse(response);
11691

11792
if (!response.ok) {
@@ -122,42 +97,34 @@ export async function request<T>(
12297
lastError = error;
12398

12499
const canRetry = shouldRetry({
125-
attempt,
126-
method: requestConfig.method,
100+
attempt: execution.attempt,
101+
method: execution.request.method,
127102
retry,
128103
error,
129104
});
130105

131106
if (!canRetry) {
132-
await runHooksSafely(clientConfig.hooks?.onError, {
133-
request: requestConfig,
134-
url,
135-
headers,
136-
error,
137-
});
107+
await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error));
138108

139109
throw error;
140110
}
141111

142112
const delay = getRetryDelay({
143-
attempt: attempt + 1,
113+
attempt: execution.attempt + 1,
144114
backoff: retry.backoff,
145115
baseDelayMs: retry.baseDelayMs,
146116
});
147117

148118
await sleep(delay);
149119
continue;
150120
} finally {
151-
clearTimeout(timeoutId);
121+
requestController.cleanup();
152122
}
153123

154-
await runHooks(clientConfig.hooks?.afterResponse, {
155-
request: requestConfig,
156-
url,
157-
headers,
158-
response,
159-
data,
160-
});
124+
await runHooks(
125+
clientConfig.hooks?.afterResponse,
126+
createAfterResponseContext(execution, response, data),
127+
);
161128

162129
return data as T;
163130
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { ClientConfig, RetryConfig } from '../types/config';
2+
import type { RequestConfig } from '../types/request';
3+
4+
const DEFAULT_TIMEOUT = 5000;
5+
6+
const DEFAULT_RETRY: Required<RetryConfig> = {
7+
attempts: 0,
8+
backoff: 'exponential',
9+
baseDelayMs: 300,
10+
retryOn: ['network-error', '5xx'],
11+
retryMethods: ['GET', 'PUT', 'DELETE'],
12+
};
13+
14+
export type ResolvedRuntimeConfig = {
15+
fetchImpl: typeof globalThis.fetch;
16+
timeout: number;
17+
retry: Required<RetryConfig>;
18+
};
19+
20+
export function resolveRuntimeConfig(
21+
clientConfig: ClientConfig,
22+
requestConfig: RequestConfig,
23+
): ResolvedRuntimeConfig {
24+
const fetchImpl = clientConfig.fetch ?? globalThis.fetch;
25+
26+
if (!fetchImpl) {
27+
throw new Error('No fetch implementation available');
28+
}
29+
30+
return {
31+
fetchImpl,
32+
timeout: requestConfig.timeout ?? clientConfig.timeout ?? DEFAULT_TIMEOUT,
33+
retry: {
34+
...DEFAULT_RETRY,
35+
...(clientConfig.retry ?? {}),
36+
...(requestConfig.retry ?? {}),
37+
},
38+
};
39+
}

0 commit comments

Comments
 (0)