@tstdl/base 0.93.127 → 0.93.128
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/api/client/client.js +45 -9
- package/api/client/tests/api-client.test.d.ts +1 -0
- package/api/client/tests/api-client.test.js +194 -0
- package/api/types.d.ts +34 -2
- package/api/types.js +9 -2
- package/authentication/client/authentication.service.js +30 -11
- package/authentication/client/http-client.middleware.js +10 -3
- package/authentication/server/authentication.service.d.ts +12 -0
- package/authentication/server/authentication.service.js +14 -2
- package/authentication/tests/authentication.client-error-handling.test.js +23 -66
- package/authentication/tests/authentication.client-service-refresh.test.js +14 -14
- package/cancellation/token.d.ts +6 -0
- package/cancellation/token.js +8 -0
- package/http/client/adapters/undici.adapter.js +0 -2
- package/http/client/http-client-request.d.ts +2 -0
- package/http/client/http-client-request.js +4 -0
- package/http/client/http-client-response.d.ts +1 -1
- package/http/client/http-client-response.js +3 -2
- package/http/utils.d.ts +6 -0
- package/http/utils.js +71 -0
- package/injector/injector.js +2 -0
- package/mail/drizzle/0000_numerous_the_watchers.sql +8 -0
- package/mail/drizzle/meta/0000_snapshot.json +1 -32
- package/mail/drizzle/meta/_journal.json +2 -9
- package/object-storage/s3/tests/s3.object-storage.integration.test.js +22 -53
- package/orm/tests/repository-expiration.test.js +3 -3
- package/package.json +1 -1
- package/rate-limit/tests/postgres-rate-limiter.test.js +9 -7
- package/task-queue/tests/complex.test.js +22 -22
- package/task-queue/tests/dependencies.test.js +15 -13
- package/task-queue/tests/queue.test.js +13 -13
- package/task-queue/tests/worker.test.js +12 -12
- package/testing/integration-setup.d.ts +2 -0
- package/testing/integration-setup.js +13 -7
- package/utils/backoff.d.ts +27 -3
- package/utils/backoff.js +31 -9
- package/utils/index.d.ts +1 -0
- package/utils/index.js +1 -0
- package/utils/retry-with-backoff.d.ts +22 -0
- package/utils/retry-with-backoff.js +64 -0
- package/utils/tests/backoff.test.d.ts +1 -0
- package/utils/tests/backoff.test.js +41 -0
- package/utils/tests/retry-with-backoff.test.d.ts +1 -0
- package/utils/tests/retry-with-backoff.test.js +49 -0
- package/mail/drizzle/0000_previous_malcolm_colcord.sql +0 -13
- package/mail/drizzle/0001_flimsy_bloodscream.sql +0 -5
- package/mail/drizzle/meta/0001_snapshot.json +0 -69
|
@@ -70,6 +70,12 @@ describe('AuthenticationClientService Error Handling & Stuck States', () => {
|
|
|
70
70
|
afterEach(async () => {
|
|
71
71
|
await service.dispose();
|
|
72
72
|
});
|
|
73
|
+
function setupServiceWithToken(iatOffset = -10, expOffset = 5) {
|
|
74
|
+
const now = Math.floor(Date.now() / 1000);
|
|
75
|
+
const initialToken = { iat: now + iatOffset, exp: now + expOffset, jti: 'initial' };
|
|
76
|
+
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
77
|
+
service = injector.resolve(AuthenticationClientService);
|
|
78
|
+
}
|
|
73
79
|
test('Corrupt Storage: should handle invalid JSON in storage gracefully', async () => {
|
|
74
80
|
// initialize with corrupt token
|
|
75
81
|
globalThis.localStorage.setItem('AuthenticationService:token', '{ invalid-json');
|
|
@@ -77,84 +83,35 @@ describe('AuthenticationClientService Error Handling & Stuck States', () => {
|
|
|
77
83
|
expect(service.token()).toBeUndefined();
|
|
78
84
|
expect(mockLogger.warn).toHaveBeenCalled(); // Should warn about parse error
|
|
79
85
|
});
|
|
80
|
-
test(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
test.each([
|
|
87
|
+
['InvalidTokenError', new InvalidTokenError()],
|
|
88
|
+
['NotFoundError', new NotFoundError('Session not found')],
|
|
89
|
+
['ForbiddenError', new ForbiddenError()],
|
|
90
|
+
['UnauthorizedError', new UnauthorizedError()],
|
|
91
|
+
['NotSupportedError', new NotSupportedError()],
|
|
92
|
+
['BadRequestError', new BadRequestError()],
|
|
93
|
+
])('Unrecoverable Errors: should logout on %s during refresh', async (_, error) => {
|
|
94
|
+
setupServiceWithToken();
|
|
95
|
+
mockApiClient.refresh.mockRejectedValue(error);
|
|
86
96
|
// Wait for loop to pick up refresh
|
|
87
|
-
await timeout(
|
|
88
|
-
expect(mockApiClient.endSession).toHaveBeenCalled();
|
|
89
|
-
expect(service.token()).toBeUndefined();
|
|
90
|
-
});
|
|
91
|
-
test('Unrecoverable Errors: should logout on NotFoundError during refresh', async () => {
|
|
92
|
-
const now = Math.floor(Date.now() / 1000);
|
|
93
|
-
const initialToken = { exp: now + 5, jti: 'initial' };
|
|
94
|
-
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
95
|
-
service = injector.resolve(AuthenticationClientService);
|
|
96
|
-
mockApiClient.refresh.mockRejectedValue(new NotFoundError('Session not found'));
|
|
97
|
-
await timeout(100);
|
|
98
|
-
expect(mockApiClient.endSession).toHaveBeenCalled();
|
|
99
|
-
expect(service.token()).toBeUndefined();
|
|
100
|
-
});
|
|
101
|
-
test('Unrecoverable Errors: should logout on ForbiddenError during refresh', async () => {
|
|
102
|
-
const now = Math.floor(Date.now() / 1000);
|
|
103
|
-
const initialToken = { exp: now + 5, jti: 'initial' };
|
|
104
|
-
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
105
|
-
service = injector.resolve(AuthenticationClientService);
|
|
106
|
-
mockApiClient.refresh.mockRejectedValue(new ForbiddenError());
|
|
107
|
-
await timeout(100);
|
|
108
|
-
expect(mockApiClient.endSession).toHaveBeenCalled();
|
|
109
|
-
expect(service.token()).toBeUndefined();
|
|
110
|
-
});
|
|
111
|
-
test('Unrecoverable Errors: should logout on UnauthorizedError during refresh', async () => {
|
|
112
|
-
const now = Math.floor(Date.now() / 1000);
|
|
113
|
-
const initialToken = { exp: now + 5, jti: 'initial' };
|
|
114
|
-
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
115
|
-
service = injector.resolve(AuthenticationClientService);
|
|
116
|
-
mockApiClient.refresh.mockRejectedValue(new UnauthorizedError());
|
|
117
|
-
await timeout(100);
|
|
118
|
-
expect(mockApiClient.endSession).toHaveBeenCalled();
|
|
119
|
-
expect(service.token()).toBeUndefined();
|
|
120
|
-
});
|
|
121
|
-
test('Unrecoverable Errors: should logout on NotSupportedError during refresh', async () => {
|
|
122
|
-
const now = Math.floor(Date.now() / 1000);
|
|
123
|
-
const initialToken = { exp: now + 5, jti: 'initial' };
|
|
124
|
-
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
125
|
-
service = injector.resolve(AuthenticationClientService);
|
|
126
|
-
mockApiClient.refresh.mockRejectedValue(new NotSupportedError());
|
|
127
|
-
await timeout(100);
|
|
128
|
-
expect(mockApiClient.endSession).toHaveBeenCalled();
|
|
129
|
-
expect(service.token()).toBeUndefined();
|
|
130
|
-
});
|
|
131
|
-
test('Unrecoverable Errors: should logout on BadRequestError during refresh', async () => {
|
|
132
|
-
const now = Math.floor(Date.now() / 1000);
|
|
133
|
-
const initialToken = { exp: now + 5, jti: 'initial' };
|
|
134
|
-
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
135
|
-
service = injector.resolve(AuthenticationClientService);
|
|
136
|
-
mockApiClient.refresh.mockRejectedValue(new BadRequestError());
|
|
137
|
-
await timeout(100);
|
|
97
|
+
await timeout(20);
|
|
138
98
|
expect(mockApiClient.endSession).toHaveBeenCalled();
|
|
139
99
|
expect(service.token()).toBeUndefined();
|
|
140
100
|
});
|
|
141
101
|
test('Recoverable Errors: should NOT logout on generic Error during refresh', async () => {
|
|
142
|
-
|
|
143
|
-
const initialToken = { exp: now + 5, jti: 'initial' };
|
|
144
|
-
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
145
|
-
service = injector.resolve(AuthenticationClientService);
|
|
102
|
+
setupServiceWithToken();
|
|
146
103
|
mockApiClient.refresh.mockRejectedValue(new Error('Network failure'));
|
|
147
|
-
await timeout(
|
|
104
|
+
await timeout(20);
|
|
148
105
|
// Should NOT have called endSession (logout)
|
|
149
106
|
expect(mockApiClient.endSession).not.toHaveBeenCalled();
|
|
150
107
|
// Token should still be present (retry logic handles it)
|
|
151
108
|
expect(service.token()).toBeDefined();
|
|
109
|
+
// Trigger immediate retry to finish test quickly
|
|
110
|
+
service.requestRefresh();
|
|
111
|
+
await timeout(20);
|
|
152
112
|
});
|
|
153
113
|
test('Logout Failure: should clear local token even if server logout fails', async () => {
|
|
154
|
-
|
|
155
|
-
const initialToken = { exp: now + 1000, jti: 'initial' };
|
|
156
|
-
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
157
|
-
service = injector.resolve(AuthenticationClientService);
|
|
114
|
+
setupServiceWithToken(-1000, 1000);
|
|
158
115
|
mockApiClient.endSession.mockRejectedValue(new Error('Network error during logout'));
|
|
159
116
|
await service.logout();
|
|
160
117
|
expect(mockApiClient.endSession).toHaveBeenCalled();
|
|
@@ -65,59 +65,59 @@ describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
|
65
65
|
test('Zombie Timer: loop should wake up immediately when token changes', async () => {
|
|
66
66
|
// 1. Mock a long expiration
|
|
67
67
|
const now = Math.floor(Date.now() / 1000);
|
|
68
|
-
const initialToken = { exp: now + 3600, jti: 'initial' };
|
|
68
|
+
const initialToken = { iat: now - 3600, exp: now + 3600, jti: 'initial' };
|
|
69
69
|
// Set in storage so initialize() (called by resolve) loads it
|
|
70
70
|
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
71
71
|
service = injector.resolve(AuthenticationClientService);
|
|
72
72
|
// Wait for loop to enter the race condition (wait phase)
|
|
73
|
-
await timeout(
|
|
73
|
+
await timeout(20);
|
|
74
74
|
// 2. Change token
|
|
75
|
-
const newToken = { exp: now + 3600, jti: 'new' };
|
|
75
|
+
const newToken = { iat: now - 1800, exp: now + 3600, jti: 'new' };
|
|
76
76
|
mockApiClient.refresh.mockResolvedValue(newToken);
|
|
77
77
|
service.requestRefresh(); // This should trigger immediate wake up
|
|
78
78
|
// Wait for loop to process
|
|
79
|
-
await timeout(
|
|
79
|
+
await timeout(20);
|
|
80
80
|
expect(mockApiClient.refresh).toHaveBeenCalled();
|
|
81
81
|
});
|
|
82
82
|
test('Forced Refresh Loss: forceRefreshToken should not be cleared on failure', async () => {
|
|
83
83
|
const now = Math.floor(Date.now() / 1000);
|
|
84
|
-
const initialToken = { exp: now + 3600, jti: 'initial' };
|
|
84
|
+
const initialToken = { iat: now - 3600, exp: now + 3600, jti: 'initial' };
|
|
85
85
|
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
86
86
|
service = injector.resolve(AuthenticationClientService);
|
|
87
|
-
await timeout(
|
|
87
|
+
await timeout(20);
|
|
88
88
|
// 1. Mock refresh failure
|
|
89
89
|
mockApiClient.refresh.mockRejectedValue(new Error('Network Error'));
|
|
90
90
|
service.requestRefresh();
|
|
91
91
|
// Wait for loop to attempt refresh and fail
|
|
92
|
-
await timeout(
|
|
92
|
+
await timeout(50);
|
|
93
93
|
expect(mockApiClient.refresh).toHaveBeenCalled();
|
|
94
94
|
expect(service.forceRefreshToken.isSet).toBe(true); // Should STILL be set
|
|
95
95
|
});
|
|
96
96
|
test('Lock Contention Backoff: should wait 5 seconds and not busy-loop', async () => {
|
|
97
97
|
const now = Math.floor(Date.now() / 1000);
|
|
98
|
-
const initialToken = { exp: now + 5, jti: 'initial' }; // Expiring soon
|
|
98
|
+
const initialToken = { iat: now - 3600, exp: now + 5, jti: 'initial' }; // Expiring soon
|
|
99
99
|
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
100
100
|
// 1. Mock lock already held
|
|
101
101
|
mockLock.tryUse.mockResolvedValue(undefined); // lockAcquired = false
|
|
102
102
|
const startTime = Date.now();
|
|
103
103
|
service = injector.resolve(AuthenticationClientService);
|
|
104
|
-
// We expect it to try once, fail to get lock, and then wait
|
|
105
|
-
await timeout(
|
|
104
|
+
// We expect it to try once, fail to get lock, and then wait some time.
|
|
105
|
+
await timeout(50);
|
|
106
106
|
expect(mockLock.tryUse).toHaveBeenCalledTimes(1);
|
|
107
107
|
// Check if it's still waiting (not finished loop)
|
|
108
108
|
const duration = Date.now() - startTime;
|
|
109
|
-
expect(duration).toBeLessThan(
|
|
109
|
+
expect(duration).toBeLessThan(500);
|
|
110
110
|
});
|
|
111
111
|
test('Busy Loop: should not busy loop when forceRefreshToken is set and lock is held', async () => {
|
|
112
112
|
const now = Math.floor(Date.now() / 1000);
|
|
113
|
-
const initialToken = { exp: now + 3600, jti: 'initial' };
|
|
113
|
+
const initialToken = { iat: now - 3600, exp: now + 3600, jti: 'initial' };
|
|
114
114
|
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
115
115
|
// Mock lock already held
|
|
116
116
|
mockLock.tryUse.mockResolvedValue(undefined);
|
|
117
117
|
service = injector.resolve(AuthenticationClientService);
|
|
118
|
-
await timeout(
|
|
118
|
+
await timeout(20);
|
|
119
119
|
service.requestRefresh(); // Set the flag
|
|
120
|
-
await timeout(
|
|
120
|
+
await timeout(50);
|
|
121
121
|
// If it busy loops, this will be much higher than 1.
|
|
122
122
|
expect(mockLock.tryUse.mock.calls.length).toBeLessThan(5);
|
|
123
123
|
});
|
package/cancellation/token.d.ts
CHANGED
|
@@ -46,6 +46,7 @@ export type ConnectConfig = {
|
|
|
46
46
|
* (emits when set).
|
|
47
47
|
*/
|
|
48
48
|
export declare class CancellationSignal implements PromiseLike<void>, Subscribable<void> {
|
|
49
|
+
#private;
|
|
49
50
|
protected readonly _stateSubject: BehaviorSubject<boolean>;
|
|
50
51
|
/**
|
|
51
52
|
* Observable which emits the current state (true for set, false for unset)
|
|
@@ -91,6 +92,11 @@ export declare class CancellationSignal implements PromiseLike<void>, Subscribab
|
|
|
91
92
|
* @internal
|
|
92
93
|
*/
|
|
93
94
|
constructor(stateSubject: BehaviorSubject<boolean>);
|
|
95
|
+
/**
|
|
96
|
+
* Returns a standard `AbortSignal` that is aborted when this token is set.
|
|
97
|
+
* Useful for interoperability with APIs like `fetch`.
|
|
98
|
+
*/
|
|
99
|
+
get abortSignal(): AbortSignal;
|
|
94
100
|
/**
|
|
95
101
|
* Returns a standard `AbortSignal` that is aborted when this token is set.
|
|
96
102
|
* Useful for interoperability with APIs like `fetch`.
|
package/cancellation/token.js
CHANGED
|
@@ -15,6 +15,7 @@ import { isBoolean } from '../utils/type-guards.js';
|
|
|
15
15
|
*/
|
|
16
16
|
export class CancellationSignal {
|
|
17
17
|
_stateSubject;
|
|
18
|
+
#abortSignal;
|
|
18
19
|
/**
|
|
19
20
|
* Observable which emits the current state (true for set, false for unset)
|
|
20
21
|
* and any subsequent state changes.
|
|
@@ -74,6 +75,13 @@ export class CancellationSignal {
|
|
|
74
75
|
constructor(stateSubject) {
|
|
75
76
|
this._stateSubject = stateSubject;
|
|
76
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Returns a standard `AbortSignal` that is aborted when this token is set.
|
|
80
|
+
* Useful for interoperability with APIs like `fetch`.
|
|
81
|
+
*/
|
|
82
|
+
get abortSignal() {
|
|
83
|
+
return (this.#abortSignal ??= this.asAbortSignal());
|
|
84
|
+
}
|
|
77
85
|
/**
|
|
78
86
|
* Returns a standard `AbortSignal` that is aborted when this token is set.
|
|
79
87
|
* Useful for interoperability with APIs like `fetch`.
|
|
@@ -20,7 +20,6 @@ export class UndiciHttpClientAdapterOptions {
|
|
|
20
20
|
;
|
|
21
21
|
let UndiciHttpClientAdapter = class UndiciHttpClientAdapter extends HttpClientAdapter {
|
|
22
22
|
options = inject(UndiciHttpClientAdapterOptions, undefined, { optional: true }) ?? {};
|
|
23
|
-
// eslint-disable-next-line max-lines-per-function, max-statements
|
|
24
23
|
async call(httpClientRequest) {
|
|
25
24
|
let body;
|
|
26
25
|
if (isDefined(httpClientRequest.body?.json)) {
|
|
@@ -64,7 +63,6 @@ let UndiciHttpClientAdapter = class UndiciHttpClientAdapter extends HttpClientAd
|
|
|
64
63
|
const httpClientResponse = new HttpClientResponse({
|
|
65
64
|
request: httpClientRequest,
|
|
66
65
|
statusCode: response.statusCode,
|
|
67
|
-
statusMessage: '?',
|
|
68
66
|
headers: new HttpHeaders(response.headers),
|
|
69
67
|
body: response.body,
|
|
70
68
|
closeHandler: () => response.body.destroy(),
|
|
@@ -34,6 +34,7 @@ export type HttpClientRequestOptions = Partial<TypedOmit<HttpClientRequest, 'url
|
|
|
34
34
|
formData?: HttpFormDataObject | FormData;
|
|
35
35
|
};
|
|
36
36
|
abortSignal?: CancellationSignal;
|
|
37
|
+
priority?: RequestPriority;
|
|
37
38
|
};
|
|
38
39
|
export type HttpRequestCredentials = 'omit' | 'same-origin' | 'include';
|
|
39
40
|
export type HttpClientRequestObject = HttpClientRequestOptions & {
|
|
@@ -95,6 +96,7 @@ export declare class HttpClientRequest implements Disposable {
|
|
|
95
96
|
credentials: HttpRequestCredentials | undefined;
|
|
96
97
|
authorization: HttpRequestAuthorization | undefined;
|
|
97
98
|
body: HttpRequestBody | undefined;
|
|
99
|
+
priority: RequestPriority | undefined;
|
|
98
100
|
/**
|
|
99
101
|
* Request timeout in milliseconds
|
|
100
102
|
* @default 30000
|
|
@@ -61,6 +61,7 @@ export class HttpClientRequest {
|
|
|
61
61
|
credentials;
|
|
62
62
|
authorization;
|
|
63
63
|
body;
|
|
64
|
+
priority;
|
|
64
65
|
/**
|
|
65
66
|
* Request timeout in milliseconds
|
|
66
67
|
* @default 30000
|
|
@@ -101,6 +102,7 @@ export class HttpClientRequest {
|
|
|
101
102
|
this.authorization = requestOptions.authorization;
|
|
102
103
|
this.body = normalizeBody(requestOptions.body);
|
|
103
104
|
this.credentials = requestOptions.credentials;
|
|
105
|
+
this.priority = requestOptions.priority;
|
|
104
106
|
this.timeout = requestOptions.timeout ?? 30000;
|
|
105
107
|
this.throwOnNon200 = requestOptions.throwOnNon200 ?? true;
|
|
106
108
|
this.context = requestOptions.context ?? {};
|
|
@@ -122,6 +124,7 @@ export class HttpClientRequest {
|
|
|
122
124
|
request.authorization = clone(request.authorization, true);
|
|
123
125
|
request.urlParameters = new HttpUrlParameters(request.urlParameters);
|
|
124
126
|
request.body = normalizeBody(request.body);
|
|
127
|
+
request.priority = this.priority;
|
|
125
128
|
return request;
|
|
126
129
|
}
|
|
127
130
|
asObject() {
|
|
@@ -135,6 +138,7 @@ export class HttpClientRequest {
|
|
|
135
138
|
query: this.query.asObject(),
|
|
136
139
|
authorization: this.authorization,
|
|
137
140
|
body,
|
|
141
|
+
priority: this.priority,
|
|
138
142
|
timeout: this.timeout,
|
|
139
143
|
throwOnNon200: this.throwOnNon200,
|
|
140
144
|
context: this.context,
|
|
@@ -9,7 +9,7 @@ export type HttpClientResponseObject = TypedOmit<HttpClientResponse, 'hasBody' |
|
|
|
9
9
|
export type HttpClientResponseOptions = {
|
|
10
10
|
request: HttpClientRequest;
|
|
11
11
|
statusCode: number;
|
|
12
|
-
statusMessage
|
|
12
|
+
statusMessage?: string | null;
|
|
13
13
|
headers: HttpHeadersObject | HttpHeaders;
|
|
14
14
|
body: HttpBody | HttpBodySource;
|
|
15
15
|
closeHandler: () => void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isString } from '../../utils/type-guards.js';
|
|
2
2
|
import { HttpBody } from '../http-body.js';
|
|
3
3
|
import { HttpHeaders } from '../http-headers.js';
|
|
4
|
+
import { getHttpStatusMessage } from '../utils.js';
|
|
4
5
|
export class HttpClientResponse {
|
|
5
6
|
closeHandler;
|
|
6
7
|
request;
|
|
@@ -14,7 +15,7 @@ export class HttpClientResponse {
|
|
|
14
15
|
constructor(options) {
|
|
15
16
|
this.request = options.request;
|
|
16
17
|
this.statusCode = options.statusCode;
|
|
17
|
-
this.statusMessage = (isString(options.statusMessage) && (options.statusMessage.length > 0)) ? options.statusMessage :
|
|
18
|
+
this.statusMessage = (isString(options.statusMessage) && (options.statusMessage.length > 0)) ? options.statusMessage : getHttpStatusMessage(this.statusCode);
|
|
18
19
|
this.headers = new HttpHeaders(options.headers);
|
|
19
20
|
this.body = (options.body instanceof HttpBody) ? options.body : new HttpBody(options.body, this.headers);
|
|
20
21
|
this.closeHandler = options.closeHandler;
|
|
@@ -28,7 +29,7 @@ export class HttpClientResponse {
|
|
|
28
29
|
statusCode: this.statusCode,
|
|
29
30
|
statusMessage: this.statusMessage,
|
|
30
31
|
headers: this.headers.asObject(),
|
|
31
|
-
body: this.body
|
|
32
|
+
body: this.body,
|
|
32
33
|
};
|
|
33
34
|
return obj;
|
|
34
35
|
}
|
package/http/utils.d.ts
CHANGED
|
@@ -15,4 +15,10 @@ export declare function readBodyAsTextStream(body: Body, headers: HttpHeaders, o
|
|
|
15
15
|
export declare function readBodyAsJson(body: Body, headers: HttpHeaders, options?: ReadBodyAsJsonOptions): Promise<UndefinableJson>;
|
|
16
16
|
export declare function readBody(body: Body, headers: HttpHeaders, options?: ReadBodyOptions): Promise<string | UndefinableJson | Uint8Array>;
|
|
17
17
|
export declare function readBodyAsStream(body: Body, headers: HttpHeaders, options?: ReadBodyOptions): ReadableStream<string> | ReadableStream<Uint8Array>;
|
|
18
|
+
/**
|
|
19
|
+
* Returns the standard HTTP status message for a given status code.
|
|
20
|
+
* @param statusCode The HTTP status code.
|
|
21
|
+
* @returns The standard status message, or 'Unknown' if the code is not recognized.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getHttpStatusMessage(statusCode: number): string;
|
|
18
24
|
export {};
|
package/http/utils.js
CHANGED
|
@@ -104,3 +104,74 @@ function ensureSize(length, options) {
|
|
|
104
104
|
throw MaxBytesExceededError.fromBytes(options.maxBytes);
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
+
const httpStatusMessages = {
|
|
108
|
+
100: 'Continue',
|
|
109
|
+
101: 'Switching Protocols',
|
|
110
|
+
102: 'Processing',
|
|
111
|
+
103: 'Early Hints',
|
|
112
|
+
200: 'OK',
|
|
113
|
+
201: 'Created',
|
|
114
|
+
202: 'Accepted',
|
|
115
|
+
203: 'Non-Authoritative Information',
|
|
116
|
+
204: 'No Content',
|
|
117
|
+
205: 'Reset Content',
|
|
118
|
+
206: 'Partial Content',
|
|
119
|
+
207: 'Multi-Status',
|
|
120
|
+
208: 'Already Reported',
|
|
121
|
+
226: 'IM Used',
|
|
122
|
+
300: 'Multiple Choices',
|
|
123
|
+
301: 'Moved Permanently',
|
|
124
|
+
302: 'Found',
|
|
125
|
+
303: 'See Other',
|
|
126
|
+
304: 'Not Modified',
|
|
127
|
+
307: 'Temporary Redirect',
|
|
128
|
+
308: 'Permanent Redirect',
|
|
129
|
+
400: 'Bad Request',
|
|
130
|
+
401: 'Unauthorized',
|
|
131
|
+
402: 'Payment Required',
|
|
132
|
+
403: 'Forbidden',
|
|
133
|
+
404: 'Not Found',
|
|
134
|
+
405: 'Method Not Allowed',
|
|
135
|
+
406: 'Not Acceptable',
|
|
136
|
+
407: 'Proxy Authentication Required',
|
|
137
|
+
408: 'Request Timeout',
|
|
138
|
+
409: 'Conflict',
|
|
139
|
+
410: 'Gone',
|
|
140
|
+
411: 'Length Required',
|
|
141
|
+
412: 'Precondition Failed',
|
|
142
|
+
413: 'Content Too Large',
|
|
143
|
+
414: 'URI Too Long',
|
|
144
|
+
415: 'Unsupported Media Type',
|
|
145
|
+
416: 'Range Not Satisfiable',
|
|
146
|
+
417: 'Expectation Failed',
|
|
147
|
+
418: 'I\'m a teapot',
|
|
148
|
+
421: 'Misdirected Request',
|
|
149
|
+
422: 'Unprocessable Content',
|
|
150
|
+
423: 'Locked',
|
|
151
|
+
424: 'Failed Dependency',
|
|
152
|
+
425: 'Too Early',
|
|
153
|
+
426: 'Upgrade Required',
|
|
154
|
+
428: 'Precondition Required',
|
|
155
|
+
429: 'Too Many Requests',
|
|
156
|
+
431: 'Request Header Fields Too Large',
|
|
157
|
+
451: 'Unavailable For Legal Reasons',
|
|
158
|
+
500: 'Internal Server Error',
|
|
159
|
+
501: 'Not Implemented',
|
|
160
|
+
502: 'Bad Gateway',
|
|
161
|
+
503: 'Service Unavailable',
|
|
162
|
+
504: 'Gateway Timeout',
|
|
163
|
+
505: 'HTTP Version Not Supported',
|
|
164
|
+
506: 'Variant Also Negotiates',
|
|
165
|
+
507: 'Insufficient Storage',
|
|
166
|
+
508: 'Loop Detected',
|
|
167
|
+
510: 'Not Extended',
|
|
168
|
+
511: 'Network Authentication Required',
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Returns the standard HTTP status message for a given status code.
|
|
172
|
+
* @param statusCode The HTTP status code.
|
|
173
|
+
* @returns The standard status message, or 'Unknown' if the code is not recognized.
|
|
174
|
+
*/
|
|
175
|
+
export function getHttpStatusMessage(statusCode) {
|
|
176
|
+
return httpStatusMessages[statusCode] ?? `Unknown (${statusCode})`;
|
|
177
|
+
}
|
package/injector/injector.js
CHANGED
|
@@ -7,6 +7,7 @@ import { DeferredPromise } from '../promise/deferred-promise.js';
|
|
|
7
7
|
import { reflectionRegistry } from '../reflection/registry.js';
|
|
8
8
|
import { toArray } from '../utils/array/array.js';
|
|
9
9
|
import { FactoryMap } from '../utils/factory-map.js';
|
|
10
|
+
import { noop } from '../utils/noop.js';
|
|
10
11
|
import { ForwardRef } from '../utils/object/forward-ref.js';
|
|
11
12
|
import { objectEntries } from '../utils/object/object.js';
|
|
12
13
|
import { assert, isArray, isDefined, isFunction, isNotNull, isNotObject, isNull, isPromise, isUndefined } from '../utils/type-guards.js';
|
|
@@ -597,6 +598,7 @@ function newInternalResolveContext(injector) {
|
|
|
597
598
|
forwardRefs: new Set(),
|
|
598
599
|
$done: new DeferredPromise(),
|
|
599
600
|
};
|
|
601
|
+
void context.$done.catch(noop); // prevent unhandled rejection if no one is awaiting it
|
|
600
602
|
return context;
|
|
601
603
|
}
|
|
602
604
|
function postProcess(context) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "
|
|
2
|
+
"id": "0c48afa4-9ab0-4965-a93e-05a6c1b88e58",
|
|
3
3
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
4
4
|
"version": "7",
|
|
5
5
|
"dialect": "postgresql",
|
|
@@ -44,37 +44,6 @@
|
|
|
44
44
|
"type": "text[]",
|
|
45
45
|
"primaryKey": false,
|
|
46
46
|
"notNull": false
|
|
47
|
-
},
|
|
48
|
-
"revision": {
|
|
49
|
-
"name": "revision",
|
|
50
|
-
"type": "integer",
|
|
51
|
-
"primaryKey": false,
|
|
52
|
-
"notNull": true
|
|
53
|
-
},
|
|
54
|
-
"revision_timestamp": {
|
|
55
|
-
"name": "revision_timestamp",
|
|
56
|
-
"type": "timestamp with time zone",
|
|
57
|
-
"primaryKey": false,
|
|
58
|
-
"notNull": true
|
|
59
|
-
},
|
|
60
|
-
"create_timestamp": {
|
|
61
|
-
"name": "create_timestamp",
|
|
62
|
-
"type": "timestamp with time zone",
|
|
63
|
-
"primaryKey": false,
|
|
64
|
-
"notNull": true
|
|
65
|
-
},
|
|
66
|
-
"delete_timestamp": {
|
|
67
|
-
"name": "delete_timestamp",
|
|
68
|
-
"type": "timestamp with time zone",
|
|
69
|
-
"primaryKey": false,
|
|
70
|
-
"notNull": false
|
|
71
|
-
},
|
|
72
|
-
"attributes": {
|
|
73
|
-
"name": "attributes",
|
|
74
|
-
"type": "jsonb",
|
|
75
|
-
"primaryKey": false,
|
|
76
|
-
"notNull": true,
|
|
77
|
-
"default": "'{}'::jsonb"
|
|
78
47
|
}
|
|
79
48
|
},
|
|
80
49
|
"indexes": {},
|
|
@@ -5,15 +5,8 @@
|
|
|
5
5
|
{
|
|
6
6
|
"idx": 0,
|
|
7
7
|
"version": "7",
|
|
8
|
-
"when":
|
|
9
|
-
"tag": "
|
|
10
|
-
"breakpoints": true
|
|
11
|
-
},
|
|
12
|
-
{
|
|
13
|
-
"idx": 1,
|
|
14
|
-
"version": "7",
|
|
15
|
-
"when": 1761124592125,
|
|
16
|
-
"tag": "0001_flimsy_bloodscream",
|
|
8
|
+
"when": 1771240070681,
|
|
9
|
+
"tag": "0000_numerous_the_watchers",
|
|
17
10
|
"breakpoints": true
|
|
18
11
|
}
|
|
19
12
|
]
|
|
@@ -1,27 +1,20 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
2
|
import { setupIntegrationTest } from '../../../testing/index.js';
|
|
3
3
|
import { readBinaryStream } from '../../../utils/stream/stream-reader.js';
|
|
4
|
-
import {
|
|
4
|
+
import { isUndefined } from '../../../utils/type-guards.js';
|
|
5
5
|
import { S3ObjectStorage } from '../s3.object-storage.js';
|
|
6
6
|
describe('S3ObjectStorage Integration', () => {
|
|
7
7
|
let storage;
|
|
8
|
-
const bucketName = 'integration-test-bucket';
|
|
9
8
|
beforeAll(async () => {
|
|
10
9
|
const { injector } = await setupIntegrationTest({
|
|
11
10
|
modules: { objectStorage: true },
|
|
12
11
|
});
|
|
13
|
-
configureS3ObjectStorage({
|
|
14
|
-
endpoint: 'http://127.0.0.1:9000',
|
|
15
|
-
accessKey: 'tstdl-dev',
|
|
16
|
-
secretKey: 'tstdl-dev',
|
|
17
|
-
bucket: bucketName,
|
|
18
|
-
region: 'us-east-1',
|
|
19
|
-
forcePathStyle: true,
|
|
20
|
-
injector,
|
|
21
|
-
});
|
|
22
12
|
storage = await injector.resolveAsync(S3ObjectStorage, 'test-module');
|
|
23
13
|
});
|
|
24
14
|
afterAll(async () => {
|
|
15
|
+
if (isUndefined(storage)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
25
18
|
const objects = await storage.getObjects();
|
|
26
19
|
for (const obj of objects) {
|
|
27
20
|
await storage.deleteObject(obj.key);
|
|
@@ -80,7 +73,7 @@ describe('S3ObjectStorage Integration', () => {
|
|
|
80
73
|
const key = 'signed-download.txt';
|
|
81
74
|
await storage.uploadObject(key, new TextEncoder().encode('signed download'));
|
|
82
75
|
const url = await storage.getDownloadUrl(key, Date.now() + 60000);
|
|
83
|
-
expect(url).
|
|
76
|
+
expect(url).toMatch(/http:\/\/(127\.0\.0\.1|localhost):9000/);
|
|
84
77
|
const response = await fetch(url);
|
|
85
78
|
expect(response.status).toBe(200);
|
|
86
79
|
expect(await response.text()).toBe('signed download');
|
|
@@ -154,15 +147,8 @@ describe('S3ObjectStorage Integration', () => {
|
|
|
154
147
|
expect(stat.metadata['extra-key']).toBe('extra-value');
|
|
155
148
|
});
|
|
156
149
|
it('should work with bucket per module', async () => {
|
|
157
|
-
const { injector } = await setupIntegrationTest(
|
|
158
|
-
|
|
159
|
-
endpoint: 'http://127.0.0.1:9000',
|
|
160
|
-
accessKey: 'tstdl-dev',
|
|
161
|
-
secretKey: 'tstdl-dev',
|
|
162
|
-
bucketPerModule: true,
|
|
163
|
-
region: 'us-east-1',
|
|
164
|
-
forcePathStyle: true,
|
|
165
|
-
injector,
|
|
150
|
+
const { injector } = await setupIntegrationTest({
|
|
151
|
+
modules: { objectStorage: true },
|
|
166
152
|
});
|
|
167
153
|
const moduleName = `test-bucket-per-module-${Math.floor(Math.random() * 1000000)}`;
|
|
168
154
|
const perModuleStorage = await injector.resolveAsync(S3ObjectStorage, moduleName);
|
|
@@ -177,7 +163,7 @@ describe('S3ObjectStorage Integration', () => {
|
|
|
177
163
|
const metadata = { 's3-test': 'true' };
|
|
178
164
|
await storage.uploadObject(key, content, { metadata });
|
|
179
165
|
const obj = await storage.getObject(key);
|
|
180
|
-
expect(await obj.getResourceUri()).toBe(`s3://
|
|
166
|
+
expect(await obj.getResourceUri()).toBe(`s3://test-module/${key}`);
|
|
181
167
|
expect(await obj.getContentLength()).toBe(content.length);
|
|
182
168
|
expect(await obj.getMetadata()).toMatchObject(metadata);
|
|
183
169
|
expect(new TextDecoder().decode(await obj.getContent())).toBe('s3 object');
|
|
@@ -242,25 +228,22 @@ describe('S3ObjectStorage Integration', () => {
|
|
|
242
228
|
const key = 'signed-download-expires.txt';
|
|
243
229
|
await storage.uploadObject(key, new TextEncoder().encode('signed download expires'));
|
|
244
230
|
const url = await storage.getDownloadUrl(key, Date.now() + 60000, {
|
|
245
|
-
|
|
231
|
+
Expires: new Date(Date.now() + 60000).toUTCString(),
|
|
246
232
|
});
|
|
247
|
-
expect(url).
|
|
233
|
+
expect(url).toMatch(/http:\/\/(127\.0\.0\.1|localhost):9000/);
|
|
248
234
|
const response = await fetch(url);
|
|
249
235
|
expect(response.status).toBe(200);
|
|
250
236
|
});
|
|
251
237
|
it('should handle Forbidden error in ensureBucketExists with wrong credentials', async () => {
|
|
252
|
-
const { injector } = await setupIntegrationTest(
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
region: 'us-east-1',
|
|
259
|
-
forcePathStyle: true,
|
|
260
|
-
injector,
|
|
238
|
+
const { injector } = await setupIntegrationTest({
|
|
239
|
+
modules: { objectStorage: true },
|
|
240
|
+
s3: {
|
|
241
|
+
accessKey: 'wrong',
|
|
242
|
+
secretKey: 'wrong',
|
|
243
|
+
},
|
|
261
244
|
});
|
|
262
245
|
try {
|
|
263
|
-
await injector.resolveAsync(S3ObjectStorage, '
|
|
246
|
+
await injector.resolveAsync(S3ObjectStorage, 'forbidden-bucket');
|
|
264
247
|
expect.fail('Should have thrown');
|
|
265
248
|
}
|
|
266
249
|
catch (error) {
|
|
@@ -268,17 +251,10 @@ describe('S3ObjectStorage Integration', () => {
|
|
|
268
251
|
}
|
|
269
252
|
});
|
|
270
253
|
it('should copy object between different storages', async () => {
|
|
271
|
-
const { injector } = await setupIntegrationTest(
|
|
272
|
-
|
|
273
|
-
endpoint: 'http://127.0.0.1:9000',
|
|
274
|
-
accessKey: 'tstdl-dev',
|
|
275
|
-
secretKey: 'tstdl-dev',
|
|
276
|
-
bucket: 'another-bucket',
|
|
277
|
-
region: 'us-east-1',
|
|
278
|
-
forcePathStyle: true,
|
|
279
|
-
injector,
|
|
254
|
+
const { injector } = await setupIntegrationTest({
|
|
255
|
+
modules: { objectStorage: true },
|
|
280
256
|
});
|
|
281
|
-
const anotherStorage = await injector.resolveAsync(S3ObjectStorage, 'another-
|
|
257
|
+
const anotherStorage = await injector.resolveAsync(S3ObjectStorage, 'another-bucket');
|
|
282
258
|
const sourceKey = 'cross-storage-source.txt';
|
|
283
259
|
const destKey = 'cross-storage-dest.txt';
|
|
284
260
|
await storage.uploadObject(sourceKey, new TextEncoder().encode('cross storage content'));
|
|
@@ -288,15 +264,8 @@ describe('S3ObjectStorage Integration', () => {
|
|
|
288
264
|
expect(new TextDecoder().decode(content)).toBe('cross storage content');
|
|
289
265
|
});
|
|
290
266
|
it('should cover ensureBucketExists with region', async () => {
|
|
291
|
-
const { injector } = await setupIntegrationTest(
|
|
292
|
-
|
|
293
|
-
endpoint: 'http://127.0.0.1:9000',
|
|
294
|
-
accessKey: 'tstdl-dev',
|
|
295
|
-
secretKey: 'tstdl-dev',
|
|
296
|
-
bucketPerModule: true,
|
|
297
|
-
region: 'us-east-1',
|
|
298
|
-
forcePathStyle: true,
|
|
299
|
-
injector,
|
|
267
|
+
const { injector } = await setupIntegrationTest({
|
|
268
|
+
modules: { objectStorage: true },
|
|
300
269
|
});
|
|
301
270
|
const perModuleStorage = await injector.resolveAsync(S3ObjectStorage, `region-test-${Math.floor(Math.random() * 1000000)}`);
|
|
302
271
|
await perModuleStorage.ensureBucketExists('us-east-1', { objectLocking: true });
|